EKS Pod Identity を活用して Terraform でプロビジョニングした EKS を Blue/Green アップグレードしてみた

EKS 内の Pod に権限を与える新たな方法が追加されました!

EKS Pod Identity とは?

EKS クラスター内の Pod に AWS 権限を与える新しい方式として EKS Pod Identity が発表されました。

Pod に Service Account 単位で細かく権限設定を行う際、 IRSA(IAM Roles for Service Accounts) を以前から利用できました。
こちらを使わずに EKS ノード単位で権限を付与すると Pod に必要以上の権限を付与してしまう可能性が高いため、IRSA はかなり一般的に利用されている機能かと思います。

Pod Identity も特定 Service Account 内の Pod から IAM ロールを利用可能にするための同じ目的の機能になります。
ただ、今回 Pod への権限付与を EKS 側の設定として扱うことができるようになった点が大きな違いになります。
というのも IRSA は仕組み上、利用する IAM ロールの信頼ポリシーで Open ID Connect プロバイダーから AssumeRole する許可設定を行う必要があります。
この際、下記例のように Condition.StringEquals 属性でクラスター固有の Open ID Connect プロバイダー URL、 名前空間、 Service Account 名の情報が必要になります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::xxxxxxxxxxxx:oidc-provider/oidc.eks.ap-northeast-1.amazonaws.com/id/XXXXXXXXXXXXXXXXXXXXXXXX"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "oidc.eks.ap-northeast-1.amazonaws.com/id/XXXXXXXXXXXXXXXXXXXXXXXX:aud": "sts.amazonaws.com",
                    "oidc.eks.ap-northeast-1.amazonaws.com/id/XXXXXXXXXXXXXXXXXXXXXXXX:sub": "system:serviceaccount:kube-system:aws-load-balancer-controller"
                }
            }
        }
    ]
}

Pod Identity であれば、 IAM ロールで pods.eks.amazonaws.com を許可しておいて、各 Service Account への実際の権限付与は EKS 側で管理することができるようになりました。
信頼ポリシーにクラスター固有の情報が無くなり、かなりシンプルになっています。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "pods.eks.amazonaws.com"
            },
            "Action": [
                "sts:TagSession",
                "sts:AssumeRole"
            ]
        }
    ]
}

その上、 Pod Identity であれば IAM ロールを複数クラスターで共用する場合も信頼ポリシーを変える必要がありません。
また、IRSA を利用する場合は Service Account 側で annotations として IAM ロールの情報を指定する必要がありました。

apiVersion: v1
kind: ServiceAccount
metadata:
  labels:
    app.kubernetes.io/component: controller
    app.kubernetes.io/name: aws-load-balancer-controller
  name: aws-load-balancer-controller
  namespace: kube-system
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789123:role/alb-ingress-controller-role

EKS のマニフェストファイルと IAM ロールの信頼ポリシーを行き来して設定するよりは、EKS の設定項目としてまとめて扱った方がシンプルで設定しやすいかと思います。

何故今回 Blue/Green アップグレードを取り上げるか

今回 EKS の Blue/Green 形式のバージョンアップグレードを題材に扱うのは Pod Identity のメリットがより強く生きる場面だからです。
この場合のより具体的なメリットとして下記が 2 点が挙げられます。

  • 複数クラスターで IAM ロールを共用したい場合に信頼ポリシーをシンプルにできる
  • IAM ロールの信頼ポリシーに EKS クラスターの Open ID Connect プロバイダー URL を埋め込む必要が無くなるため、Terraform の State を分けた際の依存関係をシンプルにしやすい

1 つ目について、複数クラスターで IAM ロールを共用することによって信頼ポリシーが複雑になることを防ぐことができます。
もちろん IAM ロールは各クラスター固有のものを用意する整理にしても良いと思います。
iam_iam-role-for-service-accounts-eks といった IRSA で利用するための IAM ロールを作成するために便利なモジュールもあるので、各クラスター専用の IAM ロールを定義しても問題は無いかと思います。
ただ、共用できるのであれば共用したいのではないでしょうか。
EKS のバージョンアップグレードについて考えるのであれば、クラスター切り替えによって EKS 以外の設定ができる限り変わらない方が望ましいと思います。
今までは IAM ロールを共用しても新規クラスターの Open ID Connect プロバイダーを信頼ポリシーに追加して、旧クラスターの Open ID Connect プロバイダーを信頼ポリシーから削除するといった操作が必要だったので、共用しても結局 IAM ロールの設定変更を行う必要がありました。
ただ、IAM ロールの信頼ポリシーを全く触らなくて良いのであれば、よりシンプルに考えることができるようになります。(IAM ロールの信頼ポリシーはクラスター切り替えによって変化が無いことが保証される)
こちらは大きなメリットかと思います。

2 つ目は Terraform で管理している場合の話なので、必ずしも全てのケースで当てはまらないかもしれません。
下記のようなケースを考えます。

旧クラスターの横に新クラスターを立てて検証してから DNS ベースで切り替える場面を想定しています。

  • VPC や Route53 Hosted Zone、 DynamoDB, IAM Role を共用します。
  • Ingress リソースを用いてアプリを公開する際に、ALB のデプロイは AWS Load Balancer Controller に、レコード追加は external-dns に任せる形とします。
  • アプリケーションコンテナから DynamoDB にアクセスする権限を付与する必要があります。

この際、 新規クラスターの操作によって 旧クラスターへ影響がでることを防ぐため、下記のように 3 つの State に分離して扱うこととします。

  • common-environment
    • 共通リソース(IAM, VPC, Route53, DynamoDB)
  • eks-blue
    • 旧クラスター
  • eks-green
    • 新規クラスター

この際、IRSA だと 新規クラスターを作成してから DynamoDB アクセス用 IAM ロールの信頼ポリシーに Open ID Connect プロバイダーの情報を埋め込む必要があります。
したがって、下記のような循環する依存関係が生まれてしまいます。

Terraform で循環参照する形になると、意図しない動作に繋がるので避けたいです。
Pod Identity を使えば、あくまで許可するのは pods.eks.amazonaws.com なので、下記のようなシンプルな形になります。

細かい話でしたが、こういった観点でも Pod への権限付与 EKS の設定として扱えると IAM ロールを複数クラスターで共用しやすくなります。

やってみる

では実際にやってみます。
既出の図になりますが、下記構成で Blue/Green アップグレードしてみます。

構成は terraform-aws-eks-blueprints を参考にしています。
こちらは ArgoCD を採用していますが、Pod Identity とはあまり関係無いので今回は利用していません。
また、Pod から AWS 権限を扱えるかを確認したいので、DynamoDB からデータを取得して返却する fast-api のアプリに変更しています。※ コードと Dockerfile は下記

from fastapi import FastAPI
import boto3

app = FastAPI()

@app.get("/")
def read_root():
    dynamodb = boto3.client("dynamodb", region_name="ap-northeast-1")
    response = dynamodb.scan(
        TableName="test-dynamodb",
    )
    return response["Items"]
FROM python:3.11-slim

WORKDIR /app

RUN pip install --upgrade pip
RUN pip install boto3 fastapi uvicorn

COPY ./app/ .

CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "8080"]

まず、 common-environment(共用リソース) から作っていきます。
具体的な作成物は VPC, Route53 Hosted Zone, ACM, DynamoDB 読み取り用の IAM ロールになります。

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.2.0"

  name = "pod-identity-blue-green-upgrade-vpc"

  cidr = "10.0.0.0/16"

  azs             = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"]
  public_subnets  = ["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24"]
  private_subnets = ["10.0.100.0/24", "10.0.101.0/24", "10.0.102.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = true

  public_subnet_tags = {
    "kubernetes.io/role/elb" = 1
  }

  private_subnet_tags = {
    "kubernetes.io/role/internal-elb" = 1
  }

}

data "aws_route53_zone" "root" {
  name = local.hosted_zone_name
}

resource "aws_route53_zone" "sub" {
  name = "${local.sub_domain_name}.${local.hosted_zone_name}"
}

resource "aws_route53_record" "ns" {
  zone_id = data.aws_route53_zone.root.zone_id
  name    = "${local.sub_domain_name}.${local.hosted_zone_name}"
  type    = "NS"
  ttl     = "30"
  records = aws_route53_zone.sub.name_servers
}

module "acm" {
  source  = "terraform-aws-modules/acm/aws"
  version = "~> 5.0.0"

  domain_name = "${local.sub_domain_name}.${local.hosted_zone_name}"
  zone_id     = aws_route53_zone.sub.zone_id

  subject_alternative_names = [
    "*.${local.sub_domain_name}.${local.hosted_zone_name}"
  ]

  validation_method   = "DNS"
  wait_for_validation = true

  tags = {
    Name = "${local.sub_domain_name}.${local.hosted_zone_name}"
  }
}

resource "aws_dynamodb_table" "test-dynamodb-table" {
  name           = "test-dynamodb"
  billing_mode   = "PAY_PER_REQUEST"
  hash_key       = "UserId"

  attribute {
    name = "UserId"
    type = "S"
  }
}

data "aws_iam_policy_document" "allow_pod_identity" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["pods.eks.amazonaws.com"]
    }

    actions = [
      "sts:AssumeRole",
      "sts:TagSession"
    ]
  }
}

resource "aws_iam_role" "read_dynamodb" {
  name               = "read-dynamodb-role"
  assume_role_policy = data.aws_iam_policy_document.allow_pod_identity.json
  managed_policy_arns = ["arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess"]
}

outputs.tf も定義しておきます。

output "vpc_id" {
  description = "The ID of the VPC"
  value       = module.vpc.vpc_id
}

output "private_subnet_ids" {
  description = "IDs of the Private Subnets"
  value       = module.vpc.private_subnets
}

output "aws_route53_zone" {
  description = "The new Route53 Zone"
  value       = aws_route53_zone.sub.name
}

output "pod_dynamodb_role_arn" {
  description = "IAM Role Arn of Pod to read dynamodb"
  value       = aws_iam_role.read_dynamodb.arn
}

次に EKS クラスターを作成します。

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 19.20.0"

  cluster_name                   = local.cluster_name
  cluster_version                = "1.27"
  cluster_endpoint_public_access = true

  cluster_addons = {
    eks-pod-identity-agent = {
      most_recent = true
    }
    aws-efs-csi-driver = {
      most_recent = true
    }
  }

  vpc_id     = data.terraform_remote_state.common-environment.outputs.vpc_id
  subnet_ids = data.terraform_remote_state.common-environment.outputs.private_subnet_ids

  eks_managed_node_groups = {
    initial = {
      node_group_name = local.node_group_name
      instance_types  = ["m3.medium"]

      min_size     = 2
      max_size     = 2
      desired_size = 2
      subnet_ids   = data.terraform_remote_state.common-environment.outputs.private_subnet_ids
    }
  }
}

module "alb_ingress_controller_role" {
  source    = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version = "~> 5.32.0"

  role_name = "alb-ingress-controller-role-blue"

  role_policy_arns = {
    policy = aws_iam_policy.alb_ingress_controller.arn
  }

  oidc_providers = {
    main = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["kube-system:aws-load-balancer-controller"]
    }
  }

}

resource "aws_iam_policy" "alb_ingress_controller" {
  name       = "alb-ingress-controller-policy-blue"
  policy     = file("${path.module}/policy/alb-ingress-controller-policy.json")
}

resource "aws_iam_policy" "external_dns" {
  name       = "external-dns-policy-blue"
  policy     = file("${path.module}/policy/external-dns-policy.json")
}


module "external_dns_role" {
  source    = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version = "~> 5.32.0"

  role_name = "external-dns-role-blue"

  role_policy_arns = {
    policy = aws_iam_policy.external_dns.arn
  }

  oidc_providers = {
    main = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["kube-system:external-dns"]
    }
  }

}

resource "aws_eks_pod_identity_association" "dynamo_read" {
  cluster_name    = module.eks.cluster_name
  namespace       = "app"
  service_account = "app-sa"
  role_arn        = data.terraform_remote_state.common-environment.outputs.pod_dynamodb_role_arn
}

一番下の aws_eks_pod_identity_association リソースが Pod Identity の設定になります。
Terraform AWS Provider は v5.29.0 で既に対応済みです。
実際にアプリケーションと ingress リソースをデプロイしてアクセスすると、無事 DynamoDB の情報を Pod 経由で取得できました。

$ curl https://www.eks-test.masutaro99.com
[{"UserId":{"S":"2"},"Point":{"N":"80"}},{"UserId":{"S":"1"},"Point":{"N":"70"}}]⏎

新しいクラスターを作成して、トラフィックを新規クラスターに流しても同様に DynamoDB の情報が取得できました。
Blue/Green アップグレードの際に IAM ロールを共用する構成がかなりやりやすくなりましたね。

ここで external-dns や ALB Ingress Controller についても IAM ロールを新旧クラスターで共用しても良いと思われる方もいらっしゃるかもしれません。
今回 Pod Identity を利用しなかったのは 利用可能な SDK のバージョンに制限があるためです。
例えば、SDK for Go v1 だと 1.47.11 以上でないとサポートされません。
Using a supported AWS SDK
v1.44.294 を利用している aws-load-balancer-controller は 現時点で Pod Identity を利用できません。
aws-load-balancer-controller/go.mod
同様に external-dns も検証時点では v1.44.311 を利用していたので、利用できませんでした。(現時点では v1.48.9 になっていたので使えそうでした。)
external-dns/go.mod
また、EKS アドオンも IRSA を利用する必要があるようです。

The EKS add-ons can only use IAM roles for service accounts instead.
EKS Pod Identity restrictions

Fargate 対応について

SDK のバージョンもそうですが、Fargate サポートしていない点も注意が必要です。

Linux and Windows pods that run on AWS Fargate (Fargate) aren't supported. Pods that run on Windows Amazon EC2 instances aren't supported.
EKS Pod Identity restrictions

Fargate は Service Account 単位での権限付与前提なので、Fargate こそ対応して欲しい所ですね。

Kubernetes リソースを覗いてみた

アドオンを追加すると、Daemonset として下記エージェントがインストールされます。

$ kubectl describe daemonset eks-pod-identity-agent -n kube-system
Name:           eks-pod-identity-agent
Selector:       app.kubernetes.io/instance=eks-pod-identity-agent,app.kubernetes.io/name=eks-pod-identity-agent
Node-Selector:  <none>
Labels:         app.kubernetes.io/instance=eks-pod-identity-agent
                app.kubernetes.io/managed-by=Helm
                app.kubernetes.io/name=eks-pod-identity-agent
                app.kubernetes.io/version=0.0.25
                helm.sh/chart=eks-pod-identity-agent-1.0.0
Annotations:    deprecated.daemonset.template.generation: 1
Desired Number of Nodes Scheduled: 2
Current Number of Nodes Scheduled: 2
Number of Nodes Scheduled with Up-to-date Pods: 2
Number of Nodes Scheduled with Available Pods: 2
Number of Nodes Misscheduled: 0
Pods Status:  2 Running / 0 Waiting / 0 Succeeded / 0 Failed
Pod Template:
  Labels:  app.kubernetes.io/instance=eks-pod-identity-agent
           app.kubernetes.io/name=eks-pod-identity-agent
  Init Containers:
   eks-pod-identity-agent-init:
    Image:      602401143452.dkr.ecr.ap-northeast-1.amazonaws.com/eks/eks-pod-identity-agent:0.0.25
    Port:       <none>
    Host Port:  <none>
    Command:
      /go-runner
      /eks-pod-identity-agent
      initialize
    Environment:  <none>
    Mounts:       <none>
  Containers:
   eks-pod-identity-agent:
    Image:       602401143452.dkr.ecr.ap-northeast-1.amazonaws.com/eks/eks-pod-identity-agent:0.0.25
    Ports:       80/TCP, 2703/TCP
    Host Ports:  0/TCP, 0/TCP
    Command:
      /go-runner
      /eks-pod-identity-agent
      server
    Args:
      --port
      80
      --cluster-name
      pod-identity-cluster-green
      --probe-port
      2703
    Liveness:   http-get http://localhost:probes-port/healthz delay=30s timeout=10s period=10s #success=1 #failure=3
    Readiness:  http-get http://localhost:probes-port/readyz delay=1s timeout=10s period=10s #success=1 #failure=30
    Environment:
      AWS_REGION:       ap-northeast-1
    Mounts:             <none>
  Volumes:              <none>
  Priority Class Name:  system-node-critical
Events:                 <none>

実際に Pod Identity を設定した Service Account に紐づく Pod を確認すると専用の環境変数が登録されてます。

$ kubectl describe pod fast-api-app-75ff4874cb-bq8x8  -n app
Name:             fast-api-app-75ff4874cb-bq8x8
Namespace:        app
Priority:         0
Service Account:  app-sa
Node:             ip-10-0-100-12.ap-northeast-1.compute.internal/10.0.100.12
Start Time:       Wed, 06 Dec 2023 08:40:56 +0000
Labels:           app.kubernetes.io/name=fast-api-app
                  pod-template-hash=75ff4874cb
Annotations:      <none>
Status:           Running
IP:               10.0.100.120
IPs:
  IP:           10.0.100.120
Controlled By:  ReplicaSet/fast-api-app-75ff4874cb
Containers:
  fast-api-app:
    Container ID:   containerd://baa9ea99a38a13415b397c3827219fd48407e6ccba9ce8d479913b39295e604d
    Image:          123456789123.dkr.ecr.ap-northeast-1.amazonaws.com/fast-api-app:latest
    Image ID:       123456789123.dkr.ecr.ap-northeast-1.amazonaws.com/fast-api-app@sha256:3027af3d9aed3abaee6aa3220c373428a11a9db81b4729231adc2c484654b106
    Port:           8000/TCP
    Host Port:      0/TCP
    State:          Running
      Started:      Wed, 06 Dec 2023 08:41:02 +0000
    Ready:          True
    Restart Count:  0
    Environment:
      AWS_STS_REGIONAL_ENDPOINTS:              regional
      AWS_DEFAULT_REGION:                      ap-northeast-1
      AWS_REGION:                              ap-northeast-1
      AWS_CONTAINER_CREDENTIALS_FULL_URI:      http://169.254.170.23/v1/credentials
      AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE:  /var/run/secrets/pods.eks.amazonaws.com/serviceaccount/eks-pod-identity-token
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-2fnjd (ro)
      /var/run/secrets/pods.eks.amazonaws.com/serviceaccount from eks-pod-identity-token (ro)
Conditions:
  Type              Status
  Initialized       True 
  Ready             True 
  ContainersReady   True 
  PodScheduled      True 
Volumes:
  eks-pod-identity-token:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  86400
  kube-api-access-2fnjd:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  3607
    ConfigMapName:           kube-root-ca.crt
    ConfigMapOptional:       <nil>
    DownwardAPI:             true
QoS Class:                   BestEffort
Node-Selectors:              <none>
Tolerations:                 node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:                      <none>

最後に

複数クラスターで IAM ロールを共用する際に特に便利な機能かと思いました!
しかし、SDK のバージョンによっては使えない点や Fargate 未対応な点もありすぐ採用できるかというと怪しい面もあるかもしれません。
ただ利用できるなら大分便利に感じるので是非検証から始めてみてはいかがでしょうか?