AWS
docker
ECS

ECS運用のノウハウ

ECSデプロイツールを公開しました

https://qiita.com/naomichi-y/items/fee7720fdadd443a0aa0


概要

ECSで本番運用を始めて早半年。ノウハウが溜まってきたので公開していきます。

設計

基本方針

基盤を設計する上で次のキーワードを意識した。

  • Immutable infrastructure
    • 一度構築したサーバは設定の変更を行わない
  • Infrastructure as Code
    • インフラの構成をコードで管理 (Terraformを採用)
  • Serverless architecture
    • 無駄にサーバを増やさない

アプリケーションレイヤに関して言えば、Twelve Factor Appが参考になる。コンテナ技術とも親和性が高い。

ECSとBeanstalk Multi-container Dockerの違い

以前に記事を書いたので、詳しくは下記参照。

Beanstalk Multi-container Dockerは、ECSを抽象化してRDSやログ管理の機能を合わせて提供してくれる。ボタンを何度か押すだけでRubyやNode.jsのアプリケーションが起動してしまう。
一見楽に見えるが、ブラックボックスな部分もありトラブルシュートでハマりやすいので、素直にECSを使った方が良いと思う。

ECSクラスタの作り方

ECSクラスタにEC2を登録する方法は2パターンある。

  1. クラスタ作成時にEC2を作成する (デフォルト)
  2. クラスタ作成時にEC2を作成せず、手動登録する

1は裏でCloudFormationが働き、クラスタへのEC2の登録からオートスケーリング周りの設定まで自動で行ってくれる。
2は空のクラスタを作成し、ECSコンテナインスタンスを手動登録する必要がある。

インフラをコード管理する上でも、ここでは2の手法をお勧めしたい。実はクラスタ作成後のオートスケール周りで設定の違いが出てくる。
下図は1の手法でクラスタを作成した場合のコンテナインスタンスの設定ページ。Scale ECS Instancesという項目が用意されている。インスタンス数を増減する場合はこのボタンから変更すれば良い。
Screen Shot 2018-08-12 at 4.38.51.png
※後述するが、インスタンスを減らす場合はこのボタンを押す前にインスタンスの保護設定が必要。でないと稼働中のアプリケーションが落ちてしまうことがある。

続いてこちらが手動登録時のUI。Scale ECS Instancesボタンが無い。インスタンスの増減はEC2(Auto Scaling Groups)から設定が必要となる。
Screen Shot 2018-08-12 at 4.39.02.png

以前、1のパターンで作成したクラスタには次のような問題が起きた。

  • クラスタが所属するサブネットを Auto Scaling Groupsから変更後、Scale ECS Instancesからインスタンスを増やしても追加インスタンスは変更後のサブネットに所属しない。更にインスタンスを増やした時点でサブネットの設定が元に戻ってしまう。
    • 原因としてはCloudFormation側のテンプレートもサブネットの変更が必要だった
  • Auto Scaling Groupsでもインスタンス数を増減できるが、インスタンス数はScale ECS Instancesと同期していない
    • Auto Scaling Groupsからインスタンス数の変更しない方が無難

2のパターンでコンテナインスタンスを登録するには、EC2作成時のイメージとしてAWS Marketplaceで公開されているAmazon ECS-Optimized Amazon Linux AMIを使うのが簡単である。

ALBを使う

ECSでロードバランサを利用する場合、CLB(Classic Load Balancer)かALB(Application Load Balancer)を選択できるが、特別な理由がない限りALBを利用するべきである。
ALBはURLベースのルーティングやHTTP/2のサポート、パフォーマンスの向上など様々なメリットが挙げられるが、ECSを使う上での最大のメリットは動的ポートマッピングがサポートされたことである。
動的ポートマッピングを使うことで、1ホストに対し複数のタスク(例えば複数のNginx)を稼働させることが可能となり、ECSクラスタのリソースを有効活用することが可能となる。

※1: ALBの監視方式はHTTP/HTTPSのため、TCPポートが必要となるミドルウェアは現状ALBを利用できない。
※2: 2017年9月、TCPによる高スループットを実現したNetwork Load Balancerが追加された。詳しくはクラスメソッドの記事辺りを参考に。HTTP/HTTPSはALB、TCPはNLBを利用することで、今後CLBを利用する機会は無くなってくるものと考えられる。

アプリケーションの設定は環境変数で管理

Twelve Factor Appでも述べられてるが、アプリケーションの設定は環境変数で管理している。
ECSのタスク定義パラメータとして環境変数を定義し、パスワードやシークレットキーなど、秘匿化が必要な値に関してはKMSで暗号化。CIによってECSにデプロイが走るタイミングで復号化を行っている。

ログドライバ

ECSにおいてコンテナはデプロイの度に破棄・生成されるため、アプリケーションを始めとする各種ログはコンテナの内部に置くことはできない。ログはイベントストリームとして扱い、コンテナとは別のストレージで保管する必要がある。

今回はログの永続化・可視化を考慮した上で、AWSが提供するElasticsearch Service(Kibana)を採用することにした。
ECSは標準でCloudWatch Logsをサポートしているため、当初は素直にawslogsドライバを利用していた。CloudWatchに転送してしまえば、Elasticsearch Serviceへのストリーミングも容易だったからである。

Network (3).png

しかし、Railsで開発したアプリケーションは例外をスタックトレースで出力し、改行単位でストリームに流されるためログの閲覧やエラー検知が非常に不便なものだった。
Multiline codec plugin等のプラグインを使えば複数行で構成されるメッセージを1行に集約できるが、AWS(Elasticsearch Service)ではプラグインのインストールがサポートされていない。
EC2にElasticsearchを構築することも一瞬考えたが、Elasticsearchへの依存度が高く、将来的にログドライバを変更する際の弊害になると考えて止めた。
その後考案したのがFluentd経由でログをElasticsearch Serviceに流す方法。この手法であればFluentdでメッセージの集約や通知もできるし、将来的にログドライバを変更することも比較的容易となる。

Network (4).png

ジョブスケジューリング

アプリケーションをコンテナで運用する際、スケジュールで定期実行したい処理はどのように実現するべきか。
いくつか方法はあるが、1つの手段としてLambdaのスケジュールイベントからタスクを叩く方法がある(Run task)。この方法でも問題はないが、最近(2017年6月)になってECSにScheduled Taskという機能が追加されており、Lambdaに置き換えて利用可能となった。Cron形式もサポートしているので非常に使いやすい。

運用

ECSで設定可能なパラメータ

ECSコンテナインスタンスにはコンテナエージェントが常駐しており、パラメータを変更することでECSの動作を調整できる。設定ファイルの場所は /etc/ecs/ecs.config
変更する可能性が高いパラメータは下表の通り。他にも様々なパラメータが存在する。

パラメータ名 説明 デフォルト値
ECS_LOGLEVEL ECSが出力するログのレベル info
ECS_AVAILABLE_LOGGING_DRIVERS 有効なログドライバの一覧 ["json-file","awslogs"]
ECS_ENGINE_TASK_CLEANUP_WAIT_DURATION タスクが停止してからコンテナが削除されるまでの待機時間 3h
ECS_IMAGE_CLEANUP_INTERVAL イメージ自動クリーンアップの間隔 30m
ECS_IMAGE_MINIMUM_CLEANUP_AGE イメージ取得から自動クリーンアップが始まるまでの間隔 1h

パラメータ変更後はエージェントの再起動が必要。

$ sudo stop ecs
$ sudo start ecs

クラスタのスケールアウトを考慮し、ecs.configはUserDataに定義しておくと良い。
以下はfluentdを有効にしたUserDataの記述例。

#!/bin/bash
echo ECS_CLUSTER=sandbox >> /etc/ecs/ecs.config
echo ECS_AVAILABLE_LOGGING_DRIVERS=["fluentd\"] >> /etc/ecs/ecs.config

CPUリソースの制限

現状ECSにおいてCPUリソースの制限を設定することはできない(docker runの--cpu-quotaオプションがサポートされていない)。
タスク定義パラメータcpuは、docker runの--cpu-sharesにマッピングされるもので、CPUの優先度を決定するオプションである。従って、あるコンテナがCPUを食いつぶしてしまうと、他のコンテナにも影響が出てしまう。
尚、Docker 1.13からは直感的にCPUリソースを制限ができる--cpusオプションが追加されている。是非ECSにも取り入れて欲しい。

ユーティリティ

実際に利用しているツールを紹介。

graph.png

ルートボリューム・Dockerボリュームのディスク拡張

ECSコンテナインスタンスは自動で2つのボリュームを作成する。1つはOS領域(/dev/xvda 8GB)、もう1つがDocker領域(/dev/xvdcz 22GB)である。
クラスタ作成時にDocker領域のサイズを変更することはできるが、OS領域は項目が見当たらず変更が出来ないように見える。

Screen_Shot_2017-08-31_at_11_00_03.png

どこから設定するかというと、一度空のクラスタを作成し、EC2マネージメントコンソールからインスタンスを作成する必要がある。

また、既存ECSコンテナインスタンスのOS領域を拡張したい場合は、EC2マネージメントコンソールのEBS項目から変更可能。スケールアウトを考慮し、Auto scallingのLaunch Configurationも忘れずに更新しておく必要がある。

補足となるが、Docker領域はOS上にマウントされていないため、ECSコンテナインスタンス上からdf等のコマンドで領域を確認することはできない。

デプロイ

ECSのデプロイツールは色々ある。

※下2つは私が開発したツールです。genovaecs_deployer をベースとしたデプロイ管理マネージャとなってます。

デプロイ方式

  • コマンド実行形式のデプロイ
  • GitHubのPushを検知した自動デプロイ
  • Slackを利用したインタラクティブデプロイ

Screen_Shot_2017-08-31_at_11_31_15.png

デプロイフロー

ECSへのデプロイフローは次の通り。

  1. リポジトリ・タスクの取得
  2. イメージのビルド
    • タグにGitHubのコミットID、デプロイ日時を追加
  3. ECRへのプッシュ
  4. タスクの更新
  5. 不要なイメージの削除
    • ECRは1リポジトリ辺り最大1,000のイメージを保管できる
  6. サービスの更新
  7. タスクの入れ替えを監視
    • コンテナの異常終了も検知
  8. Slackにデプロイ完了通知を送信

デプロイパフォーマンスの改善

ALBを利用している場合、デプロイ時のコンテナの入れ替えに時間がかかることがある。
これはELBが登録解除プロセスを実行する前に300秒待機して、リクエストの完了を待つために起きている。アプリケーションの特性によってはこの時間を短くすることで、デプロイのパフォーマンスを改善することができる。

Screen Shot 2017-09-23 at 13.33.56.png

この設定の変更により、6〜7分かかっていたデプロイが2分程度に改善された。

ログの分類

ECSのログを分類してみた。

ログの種別 ログの場所 備考
サービス AWS ECSコンソール サービス一覧ページのEventタブ APIで取得可能 (※1)
タスク AWS ECSコンソール クラスタページのTasksタブから"Desired task status"が"Stopped"のタスクを選択。タスク名のリンクから停止した理由を確認できる APIで取得可能
Docker daemon ECSコンテナインスタンス /var/log/docker (※2)
ecs-init upstart ジョブ ECSコンテナインスタンス /var/log/ecs/ecs-init.log (※2)
ECSコンテナエージェント ECSコンテナインスタンス /var/log/ecs/ecs-agent.log (※2)
IAMロール ECSコンテナインスタンス /var/log/ecs/audit.log タスクに認証情報のIAM使用時のみ
アプリケーション コンテナ /var/lib/docker/containers ログドライバで変更可能

サーバレス化

ECSから少し話が逸れるが、インフラの運用・保守コストを下げるため、Lambda(Node.js)による監視の自動化を進めている。各種バックアップからシステムの異常検知・通知までをすべてコード化することで、サービスのスケールアウトに耐えうる構成が容易に構築できるようになる。
ECS+Lambdaを使ったコンテナ運用に切り替えてから、EC2の構築が必要となるのは踏み台くらいだった。

クラスタから特定のインスタンスを外す

クラスタから特定のインスタンスを外したい場合は、インスタンスのドレイニングが必要となる。ドレイニング状態となったインスタンスには新しいタスクが配置されなくなり、別のインスタンスにタスクが配置される。
ドレイニングを行わずインスタンスを削除した場合、インスタンス上のコンテナが削除され、アプリケーションへの接続が遮断される点に注意したい。

インスタンスのドレイニングはクラスタページのECS Instancesタブから設定できる。

Screen_Shot_2017-09-25_at_15_28_54.png

ドレイニングが終わると、対象インスタンス上のタスク数は 0 となる。

Screen_Shot_2017-09-25_at_15_35_36.png

ドレイニングの自動化についてはAWSの記事が参考となる。

インスタンスを減らす

クラスタ上のインスタンスを増やすのは簡単で、コンソールから実行する場合は Scale ECS Instances を実行すれば良い。

Screen_Shot_2018-06-14_at_1_48_41.png

Screen Shot 2018-06-14 at 1.54.24.png

同様にインスタンスを減らす場合は Desired number of instances の数を減らせば良いのだが、そのまま実行してしまうと稼働中のコンテナが落ちてしまうことがある。
今まで何となく「ドレイニングしたインスタンスが外される」と思ってたが、決してそんなことはなく、どのインスタンスが落ちるかは制御できないものらしい。

で、どうすれば良いかというと、例えば次のような対応が必要となる。

  1. 削除対象インスタンスをドレイニング
  2. 削除されたくないインスタンスに対し、Set Scale In Protection (削除保護) を有効化
  3. インスタンス数を減らす

2 の削除保護は、EC2の削除保護ではなく、オートスケールグループ側の設定が必要であることに注意する。

Screen_Shot_2018-06-18_at_21_17_02.png

リザーブドインスタンスの購入

ECSはEC2でクラスタリングを構成するため、年間利用(1年または3年)が確定しているのであればリザーブドインスタンスを購入することで2〜5割程度の割引を受けることができる。

トラブルシュート

ログドライバにfluentdを使うとログの欠損が起きる

ログドライバの項に書いた通り、アプリケーションログはFluentd経由でElasticsearchに流していたが、一部のログが転送されないことに気付いた。
構成的にはアプリケーションクラスタからログクラスタ(CLB)を経由してログを流していたが、どうもCLBのアイドルタイムアウト経過後の最初のログ数件でロストが生じている。試しにCLBを外してみるとロストは起きない。

Network (1).png

ログクラスタ(ECSコンテナインスタンスの/var/log/docker)には次のようなログが残っていた。

time="2017-08-24T11:23:55.152541218Z" level=error msg="Failed to log msg \"...\" for logger fluentd: write tcp *.*.*.*:36756->*.*.*.*:24224: write: broken pipe"
3) time="2017-08-24T11:23:57.172518425Z" level=error msg="Failed to log msg \"...\" for logger fluentd: fluent#send: can't send logs, client is reconnecting"

同様の問題をIssueで見つけたが、どうも現状のECSログドライバはKeepAliveの仕組みが無いため、アイドルタイムアウトの期間中にログの送信が無いとELBが切断してしまうらしい(AWSサポートにも問い合わせた)。

という訳でログクラスタにはCLBを使わず、Route53のWeighted Routingでリクエストを分散することにした。

Network (2).png

尚、この方式ではログクラスタのスケールイン・アウトに合わせてRoute 53のレコードを更新する必要がある。
ここではオートスケールの更新をSNS経由でLambdaに検知させ、適宜レコードを更新する仕組みを取った。

※2017年9月にNLB (Network Load Balancer) がリリースされました。ログクラスタのELBをNLBに置き換えることで問題が解決することを確認済みです。

コンテナの起動が失敗し続け、ディスクフルが発生する

ECSはタスクの起動が失敗すると数十秒間隔でリトライを実施する。この時コンテナがDockerボリュームを使用していると、ECSコンテナエージェントによるクリーンアップが間に合わず、ディスクフルが発生することがあった(ECSコンテナインスタンスの/var/lib/docker/volumesにボリュームが残り続けてしまう)。
この問題を回避するには、ECSコンテナインスタンスのOS領域(※1)を拡張するか、コンテナクリーンアップの間隔を調整する必要がある。
コンテナを削除する間隔はECS_ENGINE_TASK_CLEANUP_WAIT_DURATIONパラメータを使うと良い。

※1: DockerボリュームはDocker領域ではなく、OS領域に保存される。OS領域の容量はデフォルトで8GBなので注意が必要。

また、どういう訳か稀に古いボリュームが削除されず残り続けてしまうことがあった。そんな時は次のコマンドでボリュームを削除しておく。

# コンテナから参照されていないボリュームの確認
docker volume ls -f dangling=true

# 未参照ボリュームの削除
docker volume rm $(docker volume ls -q -f dangling=true)

ECSがELBに紐付くタイミング

DockerfileのCMDでスクリプトを実行するケースは多々あると思うが、コンテナはCMDが実行された直後にELBに紐付いてしまうので注意が必要となる。

bundle exec rake assets:precompile

このようなコマンドをスクリプトで実行する場合、アセットがコンパイル中であろうがお構いなしにELBに紐付いてしまう。
時間のかかる処理は素直にDockerfile内で実行した方が良い。

ecs-agent.logに"WARN messages when no Tasks are scheduled"という警告が出力される

ECSコンテナインスタンスの/var/log/ecs-agent.logWARN messages when no Tasks are scheduledという警告が頻出していた。Issueにも症例が報告されている。
この問題は既知の不具合のようで、ECSのAMIにamzn-ami-2017.03.d-amazon-ecs-optimized - ami-e4657283を使うことで解決した(AMI amzn-ami-2016.09.f-amazon-ecs-optimized - ami-c393d6a4で問題が起こることを確認済み)。

コンテナ起動時にディスク容量不足のエラーが出てコンテナごと落ちる

CannotPullContainerError: failed to register layer: devmapper: Thin Pool has 2862 free data blocks which is less than minimum required 4449 free data blocks. Create more free space in thin pool or use dm.min_free_space option to change behavior

docker info でDockerの情報を確認する。

$ docker info
Containers: 5
 Running: 5
 Paused: 0
 Stopped: 0
Images: 6
Server Version: 17.12.0-ce
Storage Driver: devicemapper
 Pool Name: docker-docker--pool
 Pool Blocksize: 524.3kB
 Base Device Size: 10.74GB
 Backing Filesystem: ext4
 Udev Sync Supported: true
 Data Space Used: 21.84GB
 Data Space Total: 23.33GB
 Data Space Available: 1.491GB
...

ECSはデフォルトで22GBのDockerイメージ格納領域を作るが、Data Space Available の値を見ると 1.49GB しかない。

次のコマンドを実行して容量を増やす。

# 実行中でないコンテナを削除
$ docker rm $(docker ps -aq)

# 未使用のイメージを削除
$ docker rmi $(docker images -q)

# 確認
$ docker info
Containers: 5
 Running: 5
 Paused: 0
 Stopped: 0
Images: 3
Server Version: 17.12.0-ce
Storage Driver: devicemapper
 Pool Name: docker-docker--pool
 Pool Blocksize: 524.3kB
 Base Device Size: 10.74GB
 Backing Filesystem: ext4
 Udev Sync Supported: true
 Data Space Used: 20.54GB
 Data Space Total: 23.33GB
 Data Space Available: 2.79GB

1.49GB2.79GB と少し増えた。
続けてコンテナ内で使用されていないデータブロックを削除してみる。

$ sudo sh -c "docker ps -q | xargs docker inspect --format='{{ .State.Pid }}' | xargs -IZ fstrim /proc/Z/root/"

$ docker info
Containers: 5
 Running: 5
 Paused: 0
 Stopped: 0
Images: 3
Server Version: 17.12.0-ce
Storage Driver: devicemapper
 Pool Name: docker-docker--pool
 Pool Blocksize: 524.3kB
 Base Device Size: 10.74GB
 Backing Filesystem: ext4
 Udev Sync Supported: true
 Data Space Used: 10.43GB
 Data Space Total: 23.33GB
 Data Space Available: 12.9GB

2.79GB12.9GB まで増えた。
根本解決としては、Dockerに割り当てるストレージ容量を増やすか、古いイメージの削除間隔を短くすれば良さそう。