ECS上のコンテナアプリをCircleCIでCI/CDする

# この記事について
イケてる開発をするためにはCI/CDは欠かせません。
特にAWSを使って本番環境を作っている場合、デプロイ操作をいちいちWebコンソールでぽちぽちしながら行うのは結構面倒です。

そのため今回は、ECSを使ったコンテナアプリを、CircleCIを使ってCI/CDパイプラインにのせるための手順、主にCircleCIの設定周りについて詳しく解説します。

## 使用する環境・バージョン
macOS Mojave 10.14.5

## 前提条件
GitHub, CircleCI, AWSのアカウントは取得済みの状態から始めます。

## 読者に要求する前提知識
- ECSへの手動デプロイが問題なくできること、およびECS用語(タスク・サービス等)の理解
- gitの基本的な操作(add, commit, push, リモートリポジトリとの連携)
- CircleCIでのプロジェクト作成(GitHubリポジトリとの連携)ができる
- IAMユーザーの作成ができる

# 構築するCI/CDパイプライン
![スクリーンショット 2020-08-19 23.56.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520877/651abb48-3ac5-adf2-767e-2b0477b46e13.png)
今回構築するパイプライン全容は以上の図の通りです。言葉でも説明すると、

1. 開発者がアプリのコードをGitHubにpushする
2. CircleCIがGitHubにpushされたことを検知、以下3,4の手順でのデプロイを自動で行う
3. GitHubにアップされたコードからコンテナイメージをビルド、それをECRの指定リポジトリにpush
4. 3でpushされたコンテナイメージを使ったタスク・サービスに自動更新する

これが実現できるように、CircleCIの設定をすることが目標となります。

# サンプルアプリの作成
## 最終的なファイル・ディレクトリ構造
ローカルでの最終的なファイル構造は以下のようになります。

```
.
├─.circleci
│  └─config.yml
├─main.go
├─main_test.go
└─Dockerfile
```

## アプリのコードを作成
今回は、Goを用いて基本的なウェブサーバーを作りました。
`main.go`に以下のように記述します。

```main.go
func main() {
	port := "8080"
	fmt.Printf("Server Listening on port %s\n", port)

	http.HandleFunc("/", handler)

	err := http.ListenAndServe(":"+port, nil)
	if err != nil {
		log.Fatal("fatal err: ", err)
	}
}

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello, World!")
}
```

## アプリのコンテナ化
コードを書いた後は、これをコンテナ化するために`Dockerfile`を作成します。

```Dockerfile
FROM golang:1.13

WORKDIR /go/src/app
COPY . .

CMD ["go", "run", "main.go"]
```
`Dockerfile`作成後は、`docker build`コマンドを用いてビルドします。
ここではコンテナイメージに一回`testserver`という名前をつけました。

```bash
$ docker build -t testserver .
```

コンテナ化したアプリの動作を確認するためには、以下のコマンドを叩いて`http://localhost:8080`にアクセスすればOKです。

```bash
$ docker run -p 8080:8080 testserver
```

# ECSへの初回デプロイをする
CircleCIでできるのは、「既にデプロイされているアプリを更新・アップデートすること」なので、初回デプロイは手動で済ませておく必要があります。

ECS手動デプロイ時の詳しい手順・設定については記事の本筋から外れてしまうため詳しくは説明せず、CircleCIの設定に絡む部分だけを記述します。
ECSの用語・概念・詳しいデプロイ手段について知りたい方は、別記事にまとめていますのでこちらをご覧ください(下記記事のvoting-appのデプロイがほぼそのままこれに適用できます)。
→[Dockerコンテナで作ったアプリをECS+RDSでデプロイする](https://qiita.com/saki-engineering/items/8e1c4e8ecbe3f3492b40)

1. ECRリポジトリの作成→コンテナイメージのpush<br>
今回は「cicdtest」という名前のリポジトリを作成し、先ほど作ったサンプルアプリのコンテナイメージをpushしました。
2. タスク定義作成<br>
先ほどpushしたコンテナイメージを使ってタスク定義を作成します。タスク名を「testserver」、コンテナ名を「testserverContainer」としました。
3. クラスター作成<br>
今回は「circleciTest」という名前のクラスターにしました。
4. サービス作成<br>
「CircleCItestService」という名前で、先ほどのタスクを起動するサービスを作りました。

# CircleCIの設定
初回デプロイが終わったら、これを継続的にアップデートできるようにCircleCIを用いてCI/CDパイプラインを構築していきます。

参考:[CircleCI Orbsで ECR/ECS にデプロイ](https://qiita.com/shshimamo/items/76db04d5a367aa84ca92)
参考:[CircleCI + GitHub + Amazon Elastic Container Registry (Amazon ECR) + Amazon Elastic Container Service (Amazon ECS) (+ AWS Fargate) で継続的デリバリー環境を構成する](https://www.seeds-std.co.jp/blog/creators/2019-09-11-201930/)

## IAMユーザー作成
CircleCIからAWSのリソースを扱うためには、CircleCI用のIAMユーザーを作成・権限付与をし、アクセスキーを払い出す必要があります。
ロールは以下の2つを付与します。

- AmazonEC2ContainerRegistryFullAccess
- AmazonEC2ContainerServiceFullAccess

作成したIAMユーザーのアクセスキーとシークレットアクセスキーは後ほど使うので、忘れないようにどこかに控えておきます。

## configファイル作成
CircleCIの設定ファイルは、`.circleci/config.yml`として用意します。
このファイルを作成して、以下のように記述します。

```config.yml
# circleciのバージョン。後述するorbsを利用するためには2.1以上が必要
version: 2.1

orbs:
    # circleci/aws-ecr@6.1.0というorbをaws-ecrというaliasをつけて扱う
    aws-ecr: circleci/aws-ecr@6.1.0
    aws-ecs: circleci/aws-ecs@0.0.8

workflows:
    # build_and_push_imageという名前のworkflowを定義
    build_and_push_image:
        # workflowで実行するjobの順番を定義
        jobs:
        - aws-ecr/build-and-push-image:
            region: AWS_REGION
            account-url: AWS_ECR_ACCOUNT_URL
            repo: 'cicdtest'
            tag: "${CIRCLE_SHA1}"
        - aws-ecs/deploy-service-update:
            requires:
                - aws-ecr/build-and-push-image
            family: 'testserver'
            cluster-name: 'circleciTest'
            service-name: 'CircleCItestService'
            container-image-name-updates: 'container=testserverContainer,tag=${CIRCLE_SHA1}'
```
設定内容について詳しく説明します。

### CircleCI Orbsとは
本来ならば、CircleCIでのCI/CDを実現するためには、Githubへのpush確認後にどんな操作をするべきかというのを「コマンドベース」で指定していく必要があります。

例えば、今回のような「コードを元にコンテナイメージをビルド→ECRにpush」だったら、

1. `docker build`コマンドでコンテナイメージを作る
2. `docker login`でECRにログインする
3. `docker tag`で1で作ったコンテナイメージに、AWS指定の名前・タグ付けを行う
4. 3で作ったイメージをECRに`docker push`する

というように、いちいち何のコマンドを実行するのかをjobという形で定義しなくてはいけませんでした。

しかし、技術者・開発者がCircleCIでやりたい操作というのは皆似たり寄ったりです。そのため、みんながやりそうなjobについては「パッケージ」としてまとめて簡単に利用できるようにしよう、というのがOrbsです。

今回の場合は、「circleci/aws-ecr」と「circleci/aws-ecs」2つのOrbsを導入しています。

```config.yml
orbs:
    aws-ecr: circleci/aws-ecr@6.1.0
    aws-ecs: circleci/aws-ecs@0.0.8
```
ただし、2つとも名前が長いので、それぞれ「aws-ecr」「aws-ecs」というエイリアスをつけて、以下のコードで簡単に名前を書けるようにしています。

参考:[また車輪の再発明して消耗してるの？CircleCI Orbs使お？](https://blog.vtryo.me/entry/use-circleci-orbs)

### workflowの作成
Orbsを用いて、CircleCIに自動でやって欲しい一連の処理(=workflow)について記述していきましょう。
今回は「build_and_push_image」という名前のworkflowを作成し、このworkflowでは2つのjobを順番に行ってもらうようにしました。

#### job1: aws-ecr/build-and-push-image
先ほど導入したOrbsのうち、aws-ecrの方で提供されている「build-and-push-image」というjobをまず行います。
これはその名の通り、pushされたコードからコンテナイメージをビルド→ECRにpushまでの一連の操作を行うjobです。
どのAWSアカウントのどのECRリポジトリにpushするかの設定を記述します。

```config.yml
- aws-ecr/build-and-push-image:
    region: AWS_REGION
    account-url: AWS_ECR_ACCOUNT_URL
    repo: 'cicdtest'
    tag: "${CIRCLE_SHA1}"
```

- region: AWSリージョンの指定。この後与える環境変数`AWS_REGION`で値を渡す。
- account-url: 使いたいECRアカウントの指定。環境変数`AWS_ECR_ACCOUNT_URL`で値を渡す。
- repo: push先のECRリポジトリ名を指定。今回は先ほど作成したECRリポジトリ名cicdtestを指定。
- tag: CircleCIがビルドしたコンテナイメージにどんなタグをつけるかを指定。今回は、commitごとにつくSHA1ハッシュの値をそのままタグにすることで、タグ重複を防止する。

#### job2: aws-ecs/deploy-service-update
導入したOrbsのうち、aws-ecsの方で提供されている「deploy-service-update」というjobを利用します。これで「ECSのタスク・サービスの更新」を自動でやってくれます。

設定内容は以下の通りです。

```config.yml
- aws-ecs/deploy-service-update:
    requires:
        - aws-ecr/build-and-push-image
    family: 'testserver'
    cluster-name: 'circleciTest'
    service-name: 'CircleCItestService'
    container-image-name-updates: 'container=testserverContainer,tag=${CIRCLE_SHA1}'
```

- require: aws-ecr/build-and-push-imageのjobをPASSしないとこのjobを実行しないようにする
- family: 更新したいタスク名
- cluster-name: 更新したいサービスがあるクラスター名
- service-name: 更新したいサービス名
- container-image-name-updates: familyで指定したタスクの中で、どのコンテナを更新するかを指定。ここでは、「testserverContainer」という名前で、「${CIRCLE_SHA1}」のタグがついているものという指定。

## GitHubにリモートリポジトリを作成→CircleCIと連携
GitHubにリモートリポジトリを作り(名前は任意)、そこにコードをpushします。
その後、そのリポジトリと連携させたCircleCI projectを作成します。

## CircleCIに環境変数を設定する
project作成後、CircleCIに以下の環境変数を設定します。

- AWS_ACCESS_KEY_ID : さっき作成したIAMユーザーのアクセスキー
- AWS_SECRET_ACCESS_KEY: さっき作成したIAMユーザーのシークレットアクセスキー
- AWS_ACCOUNT_ID: 自分のAWSアカウントID(12桁の数字)
- AWS_ECR_ACCOUNT_URL: [myaccountid].dkr.ecr.ap-northeast-1.amazonaws.com
- AWS_REGION: ap-northeast-1

この環境変数の設定後、もう一度pipelineを実行すると、正しくCI/CDが行われます。

# テスト実行をパイプラインに組み込む
せっかくのCI/CDなので、GitHubにpushしたら「コードテストを実行→テストにPASSしたときだけデプロイに移行」というフローに改良してみたいと思います。

## テストコードを作成
`main_test.go`というファイルを作成し、以下のように記述します。

```main_test.go
func TestMyHandler(t *testing.T) {
	reqBody := bytes.NewBufferString("request body")
	req := httptest.NewRequest(http.MethodGet, "http://localhost:8080", reqBody)

	got := httptest.NewRecorder()
	handler(got, req)

	if got.Code != http.StatusOK {
		t.Errorf("want OK, but %d", got.Code)
	}
	wantBody := "Hello"
	if got := got.Body.String(); !strings.Contains(got, wantBody) {
		t.Errorf("get %s : response body does not contain %s", got, wantBody)
	}
}
```
これで、「アクセス時にhttpレスポンスコード200が返ってくるか」「レスポンスボディに"Hello"という文字が含まれているか」がテストできるようになりました。
ディレクトリ直下で`go test`コマンドを叩くことでテスト実行ができます。

## configファイルの作成
テストを行うjobを新たに定義して、CircleCIのworkflowに組み込みます。
前述した`config.yml`に追記してテストを組み込んだものがこちらです。

```config.yml
version: 2.1

orbs:
    aws-ecr: circleci/aws-ecr@6.1.0
    aws-ecs: circleci/aws-ecs@0.0.8

# 追加、testという名前のjobを定義
jobs:
    test:
        docker:
            - image: circleci/golang:1.13
        working_directory: /go/src/github.com/myname/mydirectory
        steps:
            - checkout
            - run: go get -v -t -d ./...
            - run: go test -v ./...


workflows:
    build_and_push_image:
        jobs:
        - test  # 追加、デプロイ操作前にテストを実行
        - aws-ecr/build-and-push-image:
            requires:
                - test # 追加、テストに成功したときだけデプロイ操作に移るようにする
            region: AWS_REGION
            account-url: AWS_ECR_ACCOUNT_URL
            repo: 'cicdtest'
            tag: "${CIRCLE_SHA1}"
        - aws-ecs/deploy-service-update:
            requires:
                - aws-ecr/build-and-push-image
            family: 'testserver' # タスク名
            cluster-name: 'circleciTest'
            service-name: 'CircleCItestService'
            container-image-name-updates: 'container=testserverContainer,tag=${CIRCLE_SHA1}'
```
参考:[CI/CDパイプライン – GitHub, CircleCIの連携とGCP(GCR）へのプッシュ](https://hodalog.com/ci-cd-pipeline-of-github-circleci-and-gcr/)

## masterブランチへのpushのときだけデプロイされるように制限する
このままだと、どのリモートブランチにpushされたとしてもECSへのデプロイが行われてしまいます。つまり、「まだリリースできる段階じゃないけど、とりあえずブランチを切ってリモートにもpushしておかないと」というときにもデプロイが動いてしまいます。
そのため、「テストはどのブランチへのpushでも行う」「デプロイ操作はmasterへのmerge/pushのときだけ行う」という風に設定しておくことができれば素敵なパイプラインになります。

そのために、`config.yml`に以下のように追記します。

```config.yml
(略)
workflows:
    build_and_push_image:
        jobs:
        - test
        - aws-ecr/build-and-push-image:
            (略)
            filters:    # ここを追加
                branches:
                    only:
                    - master
        - aws-ecs/deploy-service-update:
            (略)
            filters:    # ここを追加
                branches:
                    only:
                    - master
```
これで、「aws-ecr/build-and-push-image」「aws-ecs/deploy-service-update」のjobはmasterへのpushのときのみ動くようになりました。