コンテナ上のマイクロサービスの認証強化 ~StrimziとKeycloak~

2021年2月16日(火)
田畑 義之
前回に引き続き、マイクロサービスの認証強化を実現する最先端の機能を紹介します。

第七回は、前回に引き続き、内部向けのAPIであるマイクロサービスの認証を強化する最先端の機能を紹介します。

前回と同様、すべてのサービスをKubernetes(Minikube)上にデプロイする構成とし、堅牢化の対象としてIstioが提供しているBookinfoアプリケーションを取り上げます(図1)。

図1:システム構成

図1:システム構成

使用する各製品のバージョンを表1に示します。

表1:使用する製品とバージョン

製品バージョン
Minikubev1.13.1
  (Kubernetes)v1.19.2
  (Docker)19.03.8
Keycloak11.0.3
Istio1.7.4
Quarkus1.9.2.Final
Strimzi0.20.0

なお、Kubernetes環境(Minikube)はドキュメント等を参考にすでにインストールされ、動作しているものとします。また、前回と前々回の記事でご紹介した、Keycloak、Istio、Quarkusの導入/各種設定はなされているものとします。

StrimziとKeycloak

Strimziとは、Kubernetes上でApache Kafkaのクラスタを管理するオペレータです。Strimziを使用することで、Kubernetes上でのKafkaクラスタの構築/運用を簡素化できます。ここではStrimzi(Kafka)とKeycloakを組み合わせた堅牢化方法をご紹介します(図2)。

図2:StrimziとKeycloakを用いたBookinfoアプリケーションの堅牢化

図2:StrimziとKeycloakを用いたBookinfoアプリケーションの堅牢化

前回までは、暗黙のルールとして、各サービス間のデータのやり取りをREST APIで実現していました。確かにマイクロサービスにおいても、各サービス間のデータのやり取りにはREST APIの使用が主流です。しかし課題もあります(図3)。それはパフォーマンスです。一般的にREST APIによるデータのやり取りは、同期でのやり取りとなるため、それが複数のサービスに伝播すればするほど、パフォーマンスに影響が出てくる可能性があります。

図3:REST APIの課題

図3:REST APIの課題

上記の課題に対し、マイクロサービスでは、Kafkaに代表される非同期APIというものが脚光を浴びています。非同期APIを用いることで、たとえやり取りが複数のサービスに伝播したとしても、パフォーマンスへの影響を小さく抑えることができます(図4)。

図4:非同期API

図4:非同期API

今回は、例としてDetailsサービスのv2をベースに非同期APIによるデータのやり取りを組み込んだDetailsサービスのv3をQuarkusで作成し、そのQuarkusアプリケーションを例にsmallrye-reactive-messaging-kafkaエクステンションとKeycloakを使ったクライアント認証方法を説明します。

StrimziのインストールとKafkaクラスタの作成

まずは、Strimziをインストールしましょう。

リスト1:Strimziをインストール

$ wget https://github.com/strimzi/strimzi-kafka-operator/releases/download/0.20.0/strimzi-0.20.0.zip
$ apt install unzip
$ unzip strimzi-0.20.0.zip
$ cd strimzi-0.20.0/
$ kubectl create namespace kafka
$ sed -i 's/namespace: .*/namespace: kafka/' install/cluster-operator/*RoleBinding*.yaml
$ kubectl create namespace my-kafka-project

一部(install/cluster-operator/060-Deployment-strimzi-cluster-operator.yaml)設定を書き換えます。

リスト2:設定を書き換え

    env:
      - name: STRIMZI_NAMESPACE
        value: my-kafka-project           // この行を追加
        valueFrom:                        // 以下3行を削除
          fieldRef:
            fieldPath: metadata.namespace // ここまで削除

Kubernetesにデプロイします。

リスト3:Kubernetesにデプロイ

$ kubectl apply -f install/cluster-operator/ -n kafka
$ kubectl apply -f install/cluster-operator/020-RoleBinding-strimzi-cluster-operator.yaml -n my-kafka-project
$ kubectl apply -f install/cluster-operator/032-RoleBinding-strimzi-cluster-operator-topic-operator-delegation.yaml -n my-kafka-project
$ kubectl apply -f install/cluster-operator/031-RoleBinding-strimzi-cluster-operator-entity-operator-delegation.yaml -n my-kafka-project

次に、Kafkaクラスタを作成します。以下のようなCustomResourceDefinition(kafka.yaml)を作成します。

リスト4:Kafkaクラスタの作成

apiVersion: kafka.strimzi.io/v1beta1
kind: Kafka
metadata:
  name: my-cluster
spec:
  kafka:
    replicas: 1
    listeners:
      plain:
        port: 9092
        type: internal
        tls: false
      tls:
        port: 9093
        type: internal
        tls: true
        authentication:
          type: tls
      external:
        port: 9094
        type: nodeport
        tls: false
    storage:
      type: jbod
      volumes:
      - id: 0
        type: persistent-claim
        size: 100Mi
        deleteClaim: false
    config:
      offsets.topic.replication.factor: 1
      transaction.state.log.replication.factor: 1
      transaction.state.log.min.isr: 1
  zookeeper:
    replicas: 1
    storage:
      type: persistent-claim
      size: 100Mi
      deleteClaim: false
  entityOperator:
    topicOperator: {}
    userOperator: {}

作成したCustomResourceDefinitionを適用します。

リスト5:CustomResourceDefinitionの適用

$ kubectl create -n my-kafka-project -f kafka.yaml

以上で、Kafkaクラスタが作成されました。作成したKafkaクラスタのIPアドレスとポートを確認します。

リスト6:KafakaクラスタのIPアドレスとポートを確認する

$ echo $(minikube ip)
172.30.10.93
$ echo $(kubectl get services/my-cluster-kafka-external-bootstrap -o go-template='{{(index .spec.ports 0).nodePort}}' -n my-kafka-project)
31105

Quarkusアプリケーションの実装

前回実装したDetailsサービスのv2をベースに、Detailsサービスのv3を実装していきます(図5)。/details/0に対するGETリクエストに対して、本の詳細情報をレスポンスする部分はv2と共通ですが、/details/0に対するGETリクエストが走るたびに、メッセージをPublishするProducer部分と、そのメッセージをSubscribeするConsumer部分をv3で新しく実装します。一般的には、Producer部分とConsumer部分は別々のサービスに実装することが多いですが、ここでは簡単のため、両者をDetailsサービスv3に実装します。

図5:Detailsサービスv3のシーケンス

図5:Detailsサービスv3のシーケンス

まずは、既存のプロジェクトにsmallrye-reactive-messaging-kafkaのエクステンションを追加します。

リスト7:smallrye-reactive-messaging-kafakのエクステンションを追加

$ cd security-keycloak-authorization-quickstart/
$ ./mvnw quarkus:add-extension -Dextensions="smallrye-reactive-messaging-kafka"

Producer部分を、security-keycloak-authorization-quickstart/src/main/java/org/acme/security/keycloak/authorization/BooksResource.javaに追加で実装します。

リスト8:Producer部分をBooksResource.javaに追加で実装

…
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
…
import io.smallrye.reactive.messaging.annotations.Broadcast;

@Path("/")
public class BooksResource {
…
    @Inject
    @Broadcast
    @Channel("my-channel-out")
    Emitter<String> myData;
…
    @GET
    @Path("/details/0")
    @NoCache
    public Response details() {
        myData.send(identity.getPrincipal().getName());
        …
    }

}

Consumer部分を、security-keycloak-authorization-quickstart/src/main/java/org/acme/security/keycloak/authorization/SampleConsumer.javaに実装します。

リスト9:Consumer部分をSampleConsumer.javaに実装

package org.acme.security.keycloak.authorization;

import javax.enterprise.context.ApplicationScoped;

import org.eclipse.microprofile.reactive.messaging.Incoming;

@ApplicationScoped
public class SampleConsumer {
    @Incoming("my-channel-in")
    public void consume(String myData) {
        System.out.println("Hello " + myData + " !");
    }
}

application.properties(security-keycloak-authorization-quickstart/src/main/ resources/application.properties)に設定を加えます。

リスト10:application.propertiesに設定追加

…
quarkus.kubernetes.labels.version=v3
…
# Configure the SmallRye Kafka connector
kafka.bootstrap.servers=172.30.10.93:31105

# Configure the Kafka sink (we write to it)
mp.messaging.outgoing.my-channel-out.connector=smallrye-kafka
mp.messaging.outgoing.my-channel-out.topic=book-info
mp.messaging.outgoing.my-channel-out.value.serializer=org.apache.kafka.common.serialization.StringSerializer

# Configure the Kafka source (we read from it)
mp.messaging.incoming.my-channel-in.connector=smallrye-kafka
mp.messaging.incoming.my-channel-in.topic=book-info
mp.messaging.incoming.my-channel-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer

作成したQuarkusアプリケーションをビルドし、Kubernetesにデプロイします。

リスト11:Quarkusアプリケーションのビルド、デプロイ

$ ./mvnw clean package -Dquarkus.kubernetes.deploy=true

DestinationRule(destination-rule-all-mtls-v2.yaml)を書き換えて、Detailsサービスのv3にルーティングするようにします。

リスト12:DestinationRuleの書き換え

…
---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: details
spec:
  host: details
  trafficPolicy:
    tls:
      mode: ISTIO_MUTUAL
  subsets:
  - name: v3
    labels:
      version: v3
---

変更したDestinationRuleを適用します。

リスト13:変更したDestinationRuleの適用

$ kubectl apply -f destination-rule-all-mtls-v2.yaml

以上で、Producer部分とConsumer部分の実装は完了です。admin_userを用いて発行したアクセストークンを付与してBookinfoアプリケーションのProductページにアクセスすると、Productページが問題なく表示されることを確認できます(図6)。

図6:BookinfoアプリケーションのProductページ

図6:BookinfoアプリケーションのProductページ

また、ProducerとConsumerによって問題なくメッセージが送受信されたことも確認できます。

リスト14:メッセージ送受信されていることを確認

$ kubectl logs details-xxx -c details
…
Hello admin_user !

クライアント認証の設定

Kafkaでは、OAuth 2.0のClient Credentials Grantやトークンイントロスペクションをベースとしたクライアント認証を実現することができます(図7)。

図7:Kafkaのクライアント認証の流れ

図7:Kafkaのクライアント認証の流れ

Kafkaクライアント(ProducerやConsumer)は、OAuth 2.0 Client Credentials Grantで発行されたアクセストークンをKafkaクラスタに送ります。Kafkaクラスタは、受け取ったアクセストークンをトークンイントロスペクションで検証し、アクセストークンが有効であれば、Kafkaクライアントとの間にKafkaクライアントセッションを張ります。以降、そのKafkaクライアントセッションを用いて、メッセージをやり取り(Publish/Subscribe)します。実際に設定していきます。まずはClient Credentials Grantでトークンを発行したり、トークンイントロスペクションしたりできるように、Keycloakのリソースを作成します(表 2)。

表2:作成するKeycloakのリソース

リソース設定項目名設定値備考
クライアントClient IDkafka-brokerKafkaクラスタ用クライアント。
 Access Typebearer-only
クライアントClient IDkafka-producerProducer用クライアント。
 Access Typeconfidential
 Standard Flow EnabledOFF
 Direct Access Grants EnabledOFF
 Service Accounts EnabledON
クライアントClient IDkafka-consumerConsumer用クライアント。
 Access Typeconfidential
 Standard Flow EnabledOFF
 Direct Access Grants EnabledOFF
 Service Accounts EnabledON

次に、Kafkaクラスタの設定(kafka.yaml)を変更します。

リスト15:Kafkaクラスタの設定を変更

…
spec:
  kafka:
    …
    listeners:
      …
      external:
        port: 9094
        type: nodeport
        tls: false
        authentication:
          type: oauth
          clientId: kafka-broker
          clientSecret:
            secretName: my-cluster-oauth
            key: clientSecret
          validIssuerUri: http://172.30.10.93:31385/auth/realms/bookinfo
          introspectionEndpointUri: http://172.30.10.93:31385/auth/realms/bookinfo/protocol/openid-connect/token/introspect
          userNameClaim: preferred_username
…

また、Kafkaクラスタ用クライアントのクライアントシークレットを格納するために、Secret(kafka-broker-client-secret.yaml)を作成します。

リスト16:Secretを作成

apiVersion: v1
kind: Secret
metadata:
  name: my-cluster-oauth
type: Opaque
data:
  clientSecret: <Kafkaクラスタ用クライアントのクライアントシークレットをBase64エンコードしたもの>

作成したSecretと変更したKafkaクラスタの設定を適用します。

リスト17:Secret、Kafkaクラスタの設定を適用

$ kubectl apply -n my-kafka-project -f kafka-broker-client-secret.yaml
$ kubectl apply -n my-kafka-project -f kafka.yaml

以上で、Kafkaクラスタ側のクライアント認証設定は完了です。この状態で、admin_userを用いて発行したアクセストークンを付与してBookinfoアプリケーションのProductページにアクセスしてみましょう。Productページは問題なく表示されますが、Consumerからのメッセージは出力されず、接続エラーが出力されることを確認できます。

リスト18:動作の確認

$ kubectl logs details-xxx -c details
…
2020-12-10 08:36:45,003 INFO  [org.apa.kaf.cli.FetchSessionHandler] (kafka-coordinator-heartbeat-thread | dfd2724b-17ff-4dbe-948b-30768b4fe02a) [Consumer clientId=consumer-dfd2724b-17ff-4dbe-948b-30768b4fe02a-1, groupId=dfd2724b-17ff-4dbe-948b-30768b4fe02a] Error sending fetch request (sessionId=284737104, epoch=INITIAL) to node 0: {}.: org.apache.kafka.common.errors.DisconnectException

Detailsサービスに、ProducerとConsumerのクライアント認証の設定を加えていきます。まずは、pom.xml(security-keycloak-authorization-quickstart/pom.xml)の依存関係に、kafka-oauth-clientを追加します。

リスト19:pom.xmlの依存関係にkafka-oauth-clientを追加

…
        <!-- https://mvnrepository.com/artifact/io.strimzi/kafka-oauth-client -->
        <dependency>
            <groupId>io.strimzi</groupId>
            <artifactId>kafka-oauth-client</artifactId>
            <version>0.6.1</version>
        </dependency>
…

次に、application.properties(security-keycloak-authorization-quickstart/src/main/resources/application.properties)に設定を加えます。

リスト20:applicationpropertiesに設定を追加

# Configure the Kafka sink (we write to it)
mp.messaging.outgoing.my-channel-out.connector=smallrye-kafka
mp.messaging.outgoing.my-channel-out.topic=book-info
mp.messaging.outgoing.my-channel-out.value.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.my-channel-out.security.protocol=SASL_PLAINTEXT
mp.messaging.outgoing.my-channel-out.sasl.mechanism=OAUTHBEARER
mp.messaging.outgoing.my-channel-out.sasl.login.callback.handler.class=io.strimzi.kafka.oauth.client.JaasClientOauthLoginCallbackHandler
mp.messaging.outgoing.my-channel-out.sasl.jaas.config=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required \
  oauth.client.id="kafka-producer" \
  oauth.client.secret="<Producer用クライアントのクライアントシークレット>" \
  oauth.token.endpoint.uri="http://172.30.10.93:31385/auth/realms/bookinfo/protocol/openid-connect/token";

# Configure the Kafka source (we read from it)
mp.messaging.incoming.my-channel-in.connector=smallrye-kafka
mp.messaging.incoming.my-channel-in.topic=book-info
mp.messaging.incoming.my-channel-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.my-channel-in.security.protocol=SASL_PLAINTEXT
mp.messaging.incoming.my-channel-in.sasl.mechanism=OAUTHBEARER
mp.messaging.incoming.my-channel-in.sasl.login.callback.handler.class=io.strimzi.kafka.oauth.client.JaasClientOauthLoginCallbackHandler
mp.messaging.incoming.my-channel-in.sasl.jaas.config=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required \
  oauth.client.id="kafka-consumer" \
  oauth.client.secret="<Consumer用クライアントのクライアントシークレット>" \
  oauth.token.endpoint.uri="http://172.30.10.93:31385/auth/realms/bookinfo/protocol/openid-connect/token";

作成したQuarkusアプリケーションをビルドし、Kubernetesにデプロイします。

リスト21:Quarkusアプリケーションのビルド、デプロイ

$ cd security-keycloak-authorization-quickstart/
$ ./mvnw clean package -Dquarkus.kubernetes.deploy=true

以上で、ProducerとConsumerのクライアント認証の設定は完了です。admin_userを用いて発行したアクセストークンを付与してBookinfoアプリケーションのProductページにアクセスしてみましょう。Productページは問題なく表示され、Consumerからメッセージが出力されることを確認できます。

リスト22:動作を確認

$ kubectl logs details-xxx -c details
…
Hello admin_user !

おわりに

本連載では、Keycloakで実現するAPIセキュリティと題し、Keycloakのインストール方法からハードニングの実装方法、内部向けのAPIであるマイクロサービスの認証を強化する最先端の機能をご紹介しました。Keycloakでは、2020年12月現在、FAPIに対する対応を行うFAPI-SIGや、次のKeycloakを考えるKeycloak.Xという活動が活発に推進されています。着実にパワーアップしているKeycloakを、この機会に使ってみてはいかがでしょうか。

株式会社 日立製作所
OSSソリューションセンタにて、API管理や認証周りのOSSの開発/サポート/普及活動に従事。3scaleおよびkeycloakコミュニティのコントリビュータであり、多数のコードをコミットしている。

連載バックナンバー

運用・管理技術解説
第7回

コンテナ上のマイクロサービスの認証強化 ~StrimziとKeycloak~

2021/2/16
前回に引き続き、マイクロサービスの認証強化を実現する最先端の機能を紹介します。
運用・管理技術解説
第6回

コンテナ上のマイクロサービスの認証強化 ~QuarkusとKeycloak~

2021/1/19
連載6回目となる今回は、マイクロサービスの認証強化を実現する最先端の機能を紹介します。
セキュリティ技術解説
第5回

コンテナ上のマイクロサービスの認証強化 ~IstioとKeycloak~

2020/12/15
連載5回目となる今回は、Istioを用いたマイクロサービスのシステムをKeycloakを用いて認証強化する手順を紹介します。

Think ITメルマガ会員登録受付中

Think ITでは、技術情報が詰まったメールマガジン「Think IT Weekly」の配信サービスを提供しています。メルマガ会員登録を済ませれば、メルマガだけでなく、さまざまな限定特典を入手できるようになります。

Think ITメルマガ会員のサービス内容を見る

他にもこの記事が読まれています