Amazon ECS、AWS CloudFormation、AWS CodePipelineなどを本番投入してみた話

[Netadashi Meetup #4](https://atnd.org/events/91014?k=2e8aec2b207e6711e1be21c56c9b3ff2) の発表資料です。

---

# 今日の内容
* 数ヶ月前に ECS、CloudFormation、CodePipeline などのAWSサービスを使い倒して実際に本番運用を開始
* 工夫したところとか苦労したところを共有

---

# プロジェクト概要
2011年くらいに作られたWebシステムの老朽化対応

* OSや各種ミドルウェアのEOLを迎えるため入れ替えたい
* 国内の割高なクラウドサービス(単なる仮想マシン)を使っていたが、できれば安くしたい

:point_right: 社内でも活用事例の多いAWSを移行先として検討

---

# 現行システム
あるあるな一昔前のWebシステム?

* JavaのWebアプリ + スクリプト言語によるバッチ処理(cronによる定期実行)
* ソースはSVNで管理(10数個のリポジトリ)
* 課題管理やテーマ管理などはExcelで
* CI/CDはやっておらず開発者のPCでビルドして手動でデプロイ
* インフラはExcelの設定シートを見ながら手動で構築・メンテナンス

:point_right: 単純にAWS上にのせかえるだけではなく、今後のメンテナンス・運用の効率化も考えてAWSのサービスを活用することを前提に、開発フローも含めて見直し

---

# 基本方針

* AWSのマネージドサービスを使う
* 脱Excel :no_good:
* 徹底的に自動化 :factory:

---

## AWSのマネージドサービスを使う

* ロードバランサー: ELB
* データベース: RDS 
* ファイルサーバ: S3
* コンテナ管理: ECS
* コンテナイメージ管理: ECR
* バッチ実行: CloudWatch Events + ECS
* NAT: NATゲートウェイ
* 運用監視: CloudWatch
* プロビジョニング・デプロイ: CloudFormation
* CI/CDパイプライン: CodePipeline
* 各種カスタマイズ: Lambda

---

## 脱Excel

* テーマ管理・課題管理・バグ管理は社内に立てたGitLabのIssue機能を利用
* インフラ設定はCloudFormationのYAMLファイルをGitで管理
    * YAMLをインフラの詳細設計書とする
    * 設計意図(なぜその項目や値を設定したか)をYAMLのコメントで書く
* 設計書はGitBook(Markdown)で書いてGitで管理し、HTMLで出力して納品

:point_right: インフラからアプリまですべてプルリク駆動開発を可能に

---

## 自動化

* ソースはSVN :arrow_right: Gitに(履歴も含めて)移行し、GitLab-CIでCIパイプラインを構築
* GitLab-CI から CodePipeline の連携は承認(1 click)でできるように自動化


<img width="800" alt="gitlab-pipeline" src="https://qiita-image-store.s3.amazonaws.com/0/35233/5cf14bc2-f9ab-0171-eea7-77a2febc9c70.png">

---

* CDパイプラインにはCodePipeline + CloudFormation + Lambdaを利用してリリースまで自動化
* インフラ構築・更新もCloudFormationを使いほぼ自動化
    * CloudFormationが未対応のところや、SSL/TLS証明書発行のVerifyなど手動なところは一部ある:cry:

<img width="400" alt="codepipeline" src="https://qiita-image-store.s3.amazonaws.com/0/35233/d05cce0e-cfc9-e57d-24fe-b023645e266a.png">

---

### 移行前の開発〜リリースまでのフロー

<img width="700" alt="before" src="https://qiita-image-store.s3.amazonaws.com/0/35233/440b689a-daee-097b-e62f-b3ca0cef4a27.png">

---

### 移行後の開発〜リリースまでのフロー

<img width="700" alt="before" src="https://qiita-image-store.s3.amazonaws.com/0/35233/1f47b5f3-c4d5-fc43-417c-7a043334e487.png">

---

# 工夫したところとか

---

## CloudFormation

---

## CloudFormation

* 一から全部書くのは非常につらい...:sweat:
* が、途中からCloudFormationに切り替えるのはもっと大変

:point_right: AWSが提供しているリファレンスアーキテクチャのテンプレートから始めるとよい

---

### リファレンスアーキテクチャの活用

* [github.com/awslabs/ecs-refarch-cloudformation](https://github.com/awslabs/ecs-refarch-cloudformation)がECSを使ったもの
* 今回はこれをちょっとずつカスタマイズしていった
* CIも含めてAWS上で完結させるなら、今回は使っていないCodeBuildも含まれている[github.com/awslabs/ecs-refarch-continuous-deployment](https://github.com/awslabs/ecs-refarch-continuous-deployment)がいいかも


<img width="400" alt="before" src="https://raw.githubusercontent.com/awslabs/ecs-refarch-cloudformation/master/images/architecture-overview.png">

---

### 私的ベストプラクティス

* テンプレートはJSONではなくYAMLで作成
* スタックを分割して細かくスタックの作成・更新・削除をできるようにする
    * フィードバックループをすばやくまわせるようにスタックを小さく
    * 削除も細かい単位でできるので、開発環境はお金がたくさんかかるスタックだけ夜に自動削除するとかもやりやすい
* スタック間の連携は[クロススタック参照機能](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/walkthrough-crossstackref.html)を使う
    * ただし、依存関係ができるので多用は気をつけた方がよい(依存元のスタックを先に削除もできなくなる)
    * DB接続先などのエンドポイントの共有はDNS(Route53)でやったほうがより疎結合

---

* AWSオフィシャルのドキュメントの **英語版** を必ず見る
    * 日本語翻訳が追いついていないことが多々ある 
    * 頻繁にCloudFormation自体がアップデートされるので、Webの記事だけを見て実装しない(バッドノウハウ化している可能性あり)
    * [Release History](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/ReleaseHistory.html)を見て対応されていないかチェックするとよい
        * この時も必ず **英語版** をみること
* Lambdaでカスタムリソースを作るのは最後の手段:exclamation:

---

## ECS

---

### コンテナイメージのビルド

* コンテナイメージのビルド自体もコンテナ上でやろう(いわゆるDocker in Docker)
    * ビルド環境もコンテナ化することでビルド環境依存の問題を排除
    * ビルド環境の再構築もやりやすい
* コンテナのビルドキャッシュを効かせてスロービルドに対応
    * GitLab-CIの設定で、同じRunner上で動作するように固定化しておく
    * Docker 1.13以降だと、`docker build --cache-from イメージ名`でpullしたイメージをキャッシュに使えて便利

---

:warning: 社内でDockerコンテナのビルドを認証プロキシを利用して行う場合は要注意 :warning:

* 詳しくは[こちら](https://qiita.com/wadahiro/items/3b08e14e7ca2c6b31461)に書いた
* コンテナイメージに認証プロキシ情報が埋め込まれるため、ユーザID/パスワードがコンテナイメージから丸分かりになる
* 対策としては、Docker17.05.0-ce (2017-05-04) 以上の新しいバージョンを使ってビルドするとOK
* そうでなければ、認証プロキシを直接参照しないように中継用プロキシを用意したり、透過プロキシを構築するとよい
* [透過プロキシをgolangで作ってみた](https://qiita.com/wadahiro/items/7fa852b7217940e6ef26)

---

### コンテナデプロイ方式

* みんな悩むところ?
* [awesome-ecs](https://github.com/nathanpeck/awesome-ecs)のBuild and Deploy Toolsをみると10個くらいツールがある
* ECSを随分前から活用しているCookpadは内製デプロイツール [Hako](https://github.com/eagletmt/hako) を作っていたりする
* 一方、AWSとしてはCloudFormationでデプロイする方法を今はおすすめしている? ([github.com/awslabs/ecs-refarch-continuous-deployment](https://github.com/awslabs/ecs-refarch-continuous-deployment)ではそう)

* 今回は**CloudFormation方式を採用**
    * ツールの種類を増やしたくない
    * 対応範囲・プロダクトの継続性という点だとCloudFormationは有利
    * Red/Blackデプロイはやりづらいが、ECS標準のローリングデプロイで今回は困らなかった

---

#### デプロイ実装のポイント

* CloudFormationでECSにコンテナをデプロイする場合、コンテナのイメージタグを`${Tag}`変数で設定してデプロイすることになる
* 何の値を入れるとよいだろう?

```yaml
    TaskDefinition:
        Type: AWS::ECS::TaskDefinition
        Properties:
            Family: myapp
            ContainerDefinitions:
                - Name: app
                  Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/myapp:${Tag}
```

候補としては以下

* 最新版を表す `latest` 固定
* CIツールが発行するパプラインIDやビルドID
* 手動で設定するリリースバージョン
* Gitのコミットハッシュ
* タイムスタンプ

---

正解はGitのコミットハッシュ

* `AWS::ECS::TaskDefinition` の仕様として、`Image` プロパティの値を書き換えるとECSタスク定義の更新が行われる :arrow_right: **コンテナの置き換えが実行**
* `latest`だと、ECSタスク定義に変更がないためデプロイされない
    * ただし、コンテナを停止して再起動させると最新版がpullされデプロイされる
* ビルドID のように必ず毎回違う値だと、該当Dockerコンテナに変更がない場合でも無駄にコンテナのデプロイが走ることになる
* また、Gitリポジトリ内に複数のDockerfileを管理している場合は、コミットハッシュの取得方法に要注意
* 以下のように`DockerFile` 格納先ディレクトリのコミットハッシュを取るとよい

```sh
COMMIT_HASH=`git log -n 1 --pretty=format:"%H" containers/myapp/` 
```

* そうすると関係のないファイルへの変更でもイメージタグは変わらず、無駄なデプロイを防げる

---

### 思ったよりマネージドじゃない :dizzy_face:

1. ECSクラスター内のEC2インスタンスの各種ログ(OSやECSコンテナエージェントのログなど)が、AWS提供のECS最適化AMIではローカルに出力されるのみ
    * CloudWatch Logsで一元管理したい場合、別途CloudWatch Logsエージェントをインストールして設定する必要あり
    * 実装方法は[ECSの開発者ガイド](http://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/using_cloudwatch_logs.html)が参考になる
2. ECSは(EC2は) ディスク使用量のCloudWatchメトリクス取得が標準ではできない
    * CloudWatch APIを定期的にコールしてカスタムメトリクスを登録するように作り込む必要あり
    * 実装方法はAWS Compute Blogの[Optimizing Disk Usage on Amazon ECS](https://aws.amazon.com/jp/blogs/compute/optimizing-disk-usage-on-amazon-ecs/)が参考になる
3. ECSコンテナエージェントの接続が切れても自動復旧しない
    * アプリがメモリリーク等でメモリを消費し続けるとEC2がダンマリ状態になり、ECSコンテナエージェントの接続もそのうち切れる
    * が、該当EC2インスタンスはAutoScaleのクラスタから切り離されない...
    * 自前でECSコンテナエージェントの状態を見てAutoScaleから切り離す、といったLambdaを書いたりする必要あり

**結果、EC2インスタンスを定義する100行超のYAMLが出来上がった...** :cry:

---

### その他ECSのつらいところ

ELBを利用したヘルスチェックがいまいち

* コンテナ起動時のヘルスチェックと通常運用中のヘルスチェックの設定が同じ
* そのため、コンテナ起動に時間がかかるアプリだと、ヘルスチェックに失敗 :arrow_right: ELB切り離し :arrow_right: コンテナ再起動 :arrow_right: ... を延々と繰り返す :scream:
* [改善要望](https://github.com/aws/amazon-ecs-agent/issues/534)が出ているが対応はまだ未定

ワークアラウンドとしては、

* ヘルスチェックの閾値を緩くする
* 起動時のみ閾値を緩くするようにCloudWatch EventsとLambdaを駆使して動的に設定変更する
* Consul等を入れてAWSにたよらずにヘルスチェックする

---

* つらいところはあるが、AWSのAPIやLambda等サービスを駆使すれば対応できてしまうのもAWSのメリットの一つ
* 将来的にAWS側で改善されれればがっつりコードを消せる

---

#### 例: ECSでバッチアプリを定期実行する

**Lambdaを介する必要がなくなった**

* 以前(〜2017/06/07)
    * ECSのAPIを呼び出してタスク(バッチアプリのDockerコンテナ)を起動するLambdaを作成する
    * CloudWatch EventsからこのLambdaを定期実行させる

* 現在
    * ECSのタスクを定期実行するようにCloudWatch Eventsを設定するのみ

<img width="250" alt="ecs-batch" src="https://qiita-image-store.s3.amazonaws.com/0/35233/f38e7b39-c3a4-23f9-a167-51931b6e3dd2.png">

---

## CodePipeline

---

* 実は最初は使うつもりはなかった
* CloudFormationで自動化できているのでデプロイ実行コマンドを叩くくらいならいいかなと思っていた
* きっかけは、社内のAWS利用ルールへの対応
    * ステージング環境と本番環境でAWSアカウントを分けないといけない
    * 本番環境へのSSH接続やAWS管理コンソールへのログインは、特定のIPからしかできないようにしないと駄目
* せっかく自動化しても最後のコマンドを叩くところが大変 :scream:

---

クロスアカウントを駆使してリリースも自動化

* AWSには異なるAWSアカウント間でセキュアにIAMロールを利用する、クロスアカウントという機能がある
* CodePipelineからクロスアカウントアクセスを行い、異なるAWSアカウントのデプロイを行う
* この仕組みを利用すると、1つのCodePipelineのフローからステージングと本番環境にシームレスにデプロイができる
* 作成にあたっては、CodePipelineのドキュメント [他のアカウントからリソースを使用するパイプラインを作成する](http://docs.aws.amazon.com/ja_jp/codepipeline/latest/userguide/pipelines-create-cross-account.html) が参考になる

---

注意点として、CodePipelineからLambdaをアクションとしてクロスアカウントで呼び出す場合、Lambda側からの完了通知も逆向きのクロスアカウントとする必要がある

```javascript
// クロスアカウントあり/なしを吸収するassumeRole関数を作成
var assumeRole = () => {
    // ステージング環境の場合はクロスアカウントは利用しない
    if (!'${EnvironmentName}'.includes('prod')) return Promise.resolve(newCodePipeline());

    // 本番環境の場合はクロスアカウントを利用して本番からステージング用のAWSアカウントのロールを引き受けて
    // CodePipelineのAPIを利用する
    return sts.assumeRole({
        DurationSeconds: 3600,
        RoleArn: 'arn:aws:iam::123456789012:role/Prod_CrossAccount_Role',
        RoleSessionName: 'prod-lambda'
    }).promise().then(data => {
        aws.config.credentials = sts.credentialsFrom(data);
        return newCodePipeline();
    }).catch(err => {
        return newCodePipeline();
    });
};

// 作成したassumeRole関数を使ってCodePipelineの完了通知を行う
var putJobSuccess = (jobId, message) => assumeRole().then(api => {
    return api.putJobSuccessResult({jobId}).promise();
});
```
---

### CodePipelineはまだまだ発展途上な印象

* シンプルなパイプラインしか作れない
* Jenkinsなどでパイプラインを作り込んでいた場合は移行が難しそう
* 遅い (1つのアクションで最低1分はかかる)

実行基盤を管理しなくてもよいのはもちろん非常にうれしい

---

# 他にやったこと

* IAMのユーザ管理もCloudFormation + CodePipelineでワークフロー化
* IAMアカウントを極力作らない運用
* CloudTrailもCloudFormationで自動設定(社内ルールの要件を満たすように)
* PrometheusでJavaアプリのヒープなどの監視
* Prometheusのサービスディスカバリでコンテナの増減に自動追随
* CloudWatchでエラー検知時に直近のログをまとめて自動メール通知
* ...

別途記事を書くかもです。

---

# まとめ

* マネージドサービスはいいぞ :+1:
* ただし完全なフルマネージドは幻想 :sparkles:
    * シェル、nodejs or python力 :muscle: が必要
* 今後改善したいところ
    * テスト自動化をパイプラインに組み込む
    * ブランチ単位で独立した環境の自動構築
    * CloudTrailの監査ログチェック運用の自動化