AWS環境(ECS-Fargate)におけるkeycloak冗長構成(DNS_PING)
はじめに
AWS環境(ECS-Fargate)でkeycloakをDNS_PINGを使った冗長構成で立ち上げるサンプルコードです。
全体構成図
Keycloak の クラスタリング
Keycloak では、JGroups, Infinispan というライブラリを利用してクラスタリングを実現しています。
JGroups の Discovery プロトコル
JGroups はクラスタのメンバーとなるホストを見つけるために、Discovery プロトコルというものを利用しています。JGroups では、この Discovery プロトコルとして様々なプロトコルを提供しており、環境や要件に合わせて適切なプロトコルを選ぶことができるようになっています。
今回はDNS_PINGの構成を紹介していきます。
他にもS3_PING, JDBC_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
サービス検出の名前空間の作成
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