terraformでEKS上にオンラインシステムを構築してみた

# はじめに
最近マルチクラウドへの需要が増えている気がします。
パブリッククラウド毎のサービス特性やビジネス判断によって、その時々での最適解を選ぶ必要性に迫られている今日この頃。。

今の現場ではAWS ECS上でコンテナオーケストレーションを行っていますが、既存システムを他クラウドへ移行する際のポータビリティを考えると、ECSは完全にAWSロックインだし、Kubernetes Cluster管理だけ任せることができればEKS、GKE、AKSのように他クラウドへの移行も簡単になるのかなと漠然と考えていました。
個人的にもKubernetes Service系のマネージドサービスを使ってみたいということもあり、今回はEKSを使って簡単なオンラインシステムを構築してみたので、手順と所感を残していきます。

# アーキテクチャ
- インターネットからの通信をALBで受けて、バックエンドのnginxに接続する。
- 名前解決にはexternal-dnsコンテナを利用する。

![eks.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/66e16167-08e6-4bf0-85fa-662f0ee6a3b3.png)

# フォルダ構成
```
terraform
│  eks.tf
│  helm_external_dns.tf
│  helm_nginx.tf
│  iam_service_account.tf
│
└─charts
    ├─external_dns
    │  │  Chart.yml
    │  │  values.yml
    │  │
    │  └─templates
    │          external-dns.yml
    │
    └─online_app
        │  Chart.yml
        │  values.yml
        │
        └─templates
                nginx-deployment.yml
                nginx-service.yml
```

# 構築手順
## EKS ClusterとWorker Nodeのデプロイ
terraform公式の [eksモジュール](https://github.com/terraform-aws-modules/terraform-aws-eks)を利用。公式なだけあってよく作りこまれています。
work_groupsの設定だけカスタマイズするくらいで、あとは特に変更せずに使えました。

``` eks.tf
module "online_app" {
  source          = "terraform-aws-modules/eks/aws"
  version         = "v12.2.0"
  cluster_name    = "online-app"
  cluster_version = "1.17"

  // publicサブネットを含めないとexternal ALBが作成されないので注意
  subnets         = concat(data.aws_subnet.private_subnets.*.id, data.aws_subnet.public_subnets.*.id)
  vpc_id          = data.aws_vpc.vpc.id

  worker_groups = [
    {
      instance_type                 = "t3.medium"
      asg_max_size                  = 1
      // worker nodeにssh接続したい場合に指定
      key_name                      = "dummy"
      // ssh接続元IPを絞りたいなど、特殊な通信要件がある場合に指定
      additional_security_group_ids = [sg-xxxx]
      subnets                       = data.aws_subnet.private_subnets.*.id
    }
  ]
}
```

terraform applyを実行すればEKSクラスタ構築は完了です。構築に15分ほどかかるので気長に待ちます。

## k8nのコード準備
ECSではterraformコードとして表現していたコンテナオーケストレーションのコードを、EKSではKubernetesのyamlファイルに記述する必要があります。
LoadBalancerのクラウド毎の個別設定はmetadata->annotationsで設定していきます。
[LoadBalancer service](https://kubernetes.io/ja/docs/concepts/services-networking/service/#loadbalancer)

``` charts/templates/nginx-deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx
          ports:
            - containerPort: 80
```

kubernetes単独だと変数化ができないので、helmを利用して変数化しています。

``` charts/templates/nginx-service.yml
apiVersion: apps/v1
kind: Service
metadata:
  name: nginx-service
  labels:
    app: nginx
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-backend-protocol: http
    service.beta.kubernetes.io/aws-load-balancer-ssl-cert: {{ .Values.loadBalancer.sslCert }}
    service.beta.kubernetes.io/aws-load-balancer-ssl-ports: "https"
    external-dns.alpha.kubernetes.io/hostname: {{ .Values.loadBalancer.hostname }}
spec:
  selector:
    app: nginx
  ports:
    - name: https
      port: 443
      targetPort: 80
      protocol: TCP
  type: LoadBalancer
```

作成されたLoad Balancerの名前解決はどうすればいいのか悩んでいたところ、[externalDNS](https://github.com/kubernetes-sigs/external-dns)というAuto Discovery用のサービスがあるとのこと。metadata->annotationsに `external-dns.alpha.kubernetes.io/hostname` を指定することで、Serviceに到達するためのRoute 53 recordを自動で作成してくれます。Kubernetes yamlはexternalDNSのサイトに記載されているテンプレートを元に、変数化しました。

``` external-dns.yml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-dns
  annotations:
    eks.amazonaws.com/role-arn: {{ .Values.eks.serviceAccountRoleArn }}
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
  name: external-dns
rules:
  - apiGroups: [""]
    resources: ["services","endpoints","pods"]
    verbs: ["get","watch","list"]
  - apiGroups: ["extensions","networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get","watch","list"]
  - apiGroups: [""]
    resources: ["nodes"]
    verbs: ["list","watch"]
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  name: external-dns-viewer
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: external-dns
subjects:
  - kind: ServiceAccount
    name: external-dns
    namespace: default
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
spec:
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: external-dns
  template:
    metadata:
      labels:
        app: external-dns
    spec:
      serviceAccountName: external-dns
      containers:
        - name: external-dns
          image: k8s.gcr.io/external-dns/external-dns:v0.7.3
          args:
            - --source=service
            - --source=ingress
            - --domain-filter={{ .Values.externalDns.domainFilter }}
            - --provider=aws
            - --aws-zone-type={{ .Values.externalDns.zoneType }}
            - --registry=txt
            - --txt-owner-id=my-hostedzone-identifier
      securityContext:
        fsGroup: {{ .Values.externalDns.securityContext.fsGroup }}
```

externalDNSコンテナにRoute53の操作を許可するために、Service Accountとして利用するIAM Roleを作成して、EKSのOpenID Connect Providerと連携させます。
[こちらのページ](https://qiita.com/keiSunagawa/items/fb2d2edb244522a0d981)を参考に、terraformコードを作成しました。

``` iam_service_account.tf
data "aws_iam_policy_document" "eks_assume_role_policy" {
  statement {
    effect = "Allow"

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.eks.arn]
    }

    actions = ["sts:AssumeRoleWithWebIdentity"]
    condition {
      test     = "StringEquals"
      variable = "${aws_iam_openid_connect_provider.eks.url}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

data aws_iam_policy_document AllowExternalDNSUpdates {
  statement {
    effect = "Allow"

    actions = [
      "route53:ChangeResourceRecordSets",
    ]

    resources = [
      "arn:aws:route53:::hostedzone/*",
    ]
  }

  statement {
    effect = "Allow"

    actions = [
      "route53:ListHostedZones",
      "route53:ListResourceRecordSets"
    ]

    resources = [
      "*",
    ]
  }
}

resource aws_iam_role_policy cluster_AllowExternalDNSUpdates {
  name   = "AllowExternalDNSUpdates"
  role   = aws_iam_role.external_dns_cluster_AllowExternalDNSUpdates.id
  policy = data.aws_iam_policy_document.AllowExternalDNSUpdates.json
}

resource aws_iam_role external_dns_cluster_AllowExternalDNSUpdates {
  name               = "${local.name}-external-dns"
  assume_role_policy = data.aws_iam_policy_document.eks_assume_role_policy.json
}

resource aws_iam_openid_connect_provider eks {
  url = module.online_app.cluster_oidc_issuer_url

  client_id_list = [
    "sts.amazonaws.com"
  ]
  thumbprint_list = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"]
}
```

# helmコードの準備
AWSリソースとKubernetesコンテナの準備が整ったので、コンテナをデプロイするためのコードを作ります。helmをそのまま使ってもデプロイできますが、今回は[terraform Helm provider](https://registry.terraform.io/providers/hashicorp/helm/latest/docs)を利用しました。

terraform Helm providerを使ってみて、下記の点で素晴らしいと感じました。
1. terraformで構築したAWSの情報を共有できるため、helm実行時に渡す変数をaws-cliなどを使って取得する必要がない。
2. helm chartsのデプロイ状態がterraform stateとして管理できる。

Helmのコードとterraform Helm Providerのコードは以下の通りです。

``` charts/nginx/Charts.yml
apiVersion: v1
appVersion: "1.0"
description: nginx helm chart for Kubernetes
name: nginx
version: 1.0.0
```

``` charts/nginx/values.yml
loadBalancer:
  sslCert:
  hostname:
```

``` helm_nginx.tf
provider "helm" {
  version = "~> 1.3.0"

  kubernetes {
    config_path = module.online_app.kubeconfig_filename
  }
}

resource "helm_release" "nginx" {
  name         = "nginx-chart"
  chart        = "./charts/nginx"
  // force_updateをtrueにすることで、helm chartに差分があったら更新する。
  force_update = true

  set {
    name  = "loadBalancer.sslCert"
    value = data.aws_acm_certificate.this.arn
  }
  set {
    name  = "loadBalancer.hostname"
    value = "nginx.${data.aws_route53_zone.this.name}"
  }
}
```

同様にexternalDNSのデプロイコードも作成していきます。

``` charts/external_dns/Charts.yml
apiVersion: v1
appVersion: "1.0"
description: external-dns chart for Kubernetes
name: external-dns
version: 1.0.0
```

``` charts/external_dns/values.yml
eks:
  serviceAccountRoleArn:

externalDns:
  domainFilter:
  zoneType: public
  securityContext:
    fsGroup: 65534
```

``` helm_external_dns.tf
resource "helm_release" "external_dns" {
  name         = "external-dns-chart"
  chart        = "./charts/external_dns"
  force_update = true

  set {
    name  = "eks.serviceAccountRoleArn"
    value = aws_iam_role.external_dns_cluster_AllowExternalDNSUpdates.arn
  }

  set {
    name  = "externalDns.domainFilter"
    value = data.aws_route53_zone.this.name
  }
}
```

# 動作確認
コードの準備ができたら再度terraform applyを実行します。
うまく動いているようなら `external-dns.alpha.kubernetes.io/hostname` で指定したホストに接続することで、nginxの画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/6f10ae1d-6065-476f-f1c3-12eab153a495.png)
また、externalDNS podのログを見るとちゃんとRoute 53へのUPSERTに成功していることが確認できました。
```
kubectl logs external-dns-xxx  # please use your pod id
```
![externalDNS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/7fbb57e3-54e2-1446-8051-1a22764d8b3a.png)

# EKSを使ってみて所感
1. EKSクラスタ作成に時間がかかる。
コスト節約のために毎回terraformでVPC周りから検証環境を作り直しているが、ECSと比べてEKSはクラスタ作成に時間がかかる。検証するたびに毎回15分以上待つのはツライ。。

2. ECSよりもポータビリティが上がった (気がする…)
Kubernetes yamlに付与するmetadataの種類によってマルチクラウドに対応可能。
ECSでは必要なAWSリソースを自分で構築しなければいけなかったが、EKSではmetadataを付与することでALBが自動で作成されたり、DNS recordが作られたりと、特定クラウドを意識させない点はよい。

3. リソースの統合管理
ECSはAWS管理コンソール上で、コンテナインスタンスのリソース使用状況、各タスクの実行状態やサービスタスク起動数など確認できる統合管理画面が提供されているが、それに比べるとEKSの管理画面は情報が不足していると感じた。Kubernetesの統合管理を実現するための仕組みを検討する必要がある。