はじめに

OSS の IAM (Identity and Access Management) ソフトウェアである Keycloak を AWS 環境上に構築する機会がありましたので紹介します。
長くなったので記事を 3 つに分割しており、今回は 2 つ目です。本記事では Keycloak のコンテナイメージをビルドして ECR にデプロイするところまでを紹介します。

  1. 選定
  2. コンテナのビルド (これ)
  3. 実装とデプロイ

最終的な実装サンプルはこちらになります。blog ブランチが執筆時点に最も近い内容となります。

CDK で簡単に Keycloak を構築する方法

お試しで Keycloak を AWS 環境に構築したいだけであれば、AWS が提供している cdk-keycloak を使うのがおそらく最も簡単です。以下の記述だけで VPC, RDS, Keycloak 搭載 ECS on Fargate が一撃でデプロイできます。

import { Stack, StackProps } from "aws-cdk-lib";
import { KeyCloak, KeycloakVersion } from "cdk-keycloak";
import { Construct } from "constructs";

export class CdkKeycloakTestStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    new KeyCloak(this, "KeyCloak", {
      certificateArn: "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx",
      keycloakVersion: KeycloakVersion.V16_1_1,
    });
  }
}

これだとよくも悪くも抽象化され過ぎているので、もう少し props を定義してみると以下のようになります。

import { Duration, RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import { KeyCloak, KeycloakVersion } from "cdk-keycloak";
import { Construct } from "constructs";

export class CdkKeycloakTestStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    new KeyCloak(this, "KeyCloak", {
      certificateArn: "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx",
      keycloakVersion: KeycloakVersion.V16_1_1,
      auroraServerlessV2: true,
      nodeCount: 2,
      autoScaleTask: {
        min: 2,
        max: 10,
        targetCpuUtilization: 80,
      },
      backupRetention: Duration.days(7),
      bastion: true,
      databaseMinCapacity: 0.5,
      databaseMaxCapacity: 2,
      databaseRemovalPolicy: RemovalPolicy.DESTROY,
      hostname: "auth.example.com",
      internetFacing: true,
    });
  }
}

これだけの記述で Keycloak クラスタが立ち上がります。AWS Secrets Manager も自動で設定してくれるので、クレデンシャルの取り扱いなども安心です (Secret の取得は NAT Gateway 経由ですが)
ただ、本番利用を想定すると以下の点で厳しいと思われます。

  • 設定できる props に限りがあり、ECS のタスク定義やスケーリング設定などで痒いところに手が届かない
  • Version 17 以降の公式 Docker イメージはそのままデプロイしてもエラーとなる (これは自前で構築する場合も変わりませんが)
  • 以下のプロパティしか提供しないため、他リソースとの連携に難がある
Name Type
applicationLoadBalancer aws_elasticloadbalancingv2.ApplicationLoadBalancer
keycloakSecret aws_secretsmanager.ISecret
vpc aws_ec2.IVpc
db? Database

これらの理由から cdk-keycloak の利用は断念し、自前で書くことにしました。
※他に cdk-ecs-keycloak というサードパーティ製のコンストラクトもありますが、試していません。

Keycloak 構築の際の注意点

バージョンの違いによるフレームワークの違い

Keycloak はもともと WildFly という Java 製のアプリケーションサーバーをベースにしていましたが、Version 17 以降は Quarkus というフレームワークが採用されており、設定方法や起動方法が大幅に変更されています。
Quarkus は Kubernetes やサーバーレス環境に適した Java アプリケーションを作るためのフレームワークで、この変更によって起動時間やメモリ消費量の低減が実現したようです。なお、WildFly 版の Keycloak は 2022 年 9 月を持ってサポートが終了しています。
従って、極力 Version 17 以降でデプロイする必要があります。今回は検証時点で最新である 21.1.0 をデプロイする方針とします。

クラスタリングで使用される内部プロトコルの考慮

Keycloak はクラスタモードで動作させる場合、従来は JDBC_PING という Discovery プロトコルで各ノードがお互いを見つけ、クラスタリングを実現するのが一般的でした。Version 16 までは環境変数に JGROUPS_DISCOVERY_PROTOCOL="JDBC_PING" を設定するだけでいい感じにクラスタリングしてくれます。
Version 17 以降でも JDBC_PING を利用できるのですが、デフォルトではこれを実現するスタック構成ファイルがないので、自前でファイルを用意して所定のディレクトリに配置する必要があります。

ドキュメントにある通り他にも利用できるプロトコルがあり、AWS 環境では NATIVE_S3_PING が利用できますし、Google Cloud や Azure でも固有のプロトコルが用意されています。
当初は NATIVE_S3_PING でクラスタを構成する方向性で検証したのですが、mvn package で必要な依存関係をインストールし providers ディレクトリに配置するように構成したコンテナイメージを使っても、ECS のタスクロールに S3 など必要な権限を渡してもうまくいかなかったため断念し、JDBC_PING によるクラスタ構成を採用しました。以下の考え方で、むしろこちらのほうが理に叶っているのではないかと考えています。

  • NATIVE_S3_PING はノード間の通信を S3 バケットのオブジェクトへの読み書きを介して行うが、JDBC_PING は DB の特定のテーブルに対する読み書きを介して行う。DB を RDS で構築し可用性を担保すれば SPOF にはならない
  • NATIVE_S3_PING でもイメージのビルドは結局必要になる。NATIVE_S3_PING の場合は依存関係をインストールしなければならないが JDBC_PING はファイル配置だけで済むのでむしろ難易度が低い
  • S3 バケットを構築せずに済むためコストが節約でき、関連する権限設定も必要ない

コンテナイメージのビルド、デプロイ

Quarkus ベースの Keycloak はどうやら自前でイメージをビルドする必要があるらしいということがわかったので、ではどのようにビルドおよびデプロイするかを考えます。結果として以下の CDK コンストラクトライブラリを利用することにしました。

cdk-docker-image-deployment

これはコンテナイメージをビルドして既存の ECR リポジトリにプッシュするためのもので、使い勝手がいいので採用しました。内部的に CodeBuild を使っていますが、気にする必要がないように設計されています。以下のようなコードでローカルの Dockerfile をもとにコンテナイメージをビルドし、元々用意していた ECR リポジトリにプッシュするところまでをやってくれます。

import { Stack, StackProps, aws_ecr as ecr } from "aws-cdk-lib";
import * as imagedeploy from "cdk-docker-image-deployment";
import { Construct } from "constructs";

export class KeycloakBuildStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const ecrRepo = ecr.Repository.fromRepositoryName(this, "ECRRepository", "ecr-repo/keycloak")
    new imagedeploy.DockerImageDeployment(this, "KeycloakImageDeploy", {
      source: imagedeploy.Source.directory("src/image/keycloak"),
      destination: imagedeploy.Destination.ecr(ecrRepo, {
        tag: "latest"
      }),
    });
  }
}

Dockerfile は以下のようにマルチステージビルドを使いました。ビルダーでは環境変数を定義した上で Keycloak 側のビルドコマンドを実行し、生成されたファイルを最終的なイメージに配置するようにしました。

FROM quay.io/keycloak/keycloak:21.1.0 as builder
ENV KC_DB=mysql
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
ENV KC_CACHE_CONFIG_FILE=cache-ispn-jdbc-ping.xml
COPY ./cache-ispn-jdbc-ping.xml /opt/keycloak/conf/cache-ispn-jdbc-ping.xml
RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:21.1.0
COPY --from=builder /opt/keycloak/lib/quarkus/ /opt/keycloak/lib/quarkus/
COPY --from=builder /opt/keycloak/conf/cache-ispn-jdbc-ping.xml /opt/keycloak/conf
WORKDIR /opt/keycloak
USER keycloak
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

JDBC_PING のための設定ファイルは以下のような xml ファイルです。正直細部は理解できていませんが、データベースのクレデンシャルを環境変数で渡すのがポイントです。

<?xml version="1.0" encoding="UTF-8"?>
<infinispan
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="urn:infinispan:config:11.0 http://www.infinispan.org/schemas/infinispan-config-11.0.xsd"
        xmlns="urn:infinispan:config:11.0">

    <!-- custom stack goes into the jgroups element -->
    <jgroups>
        <stack name="jdbc-ping-tcp" extends="tcp">
            <JDBC_PING connection_driver="com.mysql.cj.jdbc.Driver"
                       connection_username="${env.KC_DB_USERNAME}"
                       connection_password="${env.KC_DB_PASSWORD}"
                       connection_url="${env.KC_DB_URL}"
                       info_writer_sleep_time="500"
                       initialize_sql="CREATE TABLE IF NOT EXISTS JGROUPSPING (own_addr varchar(200) NOT NULL, cluster_name varchar(200) NOT NULL, ping_data VARBINARY(255), constraint PK_JGROUPSPING PRIMARY KEY (own_addr, cluster_name));"
                       remove_all_data_on_view_change="true"
                       stack.combine="REPLACE"
                       stack.position="MPING" />
        </stack>
    </jgroups>

    <cache-container name="keycloak">
        <!-- custom stack must be referenced by name in the stack attribute of the transport element -->
        <transport lock-timeout="60000" stack="jdbc-ping-tcp"/>
        <local-cache name="realms">
            <encoding>
                <key media-type="application/x-java-object"/>
                <value media-type="application/x-java-object"/>
            </encoding>
            <memory max-count="10000"/>
        </local-cache>
        <local-cache name="users">
            <encoding>
                <key media-type="application/x-java-object"/>
                <value media-type="application/x-java-object"/>
            </encoding>
            <memory max-count="10000"/>
        </local-cache>
        <distributed-cache name="sessions" owners="2">
            <expiration lifespan="-1"/>
        </distributed-cache>
        <distributed-cache name="authenticationSessions" owners="2">
            <expiration lifespan="-1"/>
        </distributed-cache>
        <distributed-cache name="offlineSessions" owners="2">
            <expiration lifespan="-1"/>
        </distributed-cache>
        <distributed-cache name="clientSessions" owners="2">
            <expiration lifespan="-1"/>
        </distributed-cache>
        <distributed-cache name="offlineClientSessions" owners="2">
            <expiration lifespan="-1"/>
        </distributed-cache>
        <distributed-cache name="loginFailures" owners="2">
            <expiration lifespan="-1"/>
        </distributed-cache>
        <local-cache name="authorization">
            <encoding>
                <key media-type="application/x-java-object"/>
                <value media-type="application/x-java-object"/>
            </encoding>
            <memory max-count="10000"/>
        </local-cache>
        <replicated-cache name="work">
            <expiration lifespan="-1"/>
        </replicated-cache>
        <local-cache name="keys">
            <encoding>
                <key media-type="application/x-java-object"/>
                <value media-type="application/x-java-object"/>
            </encoding>
            <expiration max-idle="3600000"/>
            <memory max-count="1000"/>
        </local-cache>
        <distributed-cache name="actionTokens" owners="2">
            <encoding>
                <key media-type="application/x-java-object"/>
                <value media-type="application/x-java-object"/>
            </encoding>
            <expiration max-idle="-1" lifespan="-1" interval="300000"/>
            <memory max-count="-1"/>
        </distributed-cache>
    </cache-container>
</infinispan>

しばらく待つと、指定した ECR リポジトリに指定したタグでコンテナイメージがデプロイされます。

ひとつ注意点ですが、cdk deploy を実行する端末に Docker がインストールされている必要があるので、Linux でない場合は Docker Desktop や Rancher Desktop で Docker の動作環境をセットアップしておく必要があります。

今回はやっていませんが、自前のアプリケーションをコンテナで動作させる場合などは、コンテナのビルド/デプロイ用のパイプラインを用意して Trivy によるスキャン、Dockle を用いたベストプラクティスのチェックまで行うとより安心だと思います。機会があれば実装してみたいと思います。

おわりに

Keycloak を構築する際の留意事項と、コンテナイメージのビルド/デプロイ方式について紹介しました。
次回はこれを踏まえて ECS on Fargate を構築します。