📝

AWS環境(ECS-Fargate)におけるkeycloak冗長構成(DNS_PING)

2023/09/03に公開

はじめに

AWS環境(ECS-Fargate)でkeycloakをDNS_PINGを使った冗長構成で立ち上げるサンプルコードです。

全体構成図

Keycloak の クラスタリング

Keycloak では、JGroups, Infinispan というライブラリを利用してクラスタリングを実現しています。

JGroups の Discovery プロトコル

JGroups はクラスタのメンバーとなるホストを見つけるために、Discovery プロトコルというものを利用しています。JGroups では、この Discovery プロトコルとして様々なプロトコルを提供しており、環境や要件に合わせて適切なプロトコルを選ぶことができるようになっています。
今回はDNS_PINGの構成を紹介していきます。

他にもS3_PING, JDBC_PINGなどの方法があります。
http://jgroups.org/manual4/index.html#_s3_ping

DNS_PING

DNS_PINGは DNS の A レコードや SVC レコードを利用してクラスタのメンバーを見つけるプロトコルです。もともとは Kubernetes や OpenShift などの環境向けに作られたのですが、DNS Discovery が利用できる環境であればどこでも利用できます。

AWS ECS環境では、クラスタリング対象となるノードのIPがECS上では検出できない為、
Service DiscoveryとCloud Mapを利用し、Route53上にA レコードを登録してもらい、
サービス検出を可能とします。

ECS Service Discovery周りの設定

[参考]リンク Creating a service using Service Discovery
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/create-service-discovery.html

サービス検出の名前空間の作成

Cloud Mapの名前空間は、一言でいうと「クラウドリソースのサービスディスカバリ」になります。
名前空間は、アプリケーションのサービスをグループ化して、名前空間を作成する際、AWS Cloud Map に登録するサービスインスタンスを検出し、動的にスケールするリソース(ECS、EKSのコンテナ)を登録してくれます。
今回はAPI コールまたは DNS クエリを使用しDNS_PINGを実現します。

Cloud Mapの名前空間を作成します。下記コマンドは、名前空間上に、サービスの検出方法をAPI と DNSとして登録します。

aws servicediscovery create-private-dns-namespace \
      --name tutorial \
      --vpc vpc-abcd1234

サービス検出の設定

API と DNS: サービスは、DiscoverInstances API オペレーションを呼び出すか、DNS クエリを発行することで検出できます。keycloak DNS_PINGはその名の通り、DNS クエリをkeycloak側に設定し、Jgroupのクラスタリングを検出します。

aws servicediscovery create-service \
      --name myapplication \
      --dns-config "NamespaceId="ns-uejictsjen2i4eeg",DnsRecords=[{Type="A",TTL="300"}]" \
      --health-check-custom-config FailureThreshold=1

これにより、ECSでサービスを作成・起動する際に[serviceRegistries]に上記Cloud Mapのサービスを登録する事により、Service Discoveryが可能となります。

ま上記で作成した、サービス名称 + DNS名がkeycloak上で設定する、jgroups.dns.queryの値になります。
今回だとjgroups.dns.quer=myapplication.tutorialとなります(後述:keycloakの環境変数)

AWS コンソール上での確認

AWS Cloud Map > 名前空間から上記で作成した名前空間とサービス、Route53(private)上にレコードが作成されている事が確認できます。

keycloakの環境変数

keycloakのDockerfileは前回記載した記事Keycloak (Quarkus) + Postgres + UserStorage(mysql) Docker Composeで動かすを参照してください。

keycloakでは環境構成によって環境変数の設定が重要になってきます。
環境に合わせて設定を行わないと管理画面が開かないなどよくある事なので、
設定値をよく理解してから構築する事をお勧めします。
参考:keycloak環境変数

環境変数 設定値 説明
KC_PROXY edge プロキシとKeycloak間でHTTPを介した通信を可能にします。このモードは、リバースプロキシがクライアントとのセキュアな接続(HTTP over TLS)を維持しながら、KeycloakとHTTPで通信するような、高度にセキュアな内部ネットワークを持つデプロイメントに適しています。ALBのListenerをHTTPS:443として、keycloakをHTTP:8080で通信するのであれば、この設定は必須となります。
KC_HTTP_PORT 8080 使用するHTTPポート
KC_HTTP_ENABLED true Keycloak QuarkusでHTTPを有効にするオプション。trueに設定されている場合、KeycloakはHTTPスキームを使用してトークンを発行し、 HTTPSスキームのリクエストを拒否します。このオプションがfalseに設定されている場合、KeycloakはHTTPSスキームのリクエストも受け入れますが、HTTPスキームを推奨します。KC_PROXY=edgeにした場合、HTTPでのやり取りになる為、正直いらないかも。。
KC_HOSTNAME_STRICT_HTTPS false trueに設定されている場合、KeycloakはすべてのリダイレクトでHTTPSを使用し、 リクエストがHTTPであった場合でもHTTPSにリダイレクト。上記のKC_HTTP_ENABLEDをtrueにしていると、全てのアクセスが拒否される。その為、private areaにおける通信をすべてHTTPで行う場合は、falseとする。
KC_ADMIN_HOSTNAME auth.example.com hostname オプションに設定した値以外のホスト名を使用して管理コンソールを公開する場合は、このオプションを使用します。
KC_HOSTNAME www.example.com Keycloakサーバーのホスト名として設定
KC_HOSTNAME_STRICT_BACKCHANNEL true 用途によりますが、RPからの接続時にデフォルトでは、バックチャネルのURLはリクエストヘッダから動的に解決され、内部および外部のアプリケーションを許可します。
KC_CACHE ispn 複数のサーバーノード間でキャッシュ共有する際にispnを利用する。
KC_CACHE_STACK kubernetes Keycloakノードの通信にDNS_PINGを使用したい場合は、KC_CACHE_STACKをkubernetesに設定します
JAVA_OPTS_APPEND "-Djgroups.dns.query=myapplication.tutorial" 上記、KC_CACHE 、 KC_CACHE_STACKを設定した上で、jgroups.dns.queryに先程のcloudmapで設定したservice名を設定します。今回はmyapplication.tutorialとなります。

ELB

Enable sticky sessions
https://www.keycloak.org/server/reverseproxy#:~:text=Enable sticky sessions. Typical cluster deployment consists,session to the same Keycloak backend node.

keycloakではパフォーマンス上、ロードバランサーが特定のブラウザセッションに関連するすべてのリクエストを同じKeycloakバックエンドノードに転送するsticky sessionを利用する事を推奨しています。
理由としてはKeycloakが現在の認証セッションとユーザーセッションに関連するデータを保存するために、Infinispan分散キャッシュを背後で利用している為です。
その為、今回はELB上でsticky sessionを有効にしました。
ELB リスナー設定

ECS タスク定義

task definision
{
    "taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:XXXXX:task-definition/keycloak-test:1",
    "containerDefinitions": [
        {
            "name": "keycloak-test",
            "image": "XXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/keycloak-test:v1",
            "cpu": 0,
            "portMappings": [
                {
                    "name": "keycloak-test-8080-tcp",
                    "containerPort": 8080,
                    "hostPort": 8080,
                    "protocol": "tcp",
                    "appProtocol": "http"
                },
                {
                    "name": "keycloak-test-7800-tcp",
                    "containerPort": 7800,
                    "hostPort": 7800,
                    "protocol": "tcp",
                    "appProtocol": "http"
                }
            ],
            "essential": true,
            "command": [
                "--verbose start"
            ],
            "environment": [
                {
                    "name": "KC_DB_PASSWORD",
                    "value": "password"
                },
                {
                    "name": "KEYCLOAK_ADMIN_PASSWORD",
                    "value": "admin"
                },
                {
                    "name": "KC_ADMIN_HOSTNAME",
                    "value": "auth.example.com"
                },
                {
                    "name": "KC_DB_USERNAME",
                    "value": "root"
                },
                {
                    "name": "KC_PROXY",
                    "value": "edge"
                },
                {
                    "name": "KC_DB",
                    "value": "postgres"
                },
                {
                    "name": "KEYCLOAK_ADMIN",
                    "value": "admin"
                },
                {
                    "name": "KC_HOSTNAME_STRICT_BACKCHANNEL",
                    "value": "true"
                },
                {
                    "name": "KC_HTTP_PORT",
                    "value": "8080"
                },
                {
                    "name": "KC_HTTP_ENABLED",
                    "value": "true"
                },
                {
                    "name": "JAVA_OPTS_APPEND",
                    "value": "-Djgroups.dns.query=myapplication.tutorial"
                },
                {
                    "name": "KC_HOSTNAME_STRICT_HTTPS",
                    "value": "false"
                },
                {
                    "name": "KC_DB_URL_PORT",
                    "value": "5432"
                },
                {
                    "name": "KC_CACHE_STACK",
                    "value": "kubernetes"
                },
                {
                    "name": "KC_DB_URL",
                    "value": "jdbc:postgresql://keycloak.ap-northeast-1.rds.amazonaws.com/keycloak"
                },
                {
                    "name": "KC_HEALTH_ENABLED",
                    "value": "true"
                },
                {
                    "name": "KC_CACHE",
                    "value": "ispn"
                },
                {
                    "name": "KC_HOSTNAME",
                    "value": "www.example.com"
                }
            ],
            "environmentFiles": [],
            "mountPoints": [],
            "volumesFrom": [],
            "ulimits": [],
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-create-group": "true",
                    "awslogs-group": "/ecs/keycloak-task",
                    "awslogs-region": "ap-northeast-1",
                    "awslogs-stream-prefix": "ecs"
                },
                "secretOptions": []
            }
        }
    ],
    "family": "keycloak-test",
    "taskRoleArn": "arn:aws:iam::XXXXX:role/ecsTaskExecutionRole",
    "executionRoleArn": "arn:aws:iam::XXXXX:role/ecsTaskExecutionRole",
    "networkMode": "awsvpc",
    "revision": 1,
    "volumes": [],
    "status": "ACTIVE",
    "requiresAttributes": [
        {
            "name": "com.amazonaws.ecs.capability.logging-driver.awslogs"
        },
        {
            "name": "ecs.capability.execution-role-awslogs"
        },
        {
            "name": "com.amazonaws.ecs.capability.ecr-auth"
        },
        {
            "name": "com.amazonaws.ecs.capability.docker-remote-api.1.19"
        },
        {
            "name": "com.amazonaws.ecs.capability.task-iam-role"
        },
        {
            "name": "ecs.capability.execution-role-ecr-pull"
        },
        {
            "name": "com.amazonaws.ecs.capability.docker-remote-api.1.18"
        },
        {
            "name": "ecs.capability.task-eni"
        },
        {
            "name": "com.amazonaws.ecs.capability.docker-remote-api.1.29"
        }
    ],
    "placementConstraints": [],
    "compatibilities": [
        "EC2",
        "FARGATE"
    ],
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "cpu": "2048",
    "memory": "8192",
    "runtimePlatform": {
        "cpuArchitecture": "X86_64",
        "operatingSystemFamily": "LINUX"
    }
}

ECS サービス定義

service definision
{
    "cluster": "dev-keycloak",
    "serviceName": "ecs-service-discovery",
    "taskDefinition": "keycloak-test:1",
    "loadBalancers": [
        {
            "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXX:targetgroup/keycloak-http/80be85e70beeb53c",
            "containerName": "keycloak-test",
            "containerPort": 8080
        }
    ],
    "launchType": "FARGATE",
    "platformVersion": "LATEST",
    "networkConfiguration": {
        "awsvpcConfiguration": {
            "assignPublicIp": "DISABLED",
            "securityGroups": ["sg-XXXXX"],
            "subnets": ["subnet-XXXXX", "subnet-XXXXX", "subnet-XXXXX"]
        }
    },
    "healthCheckGracePeriodSeconds": 300,
    "desiredCount": 1,
    "serviceRegistries": [
        {
            "registryArn": "arn:aws:servicediscovery:ap-northeast-1:XXXXX:service/srv-XXXXX"
        }
    ]
}

動作検証

今回はDNS_PINGを用いてい、キャッシュ共有が行われているかを確かめたいので、
2台構成にして疑似的にKeycloakインスタンス1台に障害が起きたという想定で、ECSのタスクを落とします。
もう一台のKeycloakインスタンスからトークンが取得できるかのを検証します。
AWS ECS環境なので、意図的にKeycloakインスタンス1からトークンを取得するなどはできない為、
3回程繰り返して、トークンが取得できるか確認します。

Keycloakインスタンス1台に障害が起きた

##確認方法 tokenの取得
Keycloakインスタンス1, Keycloakインスタンス2双方が動作している際に、
初期状態で存在している"admin"アカウントを利用しtokenを取得します。

curl -s \
 -d "client_id=account" \
 -d "client_secret=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
 -d "username=admin" \
 -d "password=admin" \
 -d "grant_type=password" \
 -d "scope=openid" \
"https://auth.example.com/auth/realms/master/protocol/openid-connect/token" | python -m json.tool

access_tokenなどが取得できます。

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJYMGk0Mk1CU2dFb1FGeGpZZEhD",
    "expires_in": 60,
    "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJYMGk0Mk1CU2dFb1FGeGpZZEhD",
    "not-before-policy": 0,
    "refresh_expires_in": 1800,
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0N2Y5NmFjMS02ZTBhLTQ5NGIt",
    "scope": "openid profile email",
    "session_state": "4bef4344-6d80-4004-abbf-acaf3aae94a6",
    "token_type": "Bearer"
}

##確認方法 tokenの検証
この時点でKeycloakインスタンス 1を落としてから、上記で取得したtokenが有効か検証します。

curl -s -X POST \
-d "client_id=account" \
-d "client_secret=19beb1ef-d25b-45e7-bb66-6790562e0f04" \
-d "token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJYMGk0Mk1CU2dFb1FGeG" \
"https://auth.example.com/auth/realms/master/protocol/openid-connect/token/introspect" | python -m json.tool

有効であれば、下記のような結果が取得できます。

{
    "acr": "1",
    "active": true,
    "azp": "account",
    "client_id": "account",
    "email_verified": false,
    "exp": 1615691183,
    "iat": 1615691123,
    "iss": "http://localhost:8080/auth/realms/master",
    "jti": "197e3010-445a-4bdd-b506-4122121964a3",
    "preferred_username": "admin",
    "resource_access": {
        "account": {
            "roles": [
                "manage-account",
                "manage-account-links",
                "view-profile"
            ]
        }
    },
    "scope": "openid profile email",
    "session_state": "b3bf3cd2-bcb8-436c-b681-28b3cfa9427e",
    "sub": "04925620-0e9f-44d0-b44a-080e1fb0edf0",
    "typ": "Bearer",
    "username": "admin"
}

無効であれば

{
    "active": false
}

上記の検証でDNS_PINGが有効となり、keycloakインスタンス間でcacheが共有されている事が確認できます。

次回

次回は、infinispanを外出しにしてmicro service化する際の構成に関して検討してみたいとおもいます。

Discussion