KeycloakをFargate使ってマイグレーションした話
Tags: fargate, ecs, autoscaling, keycloak
Published: 2022-12-21 15:12:37

この記事は [AWS Community Builders Advent Calendar 2022](https://adventar.org/calendars/7633) の 22 日目の記事です。

本記事はKeycloakをFargateで稼働させる内容になっていますが、Fargateを負荷に応じてスケールアウトさせる方法や時間帯に応じてスケールアウトさせる方法についても記載しており、Fargateでのスケールアウトを制御したい方にも参考となる内容としています。

## Keycloak とは

オープンソースのアイデンティティ・アクセス管理ソフトウェアで、シングルサインオンやAPIアクセスの認証・認可制御を実現します。[Keycloakとは](https://qiita.com/daian183/items/30f01e162e03567ff21b)の記事でわかりやすく整理されています。[認証と認可 Keycloak入門](https://www.amazon.co.jp/dp/B09RJXNKD4/?&_encoding=UTF8&tag=zaitekujp-22&linkCode=ur2&linkId=aacae704ff7e4193c18cef1ae3ab2efa&camp=247&creative=1211")や、[実践 Keycloak ―OpenID Connect、OAuth 2.0を利用したモダンアプリケーションのセキュリティー保護](https://www.amazon.co.jp/dp/4814400098/?&_encoding=UTF8&tag=zaitekujp-22&linkCode=ur2&linkId=6eb644426c02c169e9bbfde9361397fe&camp=247&creative=1211)の書籍が参考になりますがKeycloak 17以降のプロダクトを利用する場合には、Quarksに対応したドキュメントになっている後者を参照されることをお勧めします。

AWSのマネージドサービスでは[Amazon Congito](https://aws.amazon.com/jp/cognito/)が該当します。

## Fargate とは

コンテナ実行を行うことができるAWSのマネジメントサービスです。[Black Belt](https://pages.awscloud.com/rs/112-TZM-766/images/20190925_AWS-BlackBelt_AWSFargate.pdf)に詳しく記載されています。
EC2上でコンテナ実行を行うECS on EC2や、KubernetesのマネジメントサービスであるAWS EKSなどもありますが、コンテナ実行基盤の保守をせずにシンプルに実行できる環境ではAWS Fargateがお勧めです。

## 今回の話

今回の話は、ECS on EC2で構築していたKeycloakのサービスをFargateに移行した話と、Keycloakをv6からv19へマイグレーションした話について記載します。

---

## ECS on EC2で構築していたKeycloakのサービスをFargateに移行した話

ECS on EC2 を Fargateに移行するメリットとして、以下3点があり、後述のバージョンアップ前に移行を行ないました。

* コンテナ基盤を保守する必要がない
* スケールアウトする際のコストを最小化できるメリットがある
* スケールアウト時の起動時間を短縮することができるため、スパイクに追従しやすい

## Keycloakをv6からv19へマイグレーションした話

Keycloakは起動時に自動的にマイグレーションする機能があるため基本的には新しいバージョンで起動するだけで良いが、DB制約などに起因する一部非互換の問題があります。そのため、エラーが発生するたびに対応方法を調べて不整合を調整する必要があります。

以下の例の場合には、SELECT REALM_ID, NAME, COUNT(*) FROM KEYCLOAK_GROUP WHERE PARENT_GROUP is NULL GROUP BY REALM_ID, NAME HAVING COUNT(*) > 1; で重複したグループ名を検出できます。
```
ERROR [org.keycloak.connections.jpa.updater.liquibase.conn.DefaultLiquibaseConnectionProvider] (ServerService Thread Pool -- 67) Change Set META-INF/jpa-changelog-9.0.1.xml::9.0.1-KEYCLOAK-12579-add-not-null-constraint::keycloak failed. Error: Duplicate entry 'school- -ks' for key 'SIBLING_NAMES' [Failed SQL: UPDATE authdbdev.KEYCLOAK_GROUP SET PARENT_GROUP = ' ' WHERE PARENT_GROUP IS NULL]
FATAL [org.keycloak.services] (ServerService Thread Pool -- 67) java.lang.RuntimeException: Failed to update database
```


また、WildFlyからQuarksに移行することに伴い設定すべき環境変数が変わっている点に注意が必要となります。

|WildFly|Quarks|
|--|--|
|DB_DATABASE|KC_DB_URL_DATABASE|
|DB_HOST|KC_DB_URL_HOST|
|DB_PASSWORD|KC_DB_PASSWORD|
|DB_USER|KC_DB_USERNAME|

Wildflyでstandalone-ha.xmlで定義していたInfinispanによるマルチノードクラスタを構成する場合には、v17以降のQuarksにおいて以下の環境変数の設定が必要となります。

KC_CACHE="ispn"
KC_CACHE_CONFIG_FILE="cache-ispn-jdbc-ping.xml"

なお、 cache-ispn-jdbc-ping.xml は以下の記述を行います（RDSにMySQLを選択した場合）。
ownersはキャッシュをいくつのノードで保持するかを設定します。
可用性を維持するために最低2ノードで稼働しつつスケールアウトする場合には、スケールインする際に同時に縮退するノード数を考慮しつつノード数を決定する必要があります。（スケールインする際のノードは制御できないため、キャッシュを保持するノードがまとめて削除されてキャッシュが失われないようにする工夫が必要となります）

また、realmsやusersのmax-countはパフォーマンスに影響を及ぼします。
max-countを超えたセッションを保持するとDBとの通信が発生することとなるため、メモリが許す限りmax-countは大きくした方が良いです。
しかしながら、ReplicatedモードではなくDuplicatedモードで起動する場合には、スケールインする際にキャッシュのリバランスが行われた結果、Out of Memoryとならないように[Distributed Load Testing on AWS](https://aws.amazon.com/jp/builders-flash/202108/distributed-load-testing/)等を活用した負荷テストツールにて十分にテストを行う必要があります。
パラメタの詳細は、[Configuring Infinispan caches](https://infinispan.org/docs/stable/titles/configuring/configuring.html)や[urn:infinispan:config:11.0](https://docs.jboss.org/infinispan/11.0/configdocs/infinispan-config-11.0.html)を参照ください。
```
<?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">

  <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}"
                 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));"
	         info_writer_sleep_time="500"
                 remove_all_data_on_view_change="true"
                 stack.combine="REPLACE"
                 stack.position="MPING" />
    </stack>
  </jgroups>

  <cache-container name="keycloak">
    <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="3">
      <expiration lifespan="-1"/>
    </distributed-cache>
    <distributed-cache name="authenticationSessions" owners="3">
      <expiration lifespan="-1"/>
    </distributed-cache>
    <distributed-cache name="offlineSessions" owners="3">
      <expiration lifespan="-1"/>
    </distributed-cache>
    <distributed-cache name="clientSessions" owners="3">
      <expiration lifespan="-1"/>
    </distributed-cache>
    <distributed-cache name="offlineClientSessions" owners="3">
      <expiration lifespan="-1"/>
    </distributed-cache>
    <distributed-cache name="loginFailures" owners="3">
      <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="3">
      <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>
```

Dockerfileは以下の通りです。

```
FROM quay.io/keycloak/keycloak:19.0.3
COPY conf/keycloak.conf /opt/keycloak/conf/keycloak.conf
COPY conf/cache-ispn-jdbc-ping.xml /opt/keycloak/conf/cache-ispn-jdbc-ping.xml

RUN /opt/keycloak/bin/kc.sh build --cache-config-file=cache-ispn-jdbc-ping.xml
WORKDIR /opt/keycloak

ENTRYPOINT [ "/opt/keycloak/bin/kc.sh" ]
```

TerraformでのECSの定義は以下の通りです。`_xxxx_`となっている箇所は変数で渡される定数としてご理解ください。

```
resource "aws_ecs_cluster" "keycloak" {
  name     = "clustername"
  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_ecs_service" "keycloak" {
  cluster                            = aws_ecs_cluster.keycloak.id
  deployment_maximum_percent         = 200
  deployment_minimum_healthy_percent = 100
  desired_count                      = _keycloak_desired_count_min_
  enable_ecs_managed_tags            = false
  enable_execute_command             = true
  health_check_grace_period_seconds  = 180
  name             = _servicename_
  platform_version = "LATEST"
  propagate_tags      = "TASK_DEFINITION"
  scheduling_strategy = "REPLICA"
  task_definition     = aws_ecs_task_definition.keycloak.arn

  capacity_provider_strategy {
      capacity_provider = "FARGATE"
      base              = 2
      weight            = 1 // 3台目以降は25%の割合でFARGATEで起動
  }

  capacity_provider_strategy {
      capacity_provider = "FARGATE_SPOT"
      base              = 0
      weight            = 3 // 3台目以降は75%の割合でFARGATE_SPOTで起動
  }

  deployment_circuit_breaker {
    enable   = false
    rollback = false
  }

  deployment_controller {
    type = "ECS"
  }

  load_balancer {
    container_name   = "keycloak"
    container_port   = aws_alb_target_group.keycloak.port
    target_group_arn = aws_alb_target_group.keycloak.arn
  }

  network_configuration {
    assign_public_ip = true
    security_groups = [
      aws_security_group.keycloak.id
    ]
    subnets = _cluster_subnets_
  }

  timeouts {}
  lifecycle {
    ignore_changes = [desired_count]
  }
}

resource "aws_ecs_task_definition" "keycloak" {
  container_definitions = jsonencode(
    [
      {
        cpu               = 0
        command           = ["start --optimized"]
        disableNetworking = false
        portMappings = [
          {
            containerPort = aws_alb_target_group.auth.port
            hostPort      = aws_alb_target_group.auth.port
            protocol      = "tcp"
          }
        ]
        environment = [
          {
            name  = "KC_DB_URL_DATABASE"
            value = _KC_DB_URL_DATABASE_
          },
          {
            name  = "KC_DB_URL_HOST"
            value = _KC_DB_URL_HOST_
          },
          {
            name  = "KC_DB_URL"
            value = _KC_DB_URL_
          },
          {
            name  = "KC_DB_PASSWORD"
            value = _KC_DB_PASSWORD_
          },
          {
            name  = "KC_DB_USERNAME"
            value = _KC_DB_USERNAME_
          },
          {
            name  = "JAVA_OPTS"
            value = _JAVA_OPTS_
          },
          {
            name  = "KC_CACHE"
            value = "ispn"
          },
          {
            name  = "KC_HOSTNAME"
            value = _keycloak_fqdn_
          },
          {
            name  = "KC_HOSTNAME_STRICT_BACKCHANNEL"
            value = "true"
          },
          {
            name  = "KC_CACHE_CONFIG_FILE"
            value = "cache-ispn-jdbc-ping.xml"
          },
        ]
        essential = true
        healthCheck = {
          command = [
            "CMD-SHELL",
            "curl -f http://localhost:${_keycloak_port_}/auth/ || exit 1",
          ]
          interval = 30
          retries  = 3
          timeout  = 5
        }
        image       = _ecr_repo_url_
        stopTimeout = 120
        logConfiguration = {
          logDriver = "awslogs"
          options = {
            awslogs-group         = aws_cloudwatch_log_group.keycloak.name
            awslogs-region        = "ap-northeast-1"
            awslogs-stream-prefix = "ecs"
          }
        }
        mountPoints = []
        name        = "keycloak"
        volumesFrom = []
      },
    ]
  )
  cpu                = _keycloak_cpu_
  task_role_arn      = aws_iam_role.ecs_task_role.arn
  execution_role_arn = aws_iam_role.execution_role.arn
  family             = _service_name_
  memory             = _keycloak_memory_
  network_mode       = "awsvpc"
  requires_compatibilities = [
    "FARGATE",
  ]
}

resource "aws_alb_target_group" "keycloak" {
  deregistration_delay          = "115"
  load_balancing_algorithm_type = "round_robin"
  name                          = _clustername_
  port                          = _keycloak_port_
  protocol                      = "HTTP"
  protocol_version              = "HTTP1"
  slow_start                    = 0
  target_type                   = "ip"
  vpc_id                        = _cluster_vpc_id_

  health_check {
    ...
  }

  stickiness {
    cookie_duration = 86400
    enabled         = false
    type            = "lb_cookie"
  }
}

resource "aws_iam_role" "execution_role" {
  name                = "ecs-execution-role"
  managed_policy_arns = ["arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"]
  assume_role_policy = jsonencode({
    "Version" : "2008-10-17",
    "Statement" : [
      {
        "Sid" : "",
        "Effect" : "Allow",
        "Principal" : {
          "Service" : "ecs-tasks.amazonaws.com"
        },
        "Action" : "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role" "ecs_task_role" {
  name = "ecs-task-role"
  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : "",
        "Effect" : "Allow",
        "Principal" : {
          "Service" : "ecs-tasks.amazonaws.com"
        },
        "Action" : "sts:AssumeRole"
      }
    ]
  })

  inline_policy {
    name = "SessionManagerRoleForECS"
    policy = jsonencode({
      "Version" : "2012-10-17",
      "Statement" : [
        {
          "Effect" : "Allow",
          "Action" : [
            "ssmmessages:CreateControlChannel",
            "ssmmessages:CreateDataChannel",
            "ssmmessages:OpenControlChannel",
            "ssmmessages:OpenDataChannel"
          ],
          "Resource" : "*"
        }
      ]
    })
  }
}

resource "aws_cloudwatch_log_group" "keycloak" {
  name              = "/ecs/${_keycloak_service_name_}"
  retention_in_days = 180
}
```

スケーリングポリシーについては以下のような定義を行うことで実現が可能となります。

```
resource "aws_appautoscaling_target" "keycloak" {
  service_namespace  = "ecs"
  resource_id        = "service/${aws_ecs_cluster.keycloak.name}/${aws_ecs_service.keycloak.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  min_capacity       = _keycloak_desired_count_min_
  max_capacity       = _keycloak_desired_count_max_
  lifecycle {
    ignore_changes = [min_capacity, max_capacity]
  }
}

resource "aws_appautoscaling_policy" "keycloak_scale_out" {
  name               = "keycloak_scale_out"
  policy_type        = "StepScaling"
  service_namespace  = aws_appautoscaling_target.keycloak.service_namespace
  resource_id        = aws_appautoscaling_target.keycloak.id
  scalable_dimension = aws_appautoscaling_target.keycloak.scalable_dimension

  step_scaling_policy_configuration {
    adjustment_type         = "ChangeInCapacity"
    cooldown                = 30
    metric_aggregation_type = "Maximum"

    step_adjustment {
      metric_interval_lower_bound = 0
      metric_interval_upper_bound = local.KeycloakCpuHightThreshold
      scaling_adjustment          = _keycloak_desired_count_scaleout_policy_
    }

    step_adjustment {
      metric_interval_lower_bound = local.KeycloakCpuHightThreshold
      scaling_adjustment          = _keycloak_desired_count_scaleout_policy_ * 2
    }
  }
}

resource "aws_appautoscaling_policy" "keycloak_scale_in" {
  name               = "keycloak_scale_in"
  policy_type        = "StepScaling"
  service_namespace  = aws_appautoscaling_target.keycloak.service_namespace
  resource_id        = aws_appautoscaling_target.keycloak.id
  scalable_dimension = aws_appautoscaling_target.keycloak.scalable_dimension

  step_scaling_policy_configuration {
    adjustment_type         = "ChangeInCapacity"
    cooldown                = 60
    metric_aggregation_type = "Average"

    step_adjustment {
      metric_interval_upper_bound = 0
      scaling_adjustment          = -1
    }
  }
}
```

時間帯によってあらかじめスケーリングを行う場合には以下の定義を行うことで実現ができます。

```
resource "aws_appautoscaling_scheduled_action" "keycloak_time_scaling_start" {
  name               = "keycloak_time_caling_start"
  service_namespace  = aws_appautoscaling_target.keycloak.service_namespace
  resource_id        = aws_appautoscaling_target.keycloak.id
  scalable_dimension = aws_appautoscaling_target.keycloak.scalable_dimension
  schedule           = _keycloak_desired_count_time_scaling_start_

  scalable_target_action {
    min_capacity = _keycloak_desired_count_min_ * _keycloak_desired_count_time_scaling_scale
    max_capacity = _keycloak_desired_count_max_ * _keycloak_desired_count_time_scaling_scale
  }
}

resource "aws_appautoscaling_scheduled_action" "keycloak_time_scaling_stop" {
  name               = "keycloak_time_caling_stop"
  service_namespace  = aws_appautoscaling_target.keycloak.service_namespace
  resource_id        = aws_appautoscaling_target.keycloak.id
  scalable_dimension = aws_appautoscaling_target.keycloak.scalable_dimension
  schedule           = _keycloak_desired_count_time_scaling_stop_

  scalable_target_action {
    min_capacity = _keycloak_desired_count_min_
    max_capacity = _keycloak_desired_count_max_
  }
  depends_on = [aws_appautoscaling_scheduled_action.keycloak_time_scaling_start]
}
```

以上、KeycloakをFargateで自由自在にコントロールすることができます。