はじめに

CI 事業部 セキュリティセクションの村上です。主にセキュリティサービスの設計・運用をしていますが、最近はその運用を支える各種システムの基盤整備、プラットフォームエンジニアリング的なところも担当しています。

これまで、Amazon EKS (Elastic Kubernetes Service) における外部へのサービス公開には、主に AWS Load Balancer Controller (LBC) と Ingress が使われてきました。
少し前にリリースされた AWS LBC v2.14.0 で、Gateway API のベータサポートが開始されました。
本記事では、この新しい機能に加えて、DNS レコードを Kubernetes 環境から管理できる ExternalDNS、さらに EKS Pod Identityクロスアカウント連携を利用して、EKS サービスを外部公開するまでを試してみました。

Gateway API とは

Gateway API は、Ingress の後継として開発されている Kubernetes の新しい API 群です。

クラスター管理者が GatewayClass や Gateway を管理し、アプリケーション開発チームが HTTPRoute などのルートリソースを管理するといった、役割に応じた責任の分離が可能です。
Ingress のようにクラウドの種類に応じた annotation を付与することなく、クラウドに依存しないルート設定も可能です。

新バージョンの AWS LBC では、Gateway リソースを作成すると ALB (Application Load Balancer) や NLB (Network Load Balancer) がプロビジョニングされ、HTTPRoute リソースや GRPCRoute リソースによってターゲットグループやリスナールールが設定されます。LBC の Gateway API サポートは現在ベータ版として提供されています。

やってみた

構成

本記事で試したアーキテクチャは以下の通りです。
EKS クラスターが存在するアカウントとは別のアカウントで Route 53 のホストゾーンを管理する、クロスアカウント構成を採用しました。

前提条件

今回は最新の AWS LBC を使用するため、LBC が AWS 側で管理される Auto Mode ではなく、通常の EKS を使用し、LBC をインストールします。また、EKS Addon で Pod Identity を導入しておきます。
また、Route 53 Hosted Zone を EKS クラスターとは別の AWS アカウントに予め作成しておきます。
Application Load Balancer で使用する証明書と、WAF も予め作成しておきます。

なるべく IaC を利用して定義していきます。Terraform + Helm を使用します。

1. Role の準備

EKS クラスターが存在する AWS アカウントに、LBC と ExternalDNS が使用する IAM Role を作成します。

variable "cluster_name" {
  description = "EKS cluster name"
  type        = string
}

variable "external_dns_namespace" {
  description = "Kubernetes namespace where ExternalDNS is deployed"
  type        = string
}

variable "external_dns_route53_access_role_arn" {
  description = "IAM role ARN for ExternalDNS in the DNS management account"
  type        = string
}
resource "aws_iam_role" "external_dns" {
  name = "${var.cluster_name}-external-dns-assumer-role"

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

resource "aws_iam_policy" "external_dns" {
  name = "${var.cluster_name}-external-dns-assumer-policy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "sts:AssumeRole",
          "sts:TagSession"
        ]
        Resource = [
          var.external_dns_route53_access_role_arn
        ]
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "external_dns" {
  policy_arn = aws_iam_policy.external_dns.arn
  role       = aws_iam_role.external_dns.name
}

Route 53 Hostzone が存在する AWS アカウントに、上記で作成した IAM Role が Assume でき、レコード更新ができるような IAM Role を作成します。

variable "role_name" {
  description = "Name of the IAM role"
  type        = string
}

variable "assumer_account_id" {
  description = "AWS Account ID of the role that can assume this role"
  type        = string
}

variable "assumer_role_arn" {
  description = "ARN of the role that can assume this role"
  type        = string
}

variable "eks_cluster_arn" {
  description = "EKS Cluster ARN"
  type        = string
}

variable "kubernetes_namespace" {
  description = "Kubernetes namespace where ExternalDNS is deployed"
  type        = string
}

variable "kubernetes_service_account" {
  description = "Kubernetes service account name for ExternalDNS"
  type        = string
  default     = "external-dns"
}

variable "hosted_zone_id" {
  description = "Route53 hosted zone ID"
  type        = string
}
# IAM Role for Kubernetes ExternalDNS
resource "aws_iam_role" "k8s_external_dns" {
  name = var.role_name

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${var.assumer_account_id}:root"
        }
        Action = [
          "sts:AssumeRole",
          "sts:TagSession"
        ]
        Condition = {
          StringEquals = {
            "aws:RequestTag/eks-cluster-arn"            = var.eks_cluster_arn
            "aws:RequestTag/kubernetes-namespace"       = var.kubernetes_namespace
            "aws:RequestTag/kubernetes-service-account" = var.kubernetes_service_account
          }
          ArnEquals = {
            "aws:PrincipalARN" = var.assumer_role_arn
          }
        }
      }
    ]
  })
}

# IAM Policy for ExternalDNS
resource "aws_iam_policy" "k8s_external_dns" {
  name        = "${var.role_name}-policy"
  description = "Policy for Kubernetes ExternalDNS to manage Route53 records"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "route53:ChangeResourceRecordSets"
        ]
        Resource = "arn:aws:route53:::hostedzone/${var.hosted_zone_id}"
      },
      {
        Effect = "Allow"
        Action = [
          "route53:ListHostedZones",
          "route53:ListResourceRecordSets"
        ]
        Resource = "*"
      }
    ]
  })
}

# Attach policy to role
resource "aws_iam_role_policy_attachment" "k8s_external_dns" {
  role       = aws_iam_role.k8s_external_dns.name
  policy_arn = aws_iam_policy.k8s_external_dns.arn
}

2. Pod Identity Association 設定

LBC と ExternalDNS の Pod が使用する Service Account が IAM Role の権限を借り受けて利用できるような設定を行います。
Pod Identity は、IRSA の後継で、annotation を使わずに AWS 側だけで Pod (Service Account) に対する AWS へのアクセス権限付与が完了する便利な機能です。

resource "aws_eks_pod_identity_association" "external_dns" {
  cluster_name    = var.cluster_name
  namespace       = var.external_dns_namespace
  service_account = "external-dns"
  role_arn        = aws_iam_role.external_dns.arn
  target_role_arn = var.external_dns_route53_access_role_arn
}

3. CRD のインストール

Gateway API の CRD をインストールします。

こちら で紹介されている手順通り、kubectl でインストールしても良いのですが、コード管理するため、各 CRD (BackendTLSPolicy、GatewayClass、Gateway、GRPCRoute、HTTPRoute、ReferenceGrant) のマニフェストを適当なディレクトリ (下の例では crd-manifests フォルダ) に保存し、Terraform でインストールしました。

ephemeral "aws_eks_cluster_auth" "main" {
  name = var.cluster_name
}

resource "kubernetes_manifest" "crds" {
  for_each = fileset(path.module, "crd-manifests/*.yaml")

  manifest = yamldecode(file(each.value))
}

4. Helm チャートの作成

Helm チャートを作成して、Load Balancer Controller と Gateway 設定、ExternalDNS 設定を行います。今回は、LBC と ExternalDNS を依存関係 (Dependencies) として定義し、一括で管理します。

以下のような構成でフォルダ・ファイルを配置します (infra という名前と templates フォルダ内の各ファイルの名前は適当です)

infra
└ templates
    └ alb-gateway.yaml
    └ alb-gatewayclass.yaml
    └ alb-config.yaml
└ Chart.yaml
└ values.yaml

各ファイルの内容は以下のようになっています。

Chart.yaml で LBC と ExternalDNS を依存関係として定義します。

apiVersion: v2
name: shared
description: Shared resources for k8s cluster
type: application
version: 0.1.0
appVersion: "1.0"

dependencies:
  - name: aws-load-balancer-controller
    version: "1.14.0"
    repository: https://aws.github.io/eks-charts
    condition: control.platform.aws
  - name: external-dns
    version: "1.19.0"
    repository: https://kubernetes-sigs.github.io/external-dns/

values.yaml で、LBC の Gateway API の有効化と、ExternalDNS が Gateway リソースの設定をもとに DNS レコードを更新するようにオプションを設定します。

global:
  clusterName: &clusterName shared-k8s-cluster
aws-load-balancer-controller:
  clusterName: *clusterName
  serviceAccount:
    name: aws-load-balancer-controller
  defaultTargetType: ip
  controllerConfig:
    featureGates:
      ALBGatewayAPI: true # Gateway API サポートを有効化
external-dns:
  policy: sync # DNS レコードの追加削除の両方行う
  txtOwnerId: *clusterName
  serviceAccount:
    name: external-dns
  provider:
    name: aws
  env:
    - name: AWS_DEFAULT_REGION
      value: us-east-1 # EKS 稼働リージョン
  sources:
    - gateway-httproute # HTTPRoute の host 名を DNS レコードのソース情報とする

sources の設定は、ExternalDNS が Gateway API の HTTPRoute リソースに書かれた hostnames を監視し、自動で DNS レコードを作成・更新するための設定です。
デフォルトでは ingress などが対象になっており、複数のリソースの hostname 情報を参照可能ですが、今回は HTTPRoute のみ対象としています。

Templates 配下の各ファイルで、GatewayClass、Gateway、LoadBalancerConfiguration を定義します。

slb-gatewayclass.yaml

apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: shared-k8s-gateway-class
spec:
  controllerName: gateway.k8s.aws/alb

alb-gateway.yaml

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: shared-k8s-gateway
spec:
  gatewayClassName: shared-k8s-gateway-class
  infrastructure:
    parametersRef:
      kind: LoadBalancerConfiguration
      name: shared-k8s-lbconfig
      group: gateway.k8s.aws
  listeners:
  - name: https
    protocol: HTTPS
    port: 443
    allowedRoutes:
      namespaces:
        from: All # 検証のため、すべての namespace からの HTTPRoute を受け入れ

alb-config.yaml

apiVersion: gateway.k8s.aws/v1beta1
kind: LoadBalancerConfiguration
metadata:
  name: shared-k8s-lbconfig
spec:
  scheme: internet-facing
  listenerConfigurations:
    - protocolPort: HTTPS:443
      defaultCertificate: "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
  wafV2:
    webACL: "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/shared-webacl/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

事前に作成した ACM の証明書と Web ACL を適用する設定を入れています。

5. Helm チャートのインストール

helm dependency update でサブチャートを取得した後、親チャートをデプロイします。

6. サンプルアプリのデプロイ

ここからは、アプリ担当者の作業を想定したものです。
まず、適当なアプリケーションをクラスターにデプロイし、service として公開します。(動作検証したいだけなので、ここだけ kubectl にて…)

kubectl run sample-app --image=nginx

続いて Service として公開します。

k expose pod sample-app --port=8080

7. HTTPRoute リソースの作成

先ほどデプロイしたサンプルアプリを ALB 経由で公開し、更にカスタムドメインで HTTPS でアクセスできるようにします。

以下のようなマニフェストファイルを作ってデプロイするだけです。

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: sample-app-route
spec:
  parentRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      namespace: shared
      name: shared-k8s-gateway
      sectionName: https
  hostnames:
    - sample-app.example.com
  rules:
    - backendRefs:
        - group: ""
          kind: Service
          name: sample-app
          port: 8080
      matches:
        - path:
            type: PathPrefix
            value: /

数分も経たないうちに、ALB のリスナールールが更新され、Route 53 に DNS レコードが追加され、アプリにアクセスできるようになりました。

おわりに

AWS Load Balancer Controller でベータサポートとなった Gateway API と ExternalDNS、Pod Identity を組み合わせ、EKS におけるサービス公開の効率化を試みました。

クラウド依存の annotation から解放され、より柔軟なルーティング設定や、インフラ管理者とアプリ開発チーム間の責務分離が容易になるなど、マルチクラウド環境では強力な選択肢になると考えられます。