はじめに

OSS の IAM (Identity and Access Management) ソフトウェアである Keycloak を AWS 環境上に構築する機会がありましたので紹介します。
⻑くなったので記事を 3 つに分割しており、今回は最後です。ECS on Fargate で動作する Quarkus ベースの Keycloak クラスタを CDK を⽤いて実装します。

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

最終的な実装サンプルはこちらになります。

構成

以下の構成図の通りに CDK でリソースを構築します。

スタック

以下のようにスタックを分割しました。

スタック名 説明
CertificateStack 証明書をデプロイするスタック。Keycloak 以外のスタックと連携することも想定し、証明書 ARN を SSM パラメータストアに登録する
KeycloakStack コンテナイメージ, VPC, Aurora サーバーレス V2, ECS on Fargate, 踏み台 EC2 インスタンスを構成するメインスタック

KeycloakStack では、Aurora サーバーレス V2 などステートフルなリソースや VPC など永続的なリソースと、Amazon ECS などのステートレスなリソースを同じスタックで定義しています。
スタック間の依存を発⽣させないために極⼒スタックを分割しないという考え⽅と、少なくともステートフルなリソースとステートレスなリソースは分割すべきという考え⽅、どちらもあると思います。今回は前者を選択しています。

実装

実装例をコードを交えて紹介します (GitHub 上のコードから⾃前メソッドなどを省き、少し簡略化したも
のを載せています)

証明書

まずは ACM 証明書のデプロイです。これだけスタックを別にしているのは、認証認可基盤だけでなくアプ
リケーション本体でもこの証明書をアタッチできるようにするためです。スタック間の依存を最⼩限にする
ため、SSM パラメーターストアに登録する⽅式を選択しました。

// Wildcard certificate
 const certificate = new acm.Certificate(this, "Certificate", {
   certificateName: "certificate",
   domainName: "dev-feature.example.com",
   subjectAlternativeNames: ["*.dev-feature.example.com"],
   validation: acm.CertificateValidation.fromDns(
     route53.HostedZone.fromLookup(this, "HostedZone", {
       domainName: "example.com",
     })
    ),
  });

 // Put parameter: certificateArn
 new ssm.StringParameter(this, "CertificateParameter", {
    parameterName: "dev/feature/certificateArn",
    stringValue: certificate.certificateArn,
 });

ただし、この証明書を us-east-1 でデプロイする想定はないため、CloudFront での使⽤については視野に⼊れていません。

VPC

VPC の構築です。今回 Secret の取得は NAT Gateway 経由を想定しているので、プライベートサブネットには NAT Gateway を配置します。機会があれば VPC エンドポイント経由も CDK でコード化してみたいです。
DB クレデンシャルを取得するための通信はクリティカルな部類だと思うので、本来であれば VPC エンドポイントを使うか NAT Gateway を冗⻑化すべきと考えます。
パブリックサブネットの情報は ALB に渡しますし、プライベートサブネットの情報も RDS に渡しますので、それぞれ vpc.selectSubnet()で取得しておきます。

// Base Vpc
const vpc = new ec2.Vpc(this, "VPC", {
 ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/16"),
 enableDnsHostnames: true,
 enableDnsSupport: true,
 natGateways: 1,
 maxAzs: 2,
 subnetConfiguration: [
   {
     name: "Public",
     subnetType: ec2.SubnetType.PUBLIC,
     cidrMask: 24,
   },
   {
    name: "Private",
    subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
    cidrMask: 24,
   },
  ], 
 });
const vpcPublicSubnets = vpc.selectSubnets({ subnetType:
ec2.SubnetType.PUBLIC });
const vpcPrivateSubnets = vpc.selectSubnets({ subnetType:
ec2.SubnetType.PRIVATE_WITH_EGRESS });

Aurora サーバーレス V2

DB は容易にスケーリングを設定できる Aurora サーバーレス V2 を選択し、ユーザー認証が特定の時間帯に
集中しても対応できるようにします。コードは以下となります。

const mysqlEngine = rds.DatabaseClusterEngine.auroraMysql({
 version: rds.AuroraMysqlEngineVersion.VER_3_02_0,
});

// Database cluster parameter group
const dbClusterParameterGroup = new rds.ParameterGroup(this,
"DBClusterParameterGroup", {
  engine: mysqlEngine,
  description: `Cluster parameter group for aurora-mysql8.0 for
${serviceName} authentication infrastructure`,
  parameters: {
    slow_query_log: "1",
  },
});
dbClusterParameterGroup.bindToCluster({});
(dbClusterParameterGroup.node.defaultChild as
rds.CfnDBClusterParameterGroup).dbClusterParameterGroupName =
`${serviceName}-db-cluster-pg-aurora-mysql8`;

// Database instance parameter group
const dbInstanceParameterGroup = new rds.ParameterGroup(this,
"DBInstanceParameterGroup", {
  engine: mysqlEngine,
  description: `Instance parameter group for aurora-mysql8.0 for
${serviceName} authentication infrastructure`,
});
dbInstanceParameterGroup.bindToInstance({});
(dbInstanceParameterGroup.node.defaultChild as
rds.CfnDBParameterGroup).dbParameterGroupName = `${serviceName}-db-instancepg-aurora-mysql8`;

// Database subnet group
const dbSubnetGroup = new rds.SubnetGroup(this, "DBSubnetGroup", {
  subnetGroupName: `${serviceName}-db-subnet-group`,
  description: `${serviceName}-db-subnet-group`,
  removalPolicy: RemovalPolicy.DESTROY,
  vpc: vpc,
  vpcSubnets: vpcPrivateSubnets,
});

// Database security group
const dbSecurityGroupName = `${serviceName}-db-security-group`;
const dbSecurityGroup = new ec2.SecurityGroup(this, "DBSecurityGroup", {
 securityGroupName: dbSecurityGroupName,
 description: dbSecurityGroupName,
 vpc: vpc,
 allowAllOutbound: true,
});
Tags.of(dbSecurityGroup).add("Name", dbSecurityGroupName);

// Database credential
const dbSecret = new asm.Secret(this, "DBSecret", {
 secretName: `${serviceName}-db-secret`,
 description: "Credentials for database",
 generateSecretString: {
   generateStringKey: "password",
   excludeCharacters: " % +~`#$&*()|[]{}:;?!'/@\"\\",
   passwordLength: 30,
   secretStringTemplate: JSON.stringify({ username: "admin" }),
 },
});

// Aurora Serverless v2
const dbCluster = new rds.DatabaseCluster(this, "DBCluster", {
 engine: mysqlEngine,
 clusterIdentifier: `${serviceName}-db-cluster`,
 instanceIdentifierBase: `${serviceName}-db-instance`,
 defaultDatabaseName: serviceName,
 deletionProtection: false,
 credentials: rds.Credentials.fromSecret(dbSecret),
 instanceProps: {
   vpc: vpc,
   vpcSubnets: vpcPrivateSubnets,
   instanceType: new ec2.InstanceType("serverless"),
   securityGroups: [dbSecurityGroup],
   parameterGroup: dbInstanceParameterGroup,
   enablePerformanceInsights: true,
 },
 subnetGroup: dbSubnetGroup,
 parameterGroup: dbClusterParameterGroup,
 backup: {
 retention: Duration.days(1),
 },
 storageEncrypted: true,
 removalPolicy: RemovalPolicy.DESTROY,
 copyTagsToSnapshot: true,
 cloudwatchLogsExports: ["error", "general", "slowquery", "audit"],
 cloudwatchLogsRetention: logs.RetentionDays.THREE_MONTHS,
});
(dbCluster.node.defaultChild as
rds.CfnDBCluster).serverlessV2ScalingConfiguration = {
  minCapacity: 0.5,
  maxCapacity: 4,
};

const dbListenerPort = 3306;
dbCluster.connections.allowInternally(
  ec2.Port.tcp(dbListenerPort),
  "Allow resources with this security group connect to database"
);
dbCluster.connections.allowFrom(
  ec2.Peer.ipv4(vpc.vpcCidrBlock),
  ec2.Port.tcp(dbListenerPort),
  "Allow resources in VPC connect to database"
);

いくつか要点があります。まず DB クラスターパラメーターグループや DB インスタンスパラメーターグル
ープは、あとから個別に設定変更ができるようにデフォルトのものではなく個別に作成しておきます。
メインのリソースに関してルールに則って命名したいケースを想定しているため、これらについても命名を
しますが、DBParameterGroup を CloudFormation で作成する場合、以前は名前を指定することができま
せんでした。
これが 2022 年 11 ⽉からできるようになったのですが、L2 コンストラクトの props では相変わらず設定
することができないので、L1 コンストラクトにアクセスし、該当するプロパティを編集します。
以下のように defaultChild を使って内部で定義されている L1 コンストラクトにアクセスし、該当のプ
ロパティを書き換えています。

// DB クラスターパラメーターグループの名前を書き換える
(dbClusterParameterGroup.node.defaultChild as
rds.CfnDBClusterParameterGroup).dbClusterParameterGroupName =
`${serviceName}-db-cluster-pg-aurora-mysql8`;

// DB インスタンスパラメーターグループの名前を書き換える
(dbInstanceParameterGroup.node.defaultChild as
rds.CfnDBParameterGroup).dbParameterGroupName = `${serviceName}-db-instancepg-aurora-mysql8`;

DB クレデンシャルは AWS Secrets Manager や SSM パラメーターストアで扱うのがセオリーです。今回
は AWS Secrets Manager を使います。以下のようにシークレットを定義し、

const dbSecret = new asm.Secret(this, "DBSecret", {
  secretName: `${serviceName}-db-secret`,
  description: "Credentials for database",
  generateSecretString: {
    generateStringKey: "password",
    excludeCharacters: " % +~`#$&*()|[]{}:;?!'/@\"\\",
    passwordLength: 30,
    secretStringTemplate: JSON.stringify({ username: "admin" }),
  },
});

DB クラスタの credentials に渡してやります。

const dbCluster = new rds.DatabaseCluster(this, "DBCluster", {
 ...
 credentials: rds.Credentials.fromSecret(dbSecret),
 ...
};

なお、以下のように rds.Credentials.fromGeneratedSecret() を使うことで、Secret をインスタンス化せずに直接設定することもできます。

aws_rds.Credentials.fromGeneratedSecret("admin")

今回は secretNamedescription を⾃前で指定したい場合のテンプレ的なコードにしたい意味もあったので、敢えて Secret を定義してからrds.Credentials.fromSecret(dbSecret)で渡す⽅法を採⽤しています。

Aurora サーバーレス V2 のスケーリング設定も、現状 L2 コンストラクトでは直接設定できないようなので、以下のように L1 コンストラクトにアクセスしてキャパシティを定義するようにしました。

(dbCluster.node.defaultChild as
rds.CfnDBCluster).serverlessV2ScalingConfiguration = {
  minCapacity: 0.5,
  maxCapacity: 4,
}

あるあるですが、通常マネコンなどでログを CloudWatch Logs へ連携するように設定した場合、初期値がNever expire となる場合が多いので、油断するとログが永久に保管されてしまって無駄な課⾦が発⽣するケースがあります。
CDK の場合はこの点も考慮されており、例えば DatabaseCluster の場合はcloudwatchLogsRetention という props が⽤意されており、保管期間を設定することができます。
これはどのように実現されているかというと、裏でカスタムリソースが作成され、Lambda が起動してロググループの保管期間を⾃動で書き換えてくれます。ここまで抽象化してくれることが CDK 最⼤の強みだと思います。
同様に、S3 バケットを削除するときに中⾝をあらかじめ削除できるautoDeleteObjects というprops もあり⾮常に便利なのですが、これも裏でカスタムリソースを作成することで実現されています。

続いて Amazon ECS のコードです。少し⻑くなります。

// Get ECR repository
const containerRepository = ecr.Repository.fromRepositoryArn(
  this,
  "ContainerRepository",
  `arn:aws:ecr:${region}:${account}:repository/${repositoryName}`
);

// Deploy container image
const tag = "latest"
new DockerImageDeployment(this, "KeycloakImageDeploy", {
  source: Source.directory("src/image/keycloak"),
  destination: Destination.ecr(containerRepository, { tag: tag }),
});

// Port settings
const containerPort = 8080;
const ecsPortSettings = [
  {
    Port: containerPort,
    Protocol: ecs.Protocol.TCP,
    Description: "keycloak: http",
    ECSServiceConnection: false,
  },
  {
    Port: 7800,
    Protocol: ecs.Protocol.TCP,
    Description: "keycloak: jgroups-tcp",
    ECSServiceConnection: true,
  },
  {
    Port: 57800,
    Protocol: ecs.Protocol.TCP,
    Description: "keycloak: jgroups-tcp-fd",
    ECSServiceConnection: true,
 },
];

// ECS cluster
const ecsCluster = new ecs.Cluster(this, "ECSCluster", {
  clusterName: `${serviceName}-cluster`,
  vpc: vpc,
  containerInsights: true,
});
ecsCluster.node.addDependency(dbCluster);

// ECS task execution role
const ecsTaskExecutionRole = new iam.Role(this, "ECSTaskExecutionRole", {
  roleName: `${serviceName}-task-execution-role`,
  assumedBy: new iam.CompositePrincipal(
    new iam.ServicePrincipal("ecs.amazonaws.com"),
    new iam.ServicePrincipal("ecs-tasks.amazonaws.com")
  ),
  managedPolicies:
[iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonEC2ContainerRegistryReadOnl
});
// ECS task role
const ecsTaskRole = new iam.Role(this, "ECSTaskRole", {
  roleName: `${serviceName}-task-role`,
  assumedBy: new iam.CompositePrincipal(new iam.ServicePrincipal("ecstasks.amazonaws.com")),
});

// ECS task definition
const ecsTaskDefinition = new ecs.FargateTaskDefinition(this,
"ECSTaskDefinitionBase", {
  family: `${serviceName}-task-definition`,
  cpu: 1024,
  memoryLimitMiB: 2048,
  runtimePlatform: {
    operatingSystemFamily: ecs.OperatingSystemFamily.LINUX,
    cpuArchitecture: ecs.CpuArchitecture.X86_64,
 },
  executionRole: ecsTaskExecutionRole,
  taskRole: ecsTaskRole,
});

// Keycloak credential
const userSecret = new asm.Secret(this, "UserSecret", {
  secretName: `${serviceName}-user-secret`,
  description: "Credentials for keycloak",
  generateSecretString: {
    generateStringKey: "password",
    excludePunctuation: true,
    passwordLength: 12,
    secretStringTemplate: JSON.stringify({ username: serviceName }),
 },
});

// ECS log group
const ecsLogGroup = new logs.LogGroup(this, "ECSLogGroup", {
 logGroupName: `ecs/${serviceName}`,
 retention: logs.RetentionDays.THREE_MONTHS,
 removalPolicy: RemovalPolicy.DESTROY,
});

// ECS port mappings
const ecsPortMappings: ecs.PortMapping[] = [];
ecsPortSettings.map((param) => {
  ecsPortMappings.push({
  containerPort: param.Port,
  protocol: param.Protocol,
 });
});

// Task definition with container definition added
ecsTaskDefinition.addContainer("ECSTaskDefinition", {
  containerName: serviceName,
  image: ecs.ContainerImage.fromEcrRepository(containerRepository, tag),
  command: ["--verbose", "start"],
  secrets: {
    KC_DB_PASSWORD: ecs.Secret.fromSecretsManager(dbCluster.secret!,
"password"),
    KEYCLOAK_ADMIN: ecs.Secret.fromSecretsManager(userSecret, "username"),
    KEYCLOAK_ADMIN_PASSWORD: ecs.Secret.fromSecretsManager(userSecret,
"password"),
 },
 logging: ecs.LogDrivers.awsLogs({
   logGroup: ecsLogGroup,
   streamPrefix: serviceName,
 }),
 environment: {
   KC_CACHE_CONFIG_FILE: "cache-ispn-jdbc-ping.xml",
   KC_DB: "mysql",
   KC_DB_URL:
`jdbc:mysql://${dbCluster.clusterEndpoint.hostname}:${dbListenerPort}/${servic
   KC_DB_URL_DATABASE: serviceName,
   KC_DB_URL_HOST: dbCluster.clusterEndpoint.hostname,
   KC_DB_URL_PORT: String(dbListenerPort),
   KC_DB_USERNAME: dbUserName,
   KC_HOSTNAME: domainName,
   KC_HOSTNAME_STRICT_BACKCHANNEL: "true",
   KC_PROXY: "edge",
 },
 portMappings: ecsPortMappings,
});

// Allow execution role to read the secrets
dbCluster.secret!.grantRead(ecsTaskDefinition.executionRole!);
userSecret.grantRead(ecsTaskDefinition.executionRole!);

// ECS service security group
const ecsServiceSecurityGroupName = `${serviceName}-ecs-service-securitygroup`;
const ecsServiceSecurityGroup = new ec2.SecurityGroup(this,
"ECSServiceSecurityGroup", {
  securityGroupName: ecsServiceSecurityGroupName,
  description: ecsServiceSecurityGroupName,
  vpc: vpc,
  allowAllOutbound: true,
});
Tags.of(ecsServiceSecurityGroup).add("Name", ecsServiceSecurityGroupName);

// ECS service
const ecsService = new ecs.FargateService(this, "ECSService", {
  serviceName: `${serviceName}-service`,
  cluster: ecsCluster,
  taskDefinition: ecsTaskDefinition,
  circuitBreaker: { rollback: true },
  desiredCount: 2,
  healthCheckGracePeriod: Duration.minutes(5),
  securityGroups: [ecsServiceSecurityGroup],
  enableECSManagedTags: true,
  enableExecuteCommand: true,
});

// ECS allowed traffic
ecsPortSettings.map((param) => {
 if (param.ECSServiceConnection) {
    ecsService.connections.allowFrom(
      ecsService.connections,
      param.Protocol === ecs.Protocol.TCP ? ec2.Port.tcp(param.Port) :
ec2.Port.udp(param.Port),
      param.Description
 );
 }
});
// Allow ECS service connect to database

dbCluster.connections.allowDefaultPortFrom(ecsService, "Allow ECS service
connect to database");
// ECS auto scaling capacity
const ecsAutoScaling = ecsService.autoScaleTaskCount({
 minCapacity: 2,
 maxCapacity: 4,
});

// ECS auto scaling by cpu utilization
ecsAutoScaling.scaleOnCpuUtilization("ECSCPUScaling", {
  policyName: `${serviceName}-cpu-scaling-policy`,
  targetUtilizationPercent: 80,
  scaleOutCooldown: Duration.seconds(300),
  scaleInCooldown: Duration.seconds(300),
});

// ECS auto scaling by schedule
ecsAutoScaling.scaleOnSchedule("ECSScalingOutBeforeOpening", {
  schedule: aas.Schedule.cron({
    minute: "30",
    hour: "23",
    weekDay: "MON-FRI",
    month: "*",
    year: "*",
 }),
  minCapacity: 2,
  maxCapacity: 4,
});
ecsAutoScaling.scaleOnSchedule("ECSScalingInAfterOpening", {
 schedule: aas.Schedule.cron({
   minute: "30",
   hour: "1",
   weekDay: "MON-FRI",
   month: "*",
   year: "*",
 }),
 minCapacity: 1,
 maxCapacity: 2,
});
ecsAutoScaling.scaleOnSchedule("ECSScalingOutBeforeClosing", {
 schedule: aas.Schedule.cron({
   minute: "0",
   hour: "8",
   weekDay: "MON-FRI",
   month: "*",
   year: "*",
 }),
 minCapacity: 2,
 maxCapacity: 4,
});
});
ecsAutoScaling.scaleOnSchedule("ECSScalingInAfterClosing", {
 schedule: aas.Schedule.cron({
   minute: "0",
   hour: "10",
   weekDay: "MON-FRI",
   month: "*",
    year: "*",
 }),
 minCapacity: 1,
 maxCapacity: 2,
});

// ALB security group
const albSecurityGroupName = `${serviceName}-alb-security-group`;
const albSecurityGroup = new ec2.SecurityGroup(this, "ALBSecurityGroup", {
  securityGroupName: albSecurityGroupName,
  description: albSecurityGroupName,
  vpc: vpc,
  allowAllOutbound: false,
});
Tags.of(albSecurityGroup).add("Name", albSecurityGroupName);
albSecurityGroup.addIngressRule(ec2.Peer.ipv4("0.0.0.0/0"),
ec2.Port.tcp(443), "Allow from anyone on port 443");

// ALB
const alb = new elbv2.ApplicationLoadBalancer(this, "ALB", {
  loadBalancerName: `${serviceName}-alb`,
  vpc: vpc,
  vpcSubnets: vpcPublicSubnets,
  internetFacing: true,
  securityGroup: albSecurityGroup,
});

// ALB HTTPS listener
const albListener = alb.addListener("ALBListener", {
  protocol: elbv2.ApplicationProtocol.HTTPS,
  certificates: [
    {
      certificateArn: Lazy.string({
        produce: () =>
          ssm.StringParameter.valueForTypedStringParameterV2(this,
"certificateArn", ssm.ParameterValueType.STRING),
      }),
    },
  ],
});

// ALB target group
albListener.addTargets("ALBTarget", {
 targetGroupName: `${serviceName}-tg`,
 targets: [ecsService],
 healthCheck: {
   healthyThresholdCount: 3,
   interval: Duration.seconds(60),
   timeout: Duration.seconds(30),
 },
 slowStart: Duration.seconds(60),
 stickinessCookieDuration: Duration.days(1),
 port: containerPort,
 protocol: elbv2.ApplicationProtocol.HTTP,
});

// Alias record for ALB
const albARecord = new route53.ARecord(this, "ALBARecord", {
  recordName: domainName,
  target: route53.RecordTarget.fromAlias(new
route53targets.LoadBalancerTarget(alb)),
  zone: route53.HostedZone.fromLookup(this, "HostedZone", {
    domainName: env.domain,
  }),
});
albARecord.node.addDependency(alb);

流れを整理すると以下のようになります。

コンテナイメージ

  • ECR リポジトリを取得
  • 前章で紹介した cdk-docker-image-deployment を使って ECR へのコンテナイメージのデプロイ
    を記述

下準備

  • ECS サービスのセキュリティグループやコンテナのポートマッピングで使う情報をオブジェクト
    ecsPortSettings にまとめておく

ECS クラスター

  • ECS クラスターを記述

ECS タスク定義

  • タスク実⾏ロールを記述
  • タスクロールを記述
  • タスク定義を記述
    • タスク実⾏ロールをアタッチ
    • タスクロールをアタッチ
  • Keycloak 管理者アクセス⽤のシークレットを記述
  • CloudWatch ロググループを記述
  • ecsPortSettings からポートマッピング情報を作成
  • タスク定義にコンテナ情報を追加
    • コンテナイメージ情報を記述
    • Keycloak ⽤シークレットをアタッチ
    • CloudWatch ロググループをアタッチ
    • 環境変数を記述
    • ポートマッピング情報をアタッチ
  • タスク実⾏ロールに DB シークレットの読み取り権限を付与
  • タスク実⾏ロールに Keycloak 管理者アクセス⽤シークレットの読み取り権限を付与

ECS サービス

  • ECS サービスのセキュリティグループを記述
  • ECS サービスを記述
    • アタッチする ECS クラスターを渡す
    • タスク定義をアタッチ
    • ECS サービスのセキュリティグループをアタッチ
  • ecsPortSettings から ECS サービスに接続設定を追加
  • ECS サービスに DB への接続設定を追加

Auto Scaling

  • Auto Scaling 設定を記述
    • Auto Scaling 設定に CPU Utilization のトリガーを追加
    • Auto Scaling 設定にスケジュールベースのトリガーを追加

Application Load Balancer

  • ALB ⽤セキュリティグループを記述
  • ALB を記述
    • セキュリティグループをアタッチ
    • リスナーを追加
    • ALB リスナーにターゲットグループを追加

Route 53 A Record

  • 向き先を ALB とした A レコード (エイリアス) を記述

構成要素が多岐にわたるのでひとつひとつ理解できていることが重要ですが、裏返せばこういう複雑なリソ
ースを CDK コードで記述することで理解が深まるとも⾔えます (私⾃⾝、書いて覚えたところがあります)
要点としては以下があります。

  • ポート情報をあらかじめオブジェクトにまとめておくことで、同じポートを複数箇所で書かなくて済む
    ようにしている
  • 要件に合わせてスケジュールベースの Application Auto Scaling を定義している
  • 本番利⽤も視野に⼊れた構成を想定しているため、 aws-ecs-patterns は使わずに L2 コンストラク
    トで書いている

最後に踏み台⽤の EC2 インスタンスも⽤意しています。

// Bastion host security group
const bastionSecurityGroupName = `${serviceName}-bastion-security-group`;
const bastionSecurityGroup = new ec2.SecurityGroup(this,
"BastionSecurityGroup", {
  securityGroupName: bastionSecurityGroupName,
  description: bastionSecurityGroupName,
  vpc: vpc,
 allowAllOutbound: true,
});
Tags.of(bastionSecurityGroup).add("Name", bastionSecurityGroupName);

// Bastion host
const bastion = new ec2.BastionHostLinux(this, "Bastion", {
  instanceName: `${serviceName}-bastion`,
  instanceType: new ec2.InstanceType("t3.micro"),
  vpc: vpc,
  securityGroup: bastionSecurityGroup,
})

// Override role name
(bastion.role.node.defaultChild as iam.CfnRole).roleName = `${serviceName}-
bastion-role`

// Allow bastion host connect to database
dbCluster.connections.allowDefaultPortFrom(bastion, "Allow bastion host
connect to database");

L1 コンストラクトにアクセスし、ロール名を命名ルールに則って上書きしています。

今回 ec2.BastionHostLinux() を使って踏み台ホストを作ってみたのですが、コンテナのデバッグやトラブルシューティングに関しては、⾃分のマシンに session-manager-plugin をインストールして直接ECS Exec する⽅式でいい気がしています。
⼀⽅で DB へのアクセスに関しては、Keycloak の DB スキーマをカスタマイズしているようなケースでは必要だと思いますが、そうでない場合は踏み台⾃体が必要ないケースも出てきそうです。このあたりはユースケースによると思います。
余談ですが、Aurora サーバーレス V1 の頃には Query Editor という、マネコンからクエリを発⾏できる機能があって便利だったのですが、V2 ではなくなってしまいました。

デプロイ

ローカルで Docker が起動していることを確認し、以下のコマンドを実⾏します。問題がなければ、
Quarkus-based Keycloak コンテナイメージのビルドおよびデプロイ、ACM 証明書のデプロイ、VPC の構
築、Aurora サーバーレス V2 の構築、Keycloak の搭載された ECS on Fargate の構築、踏み台⽤ EC2 イン
スタンスの構築が⼀挙に⾏われます。

# CFn テンプレート作成
npx cdk synth --all
# デプロイ
npx cdk deploy --all

AWS Secrets Manager から取得したシークレットで Keycloak 管理者画⾯にログインできることを確認します。

おわりに

CDK を⽤いて OSS の IAM ソフトウェアである Keycloak を構築する⼿順について、背景や留意事項を踏
まえて紹介しました。少し改良すれば、本番環境でも活⽤できると思います。
AWS 環境において、⼤規模でマルチテナントなアプリケーションの認証認可基盤を検討する際、Amazon
Cognito がフィットしないケースでは Keycloak はファーストチョイスになり得ます。そういったケースで
役⽴てば幸いです。