EKS+Terraform+CircleCIで作るコンテナアプリのインフラと開発基盤

# この記事はなに

EKS+Terraform+CircleCI(+Go)な構成のアプリを作る際の[インフラ周りのスターターキット的なリポジトリ](https://github.com/sxarp/eks-terraform-ci-sample)を作ったのですがそれの解説記事です。
元ネタは自分が以前(メインで)担当したマイクロサービスの構成で構築中/運用中に発生した種々のトラブルの問題解決を反映させています。

## 構成図

使っている要素技術をざっくり構成図にするとこんな感じです。

![arch_k.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132253/898a3651-2698-210f-134c-60e694ceebf2.png)



## 技術的なポイント/見どころ
- CircleCIからの`terraform apply`及び`kubeclt apply`
- HorizantalPodAutoscaler/ClusterAutoscalerによるオートスケール
- kustomizeによる環境ごとのマニフェストのレンダリング

## インフラとして目指したい形

* システムの定常的な設定(=AWSリソース/K8sのオブジェクトなどで時間変化しないもの)の全てがコード化されている
* コードと実際のシステムの設定とが同期していることがCIによって保証されている(マネジメントコンソールやCLIからの操作は基本必要なし)

## 目次

- [Teraform関連](https://qiita.com/sxarp/items/e93331169c5b76c75525#terraform%E9%96%A2%E9%80%A3)
  - [CircleCIから`terraform apply`](https://qiita.com/sxarp/items/e93331169c5b76c75525#circleci%E3%81%8B%E3%82%89terraform-apply)
  - [tfstateはS3で管理、DynamoDBでロックを取る](https://qiita.com/sxarp/items/e93331169c5b76c75525#tfstate%E3%81%AFs3%E3%81%A7%E7%AE%A1%E7%90%86dynamodb%E3%81%A7%E3%83%AD%E3%83%83%E3%82%AF%E3%82%92%E5%8F%96%E3%82%8B)
  - [ローカルからのplanの実行(docker-composeを使用)](https://qiita.com/sxarp/items/e93331169c5b76c75525#%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E3%81%8B%E3%82%89%E3%81%AEplan%E3%81%AE%E5%AE%9F%E8%A1%8Cdocker-compose%E3%82%92%E4%BD%BF%E7%94%A8)
  - [plan結果をPRのコメントにポスト](https://qiita.com/sxarp/items/e93331169c5b76c75525#plan%E7%B5%90%E6%9E%9C%E3%82%92pr%E3%81%AE%E3%82%B3%E3%83%A1%E3%83%B3%E3%83%88%E3%81%AB%E3%83%9D%E3%82%B9%E3%83%88)
- [K8s関連](https://qiita.com/sxarp/items/e93331169c5b76c75525#k8s%E9%96%A2%E9%80%A3)
  - [CircleCIから`kubectl apply`](https://qiita.com/sxarp/items/e93331169c5b76c75525#circleci%E3%81%8B%E3%82%89kubectl-apply)
  - [kustomizeによるyamlのレンダリング](https://qiita.com/sxarp/items/e93331169c5b76c75525#kustomize%E3%81%AB%E3%82%88%E3%82%8Byaml%E3%81%AE%E3%83%AC%E3%83%B3%E3%83%80%E3%83%AA%E3%83%B3%E3%82%B0)
  - [クラスターにインストールするリソースの管理方法](https://qiita.com/sxarp/items/e93331169c5b76c75525#%E3%82%AF%E3%83%A9%E3%82%B9%E3%82%BF%E3%83%BC%E3%81%AB%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB%E3%81%99%E3%82%8B%E3%83%AA%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%B3%95)
  - [HorizontalPodAutoscalerによるPod数のオートスケール](https://qiita.com/sxarp/items/e93331169c5b76c75525#horizontalpodautoscaler%E3%81%AB%E3%82%88%E3%82%8Bpod%E6%95%B0%E3%81%AE%E3%82%AA%E3%83%BC%E3%83%88%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%AB)
  - [ClusterAutoscalerによるノード数のオートスケール](https://qiita.com/sxarp/items/e93331169c5b76c75525#clusterautoscaler%E3%81%AB%E3%82%88%E3%82%8B%E3%83%8E%E3%83%BC%E3%83%89%E6%95%B0%E3%81%AE%E3%82%AA%E3%83%BC%E3%83%88%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%AB)
  - [kube-system上の重要なpodが落ちないようにする](https://qiita.com/sxarp/items/e93331169c5b76c75525#kube-system%E4%B8%8A%E3%81%AE%E9%87%8D%E8%A6%81%E3%81%AApod%E3%81%8C%E8%90%BD%E3%81%A1%E3%81%AA%E3%81%84%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B)
- [AWSリソース関連](https://qiita.com/sxarp/items/e93331169c5b76c75525#aws%E3%83%AA%E3%82%BD%E3%83%BC%E3%82%B9%E9%96%A2%E9%80%A3)
  - [ALB(IngressControllerではなくTerraform管理の)](https://qiita.com/sxarp/items/e93331169c5b76c75525#albingresscontroller%E3%81%A7%E3%81%AF%E3%81%AA%E3%81%8Fterraform%E7%AE%A1%E7%90%86%E3%81%AE)
  - [ワーカーノード](https://qiita.com/sxarp/items/e93331169c5b76c75525#%E3%83%AF%E3%83%BC%E3%82%AB%E3%83%BC%E3%83%8E%E3%83%BC%E3%83%89)
- [アプリケーションのCI/CD](https://qiita.com/sxarp/items/e93331169c5b76c75525#%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AEcicd)
  - [コンテナイメージのビルド/ECRへのプッシュ](https://qiita.com/sxarp/items/e93331169c5b76c75525#%E3%82%B3%E3%83%B3%E3%83%86%E3%83%8A%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8%E3%81%AE%E3%83%93%E3%83%AB%E3%83%89ecr%E3%81%B8%E3%81%AE%E3%83%97%E3%83%83%E3%82%B7%E3%83%A5)
  - [ローカル開発環境(docker-composeを使用)](https://qiita.com/sxarp/items/e93331169c5b76c75525#%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E9%96%8B%E7%99%BA%E7%92%B0%E5%A2%83docker-compose%E3%82%92%E4%BD%BF%E7%94%A8)
  - [test/lintをCIで回す](https://qiita.com/sxarp/items/e93331169c5b76c75525#testlint%E3%82%92ci%E3%81%A7%E5%9B%9E%E3%81%99)
  - [マルチステージビルド](https://qiita.com/sxarp/items/e93331169c5b76c75525#%E3%83%9E%E3%83%AB%E3%83%81%E3%82%B9%E3%83%86%E3%83%BC%E3%82%B8%E3%83%93%E3%83%AB%E3%83%89)
  - [Graceful Shutdown](https://qiita.com/sxarp/items/e93331169c5b76c75525#graceful-shutdown)

# Terraform関連

TerraformをCircleCIと一緒に使う場合の一般的な設定です。内容は以下のようになります。

- CircleCIから`terraform apply`
- tfstateはS3で管理、DynamoDBでロックを取る
- ローカルでplanの実行(docker-composeを使用)
- plan結果をPRのコメントにポスト

## CircleCIから`terraform apply`

`terraform apply`は本番に近い環境の場合、CIから行うのが治安が良いと思います。

<details>
<summary>Terraformを使う設定の.circleci/config.yml</summary>
<div>

`.circleci/config.yml`の中の関連する部分を紹介します。

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L4-L7)

```yaml:使用するimageの指定
executors:
  terraform:
    working_directory: ~/repo
    docker:
       - image: hashicorp/terraform:0.12.3
```

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L22-L25)

```yaml:AWSクレデンシャルの設定
      - run:
          name: setup aws credentials
          command: |
            echo "export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> $BASH_ENV
            echo "export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> $BASH_ENV
```

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L50-L60)

```yaml:applyの実行
  terraform-apply:
    executor: terraform
    steps:
      - restore_cache:
          key: terraform-{{ .Environment.CIRCLE_SHA1 }}
      - setup_aws_credentials
      - run:
          name: terraform apply
          command: |
            cd terraform
            terraform apply -auto-approve
```

</div>
</details>

## tfstateはS3で管理、DynamoDBでロックを取る

tfstateはローカルではなくS3などの上で管理するのが好ましいです。
またtfstateが破壊されないようにDynamoDB上のロックを取るようにするとさらに良いです。

[ここ](https://www.terraform.io/docs/backends/types/s3.html)を参考にその設定を作っていきます。

<details>
<summary>Terraformの設定のコード</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/master/terraform/terraform-config.tf)


```:terraformの設定
provider "aws" {
  region = "ap-northeast-1"
  version = "2.16"
}

terraform {
  backend "s3" {
    bucket = "terraform-backend-for-eks-sxarp"
    key    = "terraform"
    region = "ap-northeast-1"

    # tfstateに対してロックを取りつつ一貫性を維持しながら更新をかける
    # Production環境の場合は使用することを推奨
    dynamodb_table = "EKSSampleTfLock"
  }
}
```

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/master/terraform/terraform-infrastructure.tf)

```:S3とDDBの設定
resource "aws_s3_bucket" "terraform-backend" {
  bucket = "terraform-backend-for-eks-sxarp"
  acl    = "private"
}

resource "aws_dynamodb_table" "eks-sample-lock-table" {
  name           = "EKSSampleTfLock"
  billing_mode   = "PAY_PER_REQUEST"
  hash_key       = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}
```

</div>
</details>

## ローカルからのplanの実行(docker-composeを使用)

ローカルから`terraform plan`が打てるようにします。ローカルでのTerraformの管理は[tfenv](https://github.com/tfutils/tfenv)を使用するケースも多いですが、今回はdocker-composeを使い、簡単にplanが打てる環境を用意できるようにします。


<details>
<summary>Terraformを内包したdocker-composeの設定</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/docker-compose.yml#L3-L10)

```yaml
services:
  terraform:
    image: hashicorp/terraform:0.12.3
    working_dir: /terraform
    command: tail -f /dev/null
    volumes:
      - ./terraform:/terraform
    entrypoint: []
    env_file: .env
```



</div>
</details>

`.env`にAWSのクレデンシャルを設定したうえで

```sh
$ cat .env
AWS_ACCESS_KEY_ID=xxxxxxxxxxx
AWS_SECRET_ACCESS_KEY=xxxxxxxxxx
```

`docker-compose up -d`して以下を打つとplanが実行されます。

```sh
$ docker-compose exec terraform terraform plan
```

なお[Makefile](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/Makefile#L22)に`terarform-plan`というメニューも追加しているので`make terraform-plan`を打つだけでもplanを実行することができます。

## plan結果をPRのコメントにポスト

以下のようにPRにplan結果をポストするようにしています。[メルカリさんのtfnotify](https://github.com/mercari/tfnotify)をありがたく使用させて頂いています :bow:

<img width="701" alt="Screen Shot 2019-08-02 at 21.14.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132253/9d22ad52-53db-0f51-0dde-eb8a61d59be3.png">

[コメントへのリンク](https://github.com/sxarp/eks-terraform-ci-sample/pull/48#issuecomment-509916490)

設定法は[ここ](https://github.com/mercari/tfnotify/releases)からバイナリをダウンロードし、それを`.circleci/bin/tfnotify`に配置した上で、CircleCIの[ここ](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L44)でポストしています。

# K8s関連

K8s周りの設定です。

- CircleCIから`kubectl apply`
- kustomizeによるyamlのレンダリング
- クラスターにインストールするリソースの管理方法
- HorizontalPodAutoscalerによるPod数のオートスケール
- ClusterAutoscalerによるノード数のオートスケール
- kube-system上の重要なpodが落ちないようにする

## CircleCIから`kubectl apply`

CircleCIからマニフェストの適用をするためにkubectlをEKSに対して使えるようにします。

`kubectl`をEKSに対して使うには`aws eks update-kubeconfig`を打つ必要があるので、kubectlとAWS-CLIの２つが入ったCI用のイメージを用意します。

<details>
<summary>CI用イメージのDockerfile</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/master/.circleci/k8s_ci_image)

```Dockerfile
FROM python:rc-alpine3.10

ARG CLI_VERSION=1.16.190
ARG KUBECTL_VERSION=1.15.0

# add aws-cli
RUN apk -uv add --no-cache groff jq less && \
    pip install --no-cache-dir awscli==$CLI_VERSION

# add kubectl
# 最新バーションはこちらで探す:
# https://aur.archlinux.org/packages/kubectl-bin/
RUN wget https://storage.googleapis.com/kubernetes-release/release/v$KUBECTL_VERSION/bin/linux/amd64/kubectl ; \
    mv kubectl /usr/local/bin/kubectl; chmod +x /usr/local/bin/kubectl

```

</div>
</details>

このimageをexecutorに指定して以下を打つことで、EKSに対してkubectlが使えるようになります。

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L137)

```sh
$ aws eks update-kubeconfig --name terraform-eks-sample
```

なお、ここでAWS-CLIを使うのに指定するprofileはクラスターを作成したものと同一である必要があるので注意です([参考](https://docs.aws.amazon.com/eks/latest/userguide/troubleshooting.html#unauthorized))。

## kustomizeによるyamlのレンダリング

アプリケーションを環境ごと(develop/stagingなど)にnamespaceを切ってクラスターにデプロイする場合、[kustomize](https://github.com/kubernetes-sigs/kustomize)を使うとマニフェストのコピペを抑えられます。

今回の使い方としては、環境ごとに共通となるような設定を[k8s/base](https://github.com/sxarp/eks-terraform-ci-sample/tree/master/k8s/base)に置き、環境ごとに異なる(例えばstaging環境固有の)設定を[k8s/staging](https://github.com/sxarp/eks-terraform-ci-sample/tree/master/k8s/staging)以下に配置しています。

デプロイの際はimageの指定を以下のように行ってapplyしています。

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L146-L150)

```sh
cat <<EOF >> kustomization.yaml
images:
  - name: sample
    newName: $IMAGE_NAME
EOF
```

imageのtag指定は`"kustomize edit set image`が使えたのですが、kubectlに組み込みのkustomizeだとこれが使えないのでこのようなやり方を採用しています(そして別にこれで困らない)。

なお、一般的に本番環境とその他とではクラスター自体を分けた方が良いとされているので、一つのクラスターの中に複数環境を詰め込むのは開発系だけにしましょう。

## クラスターにインストールするリソースの管理方法

クラスターにインストールするリソース、例えばMetricsServerやClusterAutoscalerは`k8s/cluster/`以下にマニフェストを置き、CIで適宜(デプロイ時に毎回)applyしてクラスターに反映するようにしています。

<details>
<summary>クラスターをセットアップするCircleCIの設定</summary>
<div>


[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L28-L31)

```yaml:clusterのセットアップ
      - run:
          name: setup cluster
          command: |
            kubectl apply -R -f k8s/cluster/
```

</div>
</details>


すこし泥臭いですが、これでやっている理由は以下のようになります。

- シンプル
- クラスターに反映されているマニフェストが明確
- マニフェストの編集がしやすい

`k8s/cluster`配下で管理しているリソースは以下のようになります。

- [HorizontalPodAutoscalerに必要なMetricsServer](https://github.com/sxarp/eks-terraform-ci-sample/tree/master/k8s/cluster/metrics-server)
- [ノード数をスケールさせるClusterAutoScaler](https://github.com/sxarp/eks-terraform-ci-sample/tree/master/k8s/cluster/cluster-autoscaler)
- [Podの優先度を指定するPriorityClass](https://github.com/sxarp/eks-terraform-ci-sample/blob/master/k8s/cluster/common/priority_class.yaml)
- [ノードをクラスターに参加させるConfigMap](https://github.com/sxarp/eks-terraform-ci-sample/blob/master/k8s/cluster/aws/aws_auth.yml)

## HorizontalPodAutoscalerによるPod数のオートスケール

[HorizontalPodAutoscaler](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/)を使ってPod数を負荷状況に応じてスケールさせます。

そのためにまず[MetricsServer](https://github.com/kubernetes-incubator/metrics-server)を導入しますが、[こちらのマニフェスト](https://github.com/kubernetes-incubator/metrics-server/tree/4f8dd2f7719bdb0954220490ca327750064c8944/deploy/1.8%2B)をそのまま持ってくると、EKSの場合以下のようなエラーが出る

<details>
<summary>`Error from server (ServiceUnavailable): the server is currently unable to handle the request (get nodes.metrics.k8s.io)`</summary>
<div>

```sh
$ kubectl top node
Error from server (ServiceUnavailable): the server is currently unable to handle the request (get nodes.metrics.k8s.io)

$ stern -n kube-system metric
....
metrics-server-6c6c6c6457-6s7ct metrics-server E0710 05:02:38.034206       1 manager.go:111] unable to fully collect metrics: [unable to fully scrape metrics from source kubelet_summary:ip-10-101-1-15.ap-northeast-1.compute.internal: unable to fetch metrics from Kubelet ip-10-101-1-15.ap-northeast-1.compute.internal (ip-10-101-1-15.ap-northeast-1.compute.internal): Get https://ip-10-101-1-15.ap-northeast-1.compute.internal:10250/stats/summary/: dial tcp: lookup ip-10-101-1-15.ap-northeast-1.compute.internal on 172.20.0.10:53: no such host, unable to fully scrape metrics from source kubelet_summary:ip-10-101-0-13.ap-northeast-1.compute.internal: unable to fetch metrics from Kubelet ip-10-101-0-13.ap-northeast-1.compute.internal (ip-10-101-0-13.ap-northeast-1.compute.internal): Get https://ip-10-101-0-13.ap-northeast-1.compute.internal:10250/stats/summary/: dial tcp: lookup ip-10-101-0-13.ap-northeast-1.compute.internal on 172.20.0.10:53: no such host]
....
```

</div>
</details>

ので[こちらのissue](https://github.com/kubernetes-incubator/metrics-server/issues/165#issuecomment-435272021)を参考に以下の起動オプションを追加しました。

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/k8s/cluster/metrics-server/metrics-server-deployment.yaml#L46)

```yaml:起動オプション
          - --kubelet-preferred-address-types=InternalIP
```

また、ネットワーク的にマスターノード=>ワーカーノードでPort番号443が許可されている必要があるのでその設定もします。

<details>
<summary>443を許可するSGの設定</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/terraform/worker_node.tf#L111-L120)

```:443を許可するSGの設定
resource "aws_security_group_rule" "sample-node-ingress-cluster" {
  description              = "Allow worker Kubelets and pods to receive communication from the cluster control plane"
  # cluster -> worker nodesで443が許可されてないとmetrics-serverがうまく動かない
  from_port                = 0
  protocol                 = "tcp"
  security_group_id        = "${aws_security_group.sample-node.id}"
  source_security_group_id = "${aws_security_group.sample-cluster.id}"
  to_port                  = 65535
  type                     = "ingress"
}
```

</div>
</details>

あとはHPAのマニフェストをapplyすればPod数がオートスケールされます。


<details>
<summary>HorizontalPodAutoscalerのマニフェスト</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/master/k8s/base/hpa.yaml)

```yaml
apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  name: sample
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: sample
  minReplicas: 2
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 15
```

</div>
</details>

なお、HPAを使う場合、Deploymentにレプリカ数を指定していると干渉しておかしなことになる(デプロイ時に瞬間的にPod数がゼロになる)のでレプリカ数は指定しないようにしましょう([参考](https://github.com/kubernetes/kubernetes/issues/25238))。

また、Podに[Resource Quotas](https://kubernetes.io/docs/concepts/policy/resource-quotas/)を設定しないとスケーリングがうまく動かないので忘れずに設定するようにしましょう([設定例](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/k8s/base/deployment.yaml#L25-L29))。

## ClusterAutoscalerによるノード数のオートスケール

ノードをオートスケールさせるやり方として、AutoScalingGroupに標準で付いているCPUやメモリの使用率でスケーリングさせる方法もありますが、今回は[ClusterAutoscaler](https://github.com/kubernetes/autoscaler/tree/master/cluster-autoscaler)を使用します。
理由としては、「Podがスケージュール不可能となったときにノード数を増やす」というシンプルなルールでスケールアウト/インを行いたいからです。
また、EKSではインスタンスタイプごとにスケージュール可能なPodの数に制限がかかっている([参考](https://github.com/awslabs/amazon-eks-ami/blob/master/files/eni-max-pods.txt))ので、必ずしもPodがスケージュール不可=CPU/メモリ使用率が限界とはならないというのもあります。

ClusterAutoscalerの導入は[ここ](https://github.com/kubernetes/autoscaler/tree/master/cluster-autoscaler/cloudprovider/aws)を参考にすると簡単に行なえます。基本的に[このyaml](https://github.com/kubernetes/autoscaler/blob/master/cluster-autoscaler/cloudprovider/aws/examples/cluster-autoscaler-autodiscover.yaml)をそのまま持ってきますが、[この部分](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/k8s/cluster/cluster-autoscaler/cluster-autoscaler-autodiscover.yaml#L148)だけ調整しています。

Terraform(AWS)側の設定(権限周りとタグ)は以下のようになります。

<details>
<summary>Terraformの設定</summary>
<div>

ClusterAutoScalerが動くノードに付加する権限

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/terraform/worker_node.tf#L39-L69)

```:権限
data "aws_iam_policy_document" "cluster-autoscaler" {
  statement {
    effect = "Allow"
    actions = [
      "autoscaling:DescribeAutoScalingGroups",
      "autoscaling:DescribeAutoScalingInstances",
      "autoscaling:DescribeLaunchConfigurations",
      "autoscaling:DescribeTags",
      "autoscaling:SetDesiredCapacity",
      "autoscaling:TerminateInstanceInAutoScalingGroup"
    ]

    resources = [
      "*",
    ]
  }
}

resource "aws_iam_policy" "cluster-autoscaler" {
  name   = "cluster-autoscaler"
  path   = "/eks-sample/"
  policy = "${data.aws_iam_policy_document.cluster-autoscaler.json}"
}

resource "aws_iam_role_policy_attachment" "sample-node-cluster-autoscaler" {
  policy_arn = "${aws_iam_policy.cluster-autoscaler.arn}"
  role       = "${aws_iam_role.sample-node.name}"
}
```

オートスケールの対象としてノードに付けるタグ

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/terraform/worker_node.tf#L230-L240)

```:ノードへのタグ付け
  tag {
    key                 = "kubernetes.io/cluster/${var.cluster-name}"
    value               = "owned"
    propagate_at_launch = true
  }

  tag {
    key                 = "k8s.io/cluster-autoscaler/enabled"
    value               = "true"
    propagate_at_launch = true
  }

```

</div>
</details>

## kube-system上の重要なpodが落ちないようにする


落ちたら困るPod、例えばMetricsServerやClusterAutoScalerは[Priority](https://kubernetes.io/docs/concepts/configuration/pod-priority-preemption/)を上げることでアプリケーションのPodよりも優先的にスケージュールさせることができます。
挙動としては、高優先度のPodがスケージュール出来ない場合、低優先度のPodを落としてスケージュールされるようになります。

>If a Pod cannot be scheduled, the scheduler tries to preempt (evict) lower priority Pods to make scheduling of the pending Pod possible.

実際にオートスケールさせてみてこのような挙動は観測できましたが、長期的に頻繁にPodが停止されるような環境での確認はしていないので、より万全を期す場合、重要なPodを配置する専用のノードを用意するアプローチも良いかも知れません。

<details>
<summary>PriorityClassのマニフェスト</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/master/k8s/cluster/common/priority_class.yaml)

```yaml:PriorityClass
apiVersion: scheduling.k8s.io/v1beta1
kind: PriorityClass
metadata:
  name: high-priority
value: 100
globalDefault: false
description: "This priority class should be applied for the pods which are not application pods."
---
apiVersion: scheduling.k8s.io/v1beta1
kind: PriorityClass
metadata:
  name: application
value: 0
globalDefault: false
description: "This priority class is applied for application pods."
```

ClusterAutoScalerへのPriorityClassの適用

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/k8s/cluster/cluster-autoscaler/cluster-autoscaler-autodiscover.yaml#L154)

```yaml:PriorityClassの適用
      priorityClassName: high-priority
```

</div>
</details>

なおcoredns/aws-node/kube-proxyなどのビルドインで起動しているkube-systemのPodには最初から優先度2,000,000,000のPriorityClassであるsystem-cluster-criticalが割り当てられています。

<details>
<summary>coredns/aws-node/kube-proxyのPriorityClass</summary>
<div>

```sh:coredns/aws-node/kube-proxyのPriorityClass
$ kubeclt get po -n=kube-system
NAME                                  READY   STATUS    RESTARTS   AGE
aws-node-ffw8g                        1/1     Running   0          93m
aws-node-xrcgn                        1/1     Running   0          98m
cluster-autoscaler-6d4c7bffc4-bxwv2   1/1     Running   0          19m
coredns-57df9447f5-gzmsv              1/1     Running   0          128m
coredns-57df9447f5-h58lg              1/1     Running   0          128m
kube-proxy-5n2c7                      1/1     Running   0          93m
kube-proxy-7px4k                      1/1     Running   0          98m
metrics-server-7896b47b65-4lgnx       1/1     Running   0          17m

$ kubectl get priorityclasses.scheduling.k8s.io system-cluster-critical -o=jsonpath={.value}
2000000000%

$ kubectl get deployments.apps coredns -n kube-system -o json | jq '.spec.template.spec.priorityClassName'
"system-cluster-critical"

$ kubectl get daemonset aws-node -n kube-system -o json | jq '.spec.template.spec.priorityClassName'
"system-node-critical"

kubectl get daemonset kube-proxy -n kube-system -o json | jq '.spec.template.spec.priorityClassName'
"system-node-critical"
```

</div>
</details>


# AWSリソース関連

EKS関連のリソースをTerraformで定義していきます。元ネタは[こちら](https://learn.hashicorp.com/terraform/aws/eks-intro)です。内容は以下のようになります。

- ALB(IngressControllerではなくTerraform管理の)
- ワーカーノード

## ALB(IngressControllerではなくTerraform管理の)

外部からのリクエストがPodに到達する経路は以下のようになります。

```
ALB => TargetGroup => ノードのEC2 => NodePort => Pod
```

この構成を[EKSのIngressController](https://aws.amazon.com/blogs/opensource/kubernetes-ingress-aws-alb-ingress-controller/)を使うと簡単に構築できるのですが、今回はK8s以外のリソースは基本的にTerraformを使用して作成します。

理由は以下のような感じです。

- AWSのリソースはTerraformで一元管理したい
- Terraformを使った方がブラックボックス感が薄い
- Terraformで作るのは別にそんなに手間ではない

大事なのはIaCが出来ていることだと思うのでIngressControllerのマニフェストがちゃんとgit管理されているのであればIngressControllerでも良いんじゃないかなと思います。


ALB、TargetGroup、SecurityGroup(ALB => ノードを許可する)は以下のようになります。


<details>
<summary>ALBとListerner</summary>
<div>


[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/terraform/alb.tf#L1-L31)

```:ALBとListerner
/* ALB関連
*/
resource "aws_lb" "sample" {
  name               = "eks-sample"
  internal           = false
  load_balancer_type = "application"
  security_groups    = ["${aws_security_group.sample-alb.id}"]
  subnets            = aws_subnet.sample[*].id

  # access logの有効化を推奨
  # access_logs {
  #   bucket  = "${aws_s3_bucket.sample_alb_log.bucket}"
  #   prefix  = "sample_alb_lb"
  #   enabled = true
  # }

  tags = {
    Environment = "staging"
  }
}

resource "aws_lb_listener" "sample" {
  load_balancer_arn = "${aws_lb.sample.arn}"
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = "${aws_alb_target_group.sample.arn}"
  }
}
```

</div>
</details>

<details>
<summary>SecurityGroup</summary>
<div>

`0.0.0.0/0` => ALB => ノードを許可するようなSGを定義します。

`0.0.0.0/0` => ALB

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/terraform/alb.tf#L35-L46)

```:ALBへの通信を許可するSG
resource "aws_security_group" "sample-alb" {
  name        = "terraform-eks-sample-alb"
  description = "Security group for alb"
  vpc_id      = "${aws_vpc.sample.id}"

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
```

ALB => ノード

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/terraform/worker_node.tf#L138-L146)

```:ALBからノードへの通信を許可するSG
resource "aws_security_group_rule" "access-from-alb" {
  description              = "Allow access from ALB"
  from_port                = 0
  protocol                 = "-1"
  security_group_id        = "${aws_security_group.sample-node.id}"
  source_security_group_id = "${aws_security_group.sample-alb.id}"
  to_port                  = 65535
  type                     = "ingress"
}
```

</div>
</details>

<details>
<summary>TargetGroupとNodePortとのつなぎ込み</summary>
<div>

30001番ポートでTGとNodePortとを繋ぎます。
一つのクラスタに複数のエンドポイントを生やす(例えば環境ごとに)場合、このポート番号は被らないようにする必要があります。

TargetGroupとAutoscalingGroupへのアタッチ

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/terraform/alb.tf#L60-L74)

```:TGとASGへのアタッチ
resource "aws_alb_target_group" "sample" {
  name     = "sample-target-group"
  port     = 30001
  protocol = "HTTP"
  vpc_id   = "${aws_vpc.sample.id}"

  health_check {
    path = "/health"
  }
}

resource "aws_autoscaling_attachment" "sample" {
  autoscaling_group_name = "${aws_autoscaling_group.sample.id}"
  alb_target_group_arn   = "${aws_alb_target_group.sample.arn}"
}
```

NodePort、staging環境のkustomizeの中で宣言しています(環境ごとにNodePortを持つ想定)。

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/master/k8s/staging/node_port.yaml)

```yaml:NodePort
apiVersion: v1
kind: Service
metadata:
  name: sample
spec:
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080
    nodePort: 30001 # ALBのTGで指定する、各クラスタごとに固有であること
```

</div>
</details>

## ワーカーノード

AutoscalingGroupで管理されたワーカーノードを用意します。


<details>
<summary>Launch Configration</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/terraform/worker_node.tf#L162-L206)

```:LaunchConfigration
resource "aws_launch_configuration" "sample" {
  associate_public_ip_address = true
  iam_instance_profile        = "${aws_iam_instance_profile.sample-node.name}"

  /* image_idの選び方
  Kubernetesのversion:
  \```
  $ kubectl version -o json | jq '.serverVersion.minor'
  "12+"
  \```
  Tokyoリージョンの1.12.*でGPUのサポートなしを[https://docs.aws.amazon.com/eks/latest/userguide/eks-optimized-ami.html]から選ぶ
  */
  image_id                    = "ami-0a9b3f8b4b65b402b"

  /*
  インスタンスタイプはネットワークの性能保証があるm4系がオススメ
  ネットワークが遅いとimageのpullに異常に時間がかかることがあるので
  参考: https://aws.amazon.com/ec2/instance-types/
  */
  instance_type               = "t2.small" # microだとmetrics-serverが立ち上がらない

  /*
  worker nodesにsshできるようにする
  worker nodes内で問題が起きる場合もあるので設定することを推奨
  worker nodeのIPを特定(コンソールからも見られる):
  $ kubectl get node -o json | jq '.items | map(.status.addresses) | flatten | map(select(.type=="ExternalIP"))'
  ssh:
  $ ssh ec2-user@IP_ADDRESS -i ~/.ssh/eks-sample.pem
  kubeletのログを見る:
  $ [ec2-user@ip-10-101-0-59 ~]$ journalctl -u kubelet
  */
  key_name                    = "eks-sample"

  name_prefix                 = "terraform-eks-sample"
  security_groups             = ["${aws_security_group.sample-node.id}"]
  user_data_base64            = "${base64encode(local.sample-node-userdata)}"

  lifecycle {
    create_before_destroy = true
  }
}
```

</div>
</details>

<details>
<summary>Autoscaling Group</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/terraform/worker_node.tf#L208-L241)

```:LaunchConfigration
resource "aws_autoscaling_group" "sample" {
  launch_configuration = "${aws_launch_configuration.sample.id}"

  # 0にしておくと、desired_capaciry=0でインスタンスを全部落とせる
  min_size             = 0
  max_size             = 5

  lifecycle {
    ignore_changes = [
      desired_capacity, // Terraformではなく、CusterAutoscalerが管理する
    ]
  }

  name                 = "terraform-eks-sample"
  vpc_zone_identifier  = aws_subnet.sample[*].id

  tag {
    key                 = "Name"
    value               = "terraform-eks-sample"
    propagate_at_launch = true
  }

  tag {
    key                 = "kubernetes.io/cluster/${var.cluster-name}"
    value               = "owned"
    propagate_at_launch = true
  }

  tag {
    key                 = "k8s.io/cluster-autoscaler/enabled"
    value               = "true"
    propagate_at_launch = true
  }
}
```

</div>
</details>

ポイントとしては

- ワーカーノードにはsshできるようにする(トラブル発生時に`journalctl -u kubelet`とか打ちたい)
- インスタンスタイプはネットワークの性能保証があるm4系を使う(t2系だとimage pullが異様に遅くなることがあった)
- AutoscalingGroupのmin_sizeは0にしておくと、インスタンスを全て落とせるので開発には便利
- AutoscalingGroupのdesired_capacityはignore_changesしておく(この値はClusterAutoscalerが管理するので)

などがあります。

# アプリケーションのCI/CD

アプリケーションコード(Golang)の開発/デプロイに関連する部分を紹介します。内容は以下のようになります。

- コンテナイメージのビルド/ECRへのプッシュ
- ローカル開発環境
- test/lintをCIで回す
- マルチステージビルド
- Graceful Shutdown


## コンテナイメージのビルド/ECRへのプッシュ

コンテナイメージののビルドは一番お手軽なCircleCIの`setup_remote_docker`を使っています。
ビルド時間が長くなってきた場合は[CodeBuild](https://aws.amazon.com/codebuild/)や[Cloud Build](https://cloud.google.com/cloud-build/)を使うと良いと思います。

- [setup_remote_dockerの使い方ドキュメント](https://circleci.com/docs/2.0/building-docker-images/)
- [GitHubの該当コード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L108)



ECRへのイメージのpushは`eval $(aws ecr get-login)`して`docker push`で行えます。

<details>
<summary>.circleci/config.ymlのコード</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L118)

```sh:ecrにログイン
eval $(aws ecr get-login)
```

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L122-L124)

```yaml:imageのビルドとプッシュ
      - run:
          name: build and push image
          command: |
            IMAGE_NAME=705180747189.dkr.ecr.ap-northeast-1.amazonaws.com/terraform-eks-sample/app:$(git rev-parse HEAD)
            docker build -t $IMAGE_NAME app
            docker push $IMAGE_NAME
```

</div>
</details>




## ローカル開発環境(docker-composeを使用)

Dockerの中に実行環境を封じ込めることで、簡単に開発環境の立ち上げができるようにしています。
例えばテストを実行する場合以下を打つだけで済むようにしています。

```sh
$ docker-compose up -d && docker-compose exec app go test -v ./...
```

さらにテスト実行などよく使うコマンドを`Makefile`に纏めているので実際には`make app-test`でテストが回せます。

<details>
<summary>Makefile</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/master/Makefile#L59-L73)

```makefile:Makefile
# 開発環境立ち上げ
start:
	docker-compose up -d

# テストの実行
app-test: start
	docker-compose exec app go test -v ./...

# ファイルが変更された時にテストをまわす
# entrが入ってない場合は`brew install entr`
app-watch: start
	find app -name '*.go' | entr make app-test

# serverを起動する、Port 8080でlistenしている
app-server-start: start
	docker-compose exec app go run ./...
```

</div>
</details>


<details>
<summary>docker-compose.yml</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/docker-compose.yml#L11-L20)

```yaml:docker-compose.yml
  app:
    image: golang:1.12.6
    working_dir: /go/src/github.com/sxarp/eks-terraform-ci-sample/app
    command: tail -f /dev/null
    environment:
      - GO111MODULE=on
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ./app:/go/src/github.com/sxarp/eks-terraform-ci-sample/app
```

</div>
</details>

## test/lintをCIで回す

lintは多数のlintをアグリゲートした[golangci-lint](https://github.com/golangci/golangci-lint)を使っています。
testを回す際は`/go/pkg/mod`を`save_cache`することで依存のキャッシュをしています。


<details>
<summary>CIでのlintの設定</summary>
<div>

本来ならCI用のイメージにlintを含めるのがスタンダードですが、今回は以下のようにCircleCIのキャッシュの中に放り込んでいます。

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L83-L98)

```yaml:CircleCIでのlintの設定
      - restore_cache:
          keys:
            - go-lint-1.17.1
      - run:
          name: lint
          command: |
            # golangci-lintをインストール
            # 参考: https://github.com/golangci/golangci-lint#ci-installation
            [[ ! -f /go/lint/golangci-lint ]] && curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b /go/lint v1.17.1 || true
            cd app # go.modのあるディレクトリに移動しないと変なキャッシュの効き方をしてしまう
            /go/lint/golangci-lint run ./...
      - save_cache:
          key: go-lint-1.17.1
          paths:
            - "/go/lint"
```

</div>
</details>

<details>
<summary>CIでのGoの依存キャシュの設定</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/.circleci/config.yml#L78-L81)

```yaml:Goの依存キャッシュ
      - save_cache:
          key: go-mod-{{ checksum "app/go.sum" }}
          paths:
            - "/go/pkg/mod"
```

</div>
</details>





## マルチステージビルド

マルチステージビルドを行いベースイメージを`FROM scratch`とすることでイメージサイズを4Mぐらいにしています。
なお、今回はビルドのキャッシュによる高速化は行っていませんが、マルチステージビルドをキャッシュを使って高速化する場合、ビルダーは完成イメージに含まれないの工夫する必要があります(別途ビルダー自体をキャッシュするなど。)

<details>
<summary>マルチステージビルドしているDockerfile</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/master/app/Dockerfile)

```Dockerfile:Dockerfile
FROM golang:1.12.6 AS builder
ENV GO111MODULE=on
WORKDIR /app
COPY . .
# CGO_ENABLED=0を指定しないと、以下のようなエラーが出る:
# `standard_init_linux.go:207: exec user process caused "no such file or directory"`
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s"
FROM scratch
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"]
```

</div>
</details>



## Graceful Shutdown

K8sではpodは頻繁に落ちる想定(例えばスケールイン時)なのでGraceful Shutdownを行うようにしたほうが良いでしょう。

<details>
<summary>Graceful Shutdownの実装コード</summary>
<div>

[GitHubのコード](https://github.com/sxarp/eks-terraform-ci-sample/blob/38fe1059355ef179c0c41d513761000d93ab4c59/app/main.go#L46-L59)

```go
func gracefulShutdown(srv *http.Server) {
	c := make(chan os.Signal, 1)
	signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
	<-c
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Fatal(err)
	}

	log.Println("shutting down")
	os.Exit(0)
}
```

参考: https://github.com/gorilla/mux#graceful-shutdown

</div>
</details>