canarycage - AWS ECSへの堅牢なデプロイツール

この記事はAWSアドベントカレンダー21日目の記事です。担当は[keroxp](https://qiita.com/keroxp)でお送りします。
弊社（[ロイロ](https://n.loilo.tv/ja/)）で開発中のECSへの堅牢なデプロイツールの紹介をしようと思います。

## 概要

弊社はもともと、AWS EC2でメインのWebサービスを運用していたのですが、今年の中頃から本格的にDockerへの移行を始めました。EC2からDockerへの移行も相当に大変だったんですが、それはなんとかうまく行ってさぁAWSにデプロイだ！というところで**大変なつまづき**を経験し、その解決策を模索し始めました。

この記事はAWSでECSに「堅牢に」Dockerコンテナをデプロイしたい開発者向けの記事です。
Kubernetesを使ってる人たちには需要無だと思います。

## ECSへのデプロイの問題点

AWSを使っている場合、コンテナベースのアプリケーションをデプロイする先としてECSを選択するのは自然な流れだと思います。しかし、ECSへのコンテナのデプロイは控えめに言って安全ではありません。

ECSでWebアプリケーションを運用する場合、Serviceを作成することになりますが。Serviceに対するデプロイは、Serviceを構成するタスクを新しいタスク定義（≒Dockerイメージ）で作成されたタスクで置き換えることで実行されます。

これはいわゆるローリングアップデートというタイプのデプロイで、新旧タスクを順番に置き換えてくれるのでダウンタイムも出ない（はず）なので一見便利に思えます。

しかしECSのこのナイーブなデプロイ方法は、様々な問題を抱えています。中でも壊れたタスクを配置してしまった場合、サービスが落ちる可能性があるという点が一番問題です。

この部分の説明が難しいというか、何を持って「壊れている」かがECSの複雑なシステムによってとにかくわかりにくく、サービスが「壊れている」状態もECSは明示的に示してくれないんですよね。なので、ECS導入初期は「知らないうちに落ちてた」ことが多発しました。**もちろん、開発やステージング環境の話ですヨ。**そこで躓いたのでcageの開発を決意したというわけです。

壊れたものをデプロイしたのだからサービスが落ちるのは当然なのですが、そもそもローカルで動いていてCIも通っているのだからデプロイできないわけがないと考えるのが普通です。

デプロイという作業が、デプロイ職人が二人体制で目を皿にしながら行う月に一度の一大行事というような世界観ならいざしらず、せっかくアプリケーションがコンテナという堅牢な箱を手に入れたのだから、デプロイのサイクルもはやめていきたい。でもデプロイはできるだけ安全に行いたい。

そういった動機からECSへのデプロイツールを自作することを決めました。

つい最近[CodeDeploy経由でのECSへのB/Gデプロイメント](https://aws.amazon.com/jp/blogs/news/use-aws-codedeploy-to-implement-blue-green-deployments-for-aws-fargate-and-amazon-ecs/)が発表され、実際に使ってみましたが、実用に足るがまだ癖があると感じました。今からでしたらそちらを検討してみてもいいかもしれません。（ちなみにCodeDeployがやっているTargetGroupを２つ用意してB/Gデプロイするという手法はcageの開発初期に実装していたのですが、動いているECSサービスのTargetGroupを変えることはできず、複数のTargetGroupも割り当てられないので断念したという経緯があります。どうやら謎の特権的な振る舞いをしているようです）

とはいえcageを開発し始めたのは今年（2018年）の8月ころで、その当時はこの機能はなく、CodePipelineからのデプロイもサービス更新するだけだったので、僕たちの用途には合いませんでした。弊社は別のCIサービスを使っているので、その利便性の点でもそちらのサービスから直接堅牢なデプロイを実行できる必要があったのです。

ECSへのデプロイはfragileすぎます。デプロイに相当するアクションが、`service-update`でタスク定義を更新することしかないということが一番の問題点で、それはデプロイに相当しているだけで、サービス開発者が思い描くデプロイではないのです。

本来「安全な」デプロイとはアプリケーションによって異なるはずで、ローリングアップデートできるから「安全」なわけはないと思います。ECSはそういった開発側の柔軟なデプロイ方針を適用できないため、「望ましい状態」を定義ファイルで宣言して結局そういう状態にならずにただ止まるということが多々起きます。ECSにはロールバックの機能もないですし。

## canarycageの紹介

https://github.com/loilo-inc/canarycage

canarycage（a.k.a cage）は、そういった問題を解決し、ECSへの**継続的なデプロイを恐れない**ためのデプロイツールです。cageを開発する前、僕たちはいくつかのECSへのデプロイツールを調べ、そのどれにも満足できませんでした。AWS公式の`ecs-cli`も、僕たちが求める堅牢さは持っていませんでした。結局自分たちで`aws-cil`を使ったシェルスクリプトを書いていたのですが、だんだん長大になっていき、シェルスクリプトの限界を突破してしまったのでGoで書き直しました。

僕がcageを開発する上で打ち上げた方針はただ一つ、

> デプロイ中に「何か」が壊れていても、現存の動いているサービスに影響を与えない

ことでした。
何ヶ月かのECS運用で僕たちはECSのサービスが壊れるパターンを見つけました。
それは、

- サービス定義に誤りがある
- タスク定義に誤りがある
- タスクが配置されるVPCの設定に誤りがある
- クラスターにタスクを配置できない
- タスクのコンテナの起動が成功しない
- タスクは起動しているが、何らかの原因でALBのヘルスチェックが規定の時間以内に通らない

のようなものでした。いずれもアプリケーションのコードの振る舞いは何も壊れておらず、ECS環境でしか検証できない設定の誤りによるものがほとんどでした。誤ったサービスやタスクの定義をメインサービスに適用してしまったせいでサービスが壊れるのは、理不尽に思える時間でした。

cageは、こういった設定ミスをできる限りメインサービスに適用させないために設計されています。

## cageの使い方

go製のCLIなので、goで入れることができます。2018/12/21現在の最新版は、2.1.0です。（※1.x.x系は振る舞いが全く違うので使わないでください）

```sh:
$ go get -u github.com/loilo-inc/canarycage/cli/cage 
```

バイナリはGithubからダウンロードできます。

```sh
$ curl -oL https://github.com/loilo-inc/canarycage/releases/download/${VERSION}/canarycage_${OS}_${ARCH}.zip
$ unzip canarycage_linux_amd64.zip
$ chmod +x cage
$ mv cage /usr/local/bin/cage
```

cageには今の所２つのコマンドしかありません。それが`up`と`rollout`です。
コマンドの使い方を紹介する前に、cageに必要なファイルを説明します。

### `service.json`と`task-definition.json`

cageはデプロイメントの記述に二種類のjsonファイルを使用します。それが`service.json`と`task-definition.json`の２つのファイルで、それぞれECSサービスの作成、タスク定義の登録に使用します。これらを「デプロイファイル」と呼んでいます。

`service.json`はメインサービスの作成、後述するカナリアサービスの作成に使うサービス定義ファイルです。この内容は、[サービス定義パラメータ](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/service_definition_parameters.html)に記述のあるJSONフォーマットになります。

`aws ecs create-service --generate-cli-skeleton`で生成されるフォーマットと同じものである必要があります。

`task-definition.json`は同様に[タスク定義パラメータ](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_definitions.html)のJSONフォーマットになります。`aws ecs register-task-definition --generate-cli-skeleton`のアウトプットですね。

### ディレクトリ構成

上記２つのファイルは、一つのサービスにつき同じディレクトリに置いてある必要があります。最もナイーブなディレクトリ構成は、次のようなものになるかと思われます。

![https://camo.githubusercontent.com/793c15609bedd9adaaf026c3a5711d46862e41c1/68747470733a2f2f6779617a6f2e636f6d2f39326361666631643833306161333839396463353766333137356336643336652e706e67](https://camo.githubusercontent.com/793c15609bedd9adaaf026c3a5711d46862e41c1/68747470733a2f2f6779617a6f2e636f6d2f39326361666631643833306161333839396463353766333137356336643336652e706e67)

### `cage up`

upはその名の通り「サービスを作成する」コマンドです。`aws ecs create-service`に相当し、ほぼ同じ挙動をします。

上記のようなディレクトリ構成にした場合、最もシンプルなupコマンドの使い方は以下のようになります。

```sh:
$ cage up .deploy/
```

upコマンドは第一引数にデプロイファイルのディレクトリパスをとり、そのタスク定義を登録し、それを用いてサービスを作成します。すでにサービスが存在する場合は必要ありませんが、後述の`rollout`と同じデプロイファイルを使うということで、ない場合にシュッと作れるように用意されました。

### `cage rollout`

rolloutは、cageのメイン機能で既存のサービスを「できるだけ安全に」ロールアウトさせるコマンドです。使い方は`up`同様で、

```sh:
$ cage rollout .deploy/
```

のようにデプロイファイルがあるディレクトリパスを引数に渡すだけです。
rolloutは以下のステップでサービス更新の前にサービス定義、タスク定義、Dockerイメージの健全性を検証します。

1. `task-definition.json`で新タスク定義を登録する
 - cageでは、デプロイのたびに新しいタスク定義を登録し直すことを推奨しています
  - 大抵の場合はタスク定義のDockerイメージのタグの記述だけを変更することになるでしょうが、イメージタグが異なるタスク定義は異なるものとして管理するべきです
- `service.json`と新タスク定義でカナリアサービスを作成する
 - サービス定義、タスク定義、Dockerイメージが壊れていないか検証するために、メインサービスと同じクラスターに「カナリアサービス」を作成します
 - カナリアサービスは、基本的にロールアウト対象となる本サービスと同じリソースを使用し、同じTargetGroupに登録されます
  - この時点で、`web-server`というサービスがある場合は、`web-server-canary`というサービスを新規作成します
- カナリアサービスが安定するまで待つ
  - この段階で、サービス定義、タスク定義、Dockerイメージの不備がふるい落とされます
  - カナリアサービスが安定して起動しない場合は、ロールアウト失敗になりますが、メインサービスのリソースを何も変更しません
- カナリアサービスのタスクがALBのTarget Groupに登録され、healthyと認定されているか確認する
  - タスクがRUNNINGになりサービスが安定しても、タスクがALBから健全だと判定されるとは限りません
  - それどころか、ALBのヘルスチェックに失敗したタスクはTargetGroupから外されるどころか、ECSからも消されます
  - cageはカナリアサービスのタスクがTargetGroupに登録され、ALBからhealthyと認定されるまでを監視して検証します
  - 何らかの原因でヘルスチェックが通らないタスクを配置してしまった場合でも、メインサービスに影響を与えないようになっています
- メインサービスのタスク定義を新タスク定義に更新して、サービスが安定するまで待つ
  - メインサービスを新タスク定義で更新し、ECSの機能でタスクのローリングアップデートを行います
  - ちなみに最近リリースされたCodeDeployのECS Blue/Green Deploymentには対応していません
- カナリアサービスを削除する
  - 検証に使用したカナリアサービスを削除します
  - これでロールアウトが完了したメインサービスだけが残ります

## cageのベタープラクティス

cageを使う上で、弊社で行っているいくつかのベタープラクティスを紹介します。

### デプロイファイルをGitで管理する

デプロイファイルは、cageの実行時に正しいものが存在すればいいのですがCI/CD的な用途のためにGitでサービスのリポジトリに入れて管理することをおすすめします。Gitリポジトリに入れて管理することで、開発チームで正しいデプロイメントの記述が一つに定まり、誰が`up`や`rollout`を実行しても同じ結果を得ることができるようになります。

しかし**AWSのリソースの記述は人類には難しすぎる**ので、インフラの変更をデプロイファイルに反映するのが大変かもしれません。

### デプロイファイルをterraformで生成する

言い遅れましたが、弊社ではAWSのリソース管理を基本的に95%くらいまでは[terraform](https://www.terraform.io/)で管理しています。サービスで使うリソースはほぼ全てterraformで管理しているので、当然ECSに関連するリソースもterraformに記録されています。

`service.json`や`task-definition.json`の中に、どうしても生のAWSのリソースを記述しないといけない場合は、**terraform outputでデプロイファイルを生成する**という手法をおすすめします。

なぜなら、セキュリティグループやサブネットのIDは**人類には覚えることは不可能**なので、常にリソース的に正しいデプロイファイルを維持するために、terraformでインフラに変更を加えるたびに自動的に生成しています。

とはいえ殆どの項目を変えることがないので、必須というわけでもないです。
ただ、正しいリソース情報をGitファイルで管理するのはとても便利です。コンソールにいってコピペする手間が減ります。

terraformにこういった記述をしておいて、

```tf
output "service-1/service.json" {
  value = <<-EOS
{
    "cluster": "${aws_ecs_cluster.service.name}",
    "serviceName": "web-service",
    "taskDefinition": "dummy",
    "loadBalancers": [
        {
            "targetGroupArn": "${aws_alb_target_group.service.arn}",
            "containerName": "web-service",
            "containerPort": 80
        }
    ],
    "desiredCount": 2,
    "launchType": "FARGATE",
    "deploymentConfiguration": {
        "maximumPercent": 200,
        "minimumHealthyPercent": 100
    },
    "networkConfiguration": {
        "awsvpcConfiguration": {
            "subnets": [
                "${aws_subnet.subnet_a.id}",
                "${aws_subnet.subnet_b.id}",
                "${aws_subnet.subnet_c.id}"
            ],
            "securityGroups": [
                "${aws_security_group.sg-1.id}",
                "${aws_security_group.sg-2.id}"
            ],
            "assignPublicIp": "DISABLED"
        }
    },
    "healthCheckGracePeriodSeconds": 60
}
  EOS
}    
}
```

terraformコマンドでファイルを更新

```sh
$ terraform output service-1/service.json > .deploy/service.json
```

やはり少し面倒なんですが、確実さはあるかと。

### monorepoでデプロイファイルを管理する

弊社では試験的にマイクロサービスとmonorepoの運用をやっていて（まだ勝敗は不明）、cageのデプロイファイルもそれに合わせた構成で管理しています。リリースステージがdevelopment,stging,prductionの３つあるので、それ掛けるマイクロサービスの数のソースコードとデプロイファイルが一つのリポジトリで管理されています。画像にするとこんな感じ。

<img width="313" alt="スクリーンショット 2018-12-21 0.22.17.png" src="https://qiita-image-store.s3.amazonaws.com/0/3209/969eb22b-30fa-87cb-a530-7066e0b8abbe.png">

実際はもっと多いですが、これが正しいのかまだ分かってないです。特にCI系のサービスがmonorepoに厳しく、うまく運用できてるとはいい難いです。例えばPullRequestはservice-1の変更だけど、developにマージしたときそれがわからないので全部デプロイしちゃうとか。

そういう事情も相まってデプロイに安心感をもとめたという理由がありますが。

### デプロイファイルに環境変数を注入する
リポジトリでデプロイファイルを管理する場合、どうしても手動デプロイやCD(Continual Delivery)段階まで分からない要素が存在します。
いちばん身近な例が、タスク定義ファイルの`image`の項目です。
`image`の項目はCDするDockerイメージのタグを指定しなければいけませんが、例えば「masterブランチのCIを通った場合のみECRにプッシュしてCDしたい」という場合、CDするためのタグはコードをpushした段階ではわかりません。

そういった場合は、シェルスクリプトの環境変数リテラルを`task-definition.json`と`service.json`に記述しておくことが便利です。（※「\$VAR」のような記法は使えません。${VAR}だけになります。）

```json:task-definition.json
"image": "123456789.dkr.ecr.ap-northeast-1.amazonaws.com/web-server:${IMAGE_TAG}"
```

cageはコマンドを実行する際、`task-definition.json`と`service-definition.json`に記述された環境変数リテラルを自動的に自身の環境変数で置き換えて登録します。

弊社はCircleCIを使っていますが、CI上ではこういったスクリプトが実行されています。

```sh:ci.sh
... CI
# コミットをtagにしてECRにpush
$(aws ecr get-login --no-include-email --region ap-northeast-1)
ECR_URI=123456789.dkr.ecr.ap-northeast-1.amazonaws.com
docker tag ${IMAGE} ${ECR_URI}/web-server:${CIRCLE_SHA1}
docker push ${ECR_URI}/web-server:${CIRCLE_SHA1}
docker tag ${IMAGE} ${ECR_URI}/web-server:latest
docker push ${ECR_URI}/web-server:latest
# cageでデプロイ
IMAGE_TAG=${CIRCLE_SHA1} cage deploy .deploy/
```

## まとめとAWSでのコンテナのこれからポエム

ECSへのデプロイツールについて紹介しましたが、本来こういうツールは必要ないはずで、堅牢なデプロイはECSに統合されているべきだと思っています。

ECSは、現在コンテナのデプロイスタック（タスクを配置）としてのみ設計されている印象で、実際にWebサービスをコンテナでデプロイすることを第一に設計できていないように思えます。ECSは下記のようにタスクをWebサービスとして成り立たせるために周辺の別サービスと連携させていますが、あまりいい設計には思えません。正直言って使いづらいです。

- クラスター: EC2
- ネットワーク: VPC
- ロードバランサー: ALB
- デプロイ: CodeDeploy
- スケーリング: Application AutoScaling

これはEC2でWebアプリケーションを動かすのとほとんど大差ない構成です。開発中、コンテナの良さとは…という問いを繰り返ししていました。「もうEC2でDocker動かせばよくね？」的な。

控えめに言って、現在のAWSのクラウド関連のサービスは、VPCとインスタンス時代の設計に引っ張れれすぎてコンテナファーストになっていないと感じています。

現在のWebアプリケーションの開発者にとって、王様はDockerコンテナであり、ロードバランサーやVPCネットワークは**コンテナだけ**をサポートするために作られていてほしいというのが本音です。Kubernetesはまさにコンテナファーストの思想で設計されているように思います。実際Kubernetes on GKEも移行先として検討中です。

それにしてもDockerイメージをビルドしてrunするのはめちゃくちゃ簡単なのにそれをどこかへデプロイするのはなんでこんなに楽にならないんですかね？ VPS時代にSSHで`git pull`して再起動していた時代のほうがわかりやすかった。個人的に作ってるアプリとかは[Now](https://zeit.co/now)にデプロイしてますが、AWSにもこういうのがほしい。ElasticBeanstalk？知らない子ですね…

別件ですが、Goいいですね。CLI書くのにおすすめだと思います。
お客様の中にDocker神もしくはKubernetes神がいらっしゃいましたら[弊社にきて](https://n.loilo.tv/ja/job)僕の仕事をなくしてください。僕の仕事はTypeScriptでExpressとReactを書くことなんです。よろしくお願いいたします。

それでは失礼します！