2017/12/08 追記
この記事もだいぶ古くなってしまったので、今となっては必ずしも最新の状況をあらわしていない箇所があります。
というわけで、新しめの情報について書きました!
DELISH KITCHENをECS移行した話(前編)をご参照ください!
はじめに
若干タイトル詐欺になってしまいますが、結論から言うとまだサービス運用開始には至っていません。
本当なら、実際にこういうふうに運用しているよ!という情報を発信できればよかったのですが、まだ経験値が足らないという結論に至りました。
これを書いた経緯
- Amazon EC2 Container Service(ECS)でサービス運用をしたいと思ってググってみた
- わりとたくさん情報が出てくるは出てくるんだけど、サンプルを動かしてみたとか簡単な紹介のみに終始しているものが多くて、具体的な実装例に言及しているものはあまりなかった(もしくは見つけられなかった)
- 実際にサービスで運用できる(くらいの)環境を構築するためにはいろいろと試行錯誤する必要があった
- 今後同じように調査する人たちの役に立つかと思い記事にしてみた ←今ココ
記事を読むにあたって
この記事中では、以下の前提知識が必要となります。特に説明は行っていないのでご了承ください。
- Dockerそのものの知識
-
docker
コマンドの使用方法 - awscliの使用方法
ECSでなにができるか、できないか
- ECSとは、Amazon Web Serviceが提供するDocker用のオーケストレーションツールの1つ
- 同様のサービスとしては、Kubernetes、Docker Swarm、ほかにもいろいろ
- Dockerコンテナの起動方法やコンテナ間の連携方法を定義できる
- Dockerコンテナの起動状態を表示、変更できる
- グラフィカルに可視化してくれるわけではないので、必要であれば自分で可視化ツールを導入しなければならない
- 古いコンテナから新しいコンテナへの切り替えを、ダウンタイムなしで実現できる
- いわゆるBlue Green Deployment
ECSで必要となる知識
- Dockerの知識全般
-
docker
コマンドの使用方法- ECSダッシュボードをポチポチやるだけなら不要ですが、実際にはsshログインしてコマンドを叩かないと何が起きているか分からないことがよくありました
ECSで実施する作業フロー
- Clusterを定義
- 0個以上のContainer Instanceで構成される
- Container Instanceを起動
- コンテナが起動するEC2インスタンスを起動する
- どのClusterに属するかを指定する
- Task Definitionを定義
- 1つ、または複数のコンテナを起動するための定義を記述する
- 同一Task Definition内のコンテナ間同士はお互いに通信が可能
- 内容はリビジョンごとに保持されており、変更するとリビジョンが上がる
- Serviceを定義
- Task Definitionの特定リビジョンを指定する
- 希望する起動個数を指定する
- 必要ならばELBとの関連付けを行う
構成の設計
以下で構成される、比較的シンプルなサービスを運用することを想定します。
実際にはもう少し複雑な構成である場合が多いと思いますが、下記の延長で考えれば
サービスの構成
- ELB
- apache+app
- 複数で構成される
- apache+app
- redis
コンテナの構成
- app
-
RoRベースのWEBアプリのコンテナ
-
unicornが起動する
-
コンテナ起動高速化のため、事前にassets precompileをしたものをイメージ化する
-
Dockerfileは以下のような感じ
DockerfileFROM ruby:2.2 COPY Gemfile Gemfile COPY Gemfile.lock Gemfile.lock COPY vendor/gems vendor/gems RUN apt-get update \ && apt-get install -y --no-install-recommends build-essential \ && mkdir -p /app/public WORKDIR /app RUN bundle install --without test:development COPY . . RUN RAILS_ENV=$RAILS_ENV DB_ADAPTER=nulldb bundle exec rake assets:precompile # assets precompile時にDB接続しようとするのを回避するためnulldbを使用 EXPOSE 3000 VOLUME ["/app/public"] ENTRYPOINT ["bundle", "exec", "unicorn_rails", "-E", $RAILS_ENV, "-c", "config/unicorn.rb"]
-
- apache
-
appの前段にあたるコンテナ
-
apache+設定ファイルの構成
-
appとの通信は
mod_proxy
で行う -
Dockerfileは以下のような感じ
DockerfileFROM debian:jessie RUN apt-get update \ && apt-get install -y --no-install-recommends apache2 && mkdir /var/www/public && a2enmod proxy \ proxy_http COPY sites-available/* /etc/apache2/sites-available/ EXPOSE 80 ENTRYPOINT ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]
-
- redis
- データ一時記憶場所として利用
- 詳しくは後述しますが、本来はサービスの安定性を考慮して複数コンテナで冗長化をしておくか、Amazon ElastiCacheなどのサービスを利用するのが適切だと思いますが、今回のテーマの主眼ではないので簡易な構成としています
- 標準コンテナを利用するのでDockerfileはなし
ECSの設定
クラスタの作成
今回はdefault
という名前のクラスタを用いるものとします。自分で作成することもできます。
EC2インスタンスの起動
ECSはEC2インスタンス上で各コンテナを実行しますが、そのインスタンスにはECS Agentというデーモンプログラムが起動している必要があります。
既存のインスタンスにECS Agentをインストールすることもできるようですが、ECS用インスタンスとしてすでにセットアップされたAMIがAmazonから提供されているので、こちらを利用して新規に起動するのが簡単かと思います。
利用可能なAMIの一覧はこちらから参照できます。自分のregionにあったAMIを選びましょう。
上記で選んだAMIを指定してインスタンスを起動するのですが、いくつか注意点があります。
- UserDataでクラスタ指定を行う
-
以下の入力をしておく。
default
の部分は必要なら置き換える#!/bin/bash echo ECS_CLUSTER=default >> /etc/ecs/ecs.config
-
- ECSに対するアクセス可能なIAM Roleを割り当てる
-
AmazonEC2ContainerServiceforEC2Role
というポリシーをattachしたRoleを作成しておく -
ポリシーの内容は以下のようになる
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "ecs:CreateCluster", "ecs:DeregisterContainerInstance", "ecs:DiscoverPollEndpoint", "ecs:Poll", "ecs:RegisterContainerInstance", "ecs:StartTelemetrySession", "ecs:Submit*", "ecr:GetAuthorizationToken", "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "*" } ] }
-
- sshログインを可能とする
- 必須ではないが、
docker
コマンドを実行できたほうが便利なので個人的にオススメ - セキュリティグループで22番ポートを開けておく、適切なKeypairを選択しておく、など
- 必須ではないが、
インスタンス起動後、コンテナインスタンス一覧に含まれていれば成功です。
$ aws --region ap-northeast-1 ecs list-container-instances --cluster default
{
"containerInstanceArns": [
"arn:aws:ecs:ap-northeast-1:NNNNNNNNNNNN:container-instance/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaa",
]
}
Task Definitionの作成
コンテナの起動方法をTask Definitionとして登録します。
Task Definitionはあくまでタスクの形式を登録するだけで、これだけではコンテナは起動しません。
今回は以下の様なTask Definition定義をします。
web-task
- appコンテナとapacheコンテナの組み合わせ
- apacheコンテナは80番ポートでアクセス可能とする
-
portMappings
の箇所 - apacheとappはlinksを利用して通信可能なので、appの3000番ポートはportMappingsでの開放はしなくてよい(してもいいけど)
-
- apacheはappに対して通信可能でなければならない
-
links
の箇所で、app
というホスト名でweb-appコンテナにアクセスできるようにしている -
environments
の箇所で、PROXY_PASS
としてapp:3000
という値をセットしており、apacheのmod_proxy設定によってこの環境変数を参照している
-
- apacheはappのファイルを参照可能でなければならない
- assetsファイルはapacheが直接ハンドリングするため
-
volumes
,mountPoints
の箇所
- appはredisに対して通信可能でなければならない
-
environments
の箇所で、DOCKER_REDIS_HOST
としてコンテナインスタンスのIPアドレスを直接指定する - なお、これは限定的な対応であり、本来ならば後述するService Discoveryの仕組みを構築する必要がある
-
{
"taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:NNNNNNNNNNNN:task-definition/web-task:1",
"revision": 1,
"containerDefinitions": [
{
"volumesFrom": [],
"memory": 256,
"extraHosts": null,
"dnsServers": null,
"disableNetworking": null,
"dnsSearchDomains": null,
"portMappings": [
{
"hostPort": 80,
"containerPort": 80,
"protocol": "tcp"
}
],
"hostname": null,
"essential": true,
"entryPoint": null,
"mountPoints": [
{
"containerPath": "/var/www/public",
"sourceVolume": "assets_data",
"readOnly": null
}
],
"name": "web-apache",
"ulimits": null,
"dockerSecurityOptions": null,
"environment": [
{
"name": "PROXY_PASS",
"value": "http://app:3000/"
}
],
"links": [
"web-app:app"
],
"workingDirectory": null,
"readonlyRootFilesystem": null,
"image": "YOUR_DOCKER_REPOS/apache",
"command": null,
"user": null,
"dockerLabels": null,
"logConfiguration": null,
"cpu": 10,
"privileged": null
},
{
"volumesFrom": [],
"memory": 1000,
"extraHosts": null,
"dnsServers": null,
"disableNetworking": null,
"dnsSearchDomains": null,
"portMappings": [],
"hostname": null,
"essential": true,
"entryPoint": [],
"mountPoints": [
{
"containerPath": "/app/public",
"sourceVolume": "assets_data",
"readOnly": null
}
],
"name": "web-app",
"ulimits": null,
"dockerSecurityOptions": null,
"environment": [
{
"name": "RAILS_ENV",
"value": "production"
},
{
"name": "DOCKER_REDIS_HOST",
"value": "10.11.20.30"
}
],
"links": null,
"workingDirectory": null,
"readonlyRootFilesystem": null,
"image": "YOUR_DOCKER_REPOS/app",
"command": null,
"user": null,
"dockerLabels": null,
"logConfiguration": null,
"cpu": 50,
"privileged": null
}
],
"volumes": [
{
"host": {
"sourcePath": "assets_data"
},
"name": "assets_data"
}
],
"family": "web"
}
redis-task
- redisコンテナを起動
- データストレージは永続化する
-
mountPoints
の箇所
-
{
"taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:NNNNNNNNNNNN:task-definition/redis-task:1",
"revision": 1,
"containerDefinitions": [
{
"volumesFrom": [],
"memory": 512,
"extraHosts": null,
"dnsServers": null,
"disableNetworking": null,
"dnsSearchDomains": null,
"portMappings": [
{
"hostPort": 6379,
"containerPort": 6379,
"protocol": "tcp"
}
],
"hostname": null,
"essential": true,
"entryPoint": null,
"mountPoints": [
{
"containerPath": "/data",
"sourceVolume": "redis_data",
"readOnly": null
}
],
"name": "redis",
"ulimits": null,
"dockerSecurityOptions": null,
"environment": [],
"links": null,
"workingDirectory": null,
"readonlyRootFilesystem": null,
"image": "redis:2.8",
"command": null,
"user": null,
"dockerLabels": null,
"logConfiguration": null,
"cpu": 10,
"privileged": null
}
],
"volumes": [
{
"host": {
"sourcePath": "redis_data"
},
"name": "redis_data"
}
],
"family": "redis"
}
Serviceの作成
Task Definitionをどれくらい起動するか、を定義するのがServiceです。
これを登録すると、指定した数のコンテナを起動しようとします。
ただし、競合リソースの確保という概念があって、これが競合するコンテナは同一インスタンスでは起動しません。
例えば、webはホスト側の80番ポートを開放するコンテナを含むタスク定義なので、1つのインスタンスに最大1つまでしか起動できません。複数のコンテナが必要なら、そのぶんインスタンスも起動しておく必要があるわけです。
他にも、コンテナに割り当てるメモリやCPUといった要素もリソースの一部なので、これが不足するようならばインスタンスの追加を行わないと、コンテナは起動されません。
またServiceには、Minimum healthy percent
とMaximum percent
という設定があるのですが、これについては後述のBlue Green Deploymentで説明します。
下記ではとりあえず、それぞれ50
, 100
を指定しておきます。
web-service
- コンテナ数1で起動
- ELBとの関連付けを行う
{
"taskDefinition": "arn:aws:ecs:us-northeast-1:NNNNNNNNNNNN:task-definition/web-task:1",
"loadBalancers": [
{
"containerName": "web-apache",
"containerPort": 80,
"loadBalancerName": "your-elb-name-here"
}
],
"role": "ecsServiceRole",
"desiredCount": 1,
"serviceName": "web-service",
"cluster": "default",
"deploymentConfiguration": {
"maximumPercent": 100,
"minimumHealthyPercent": 50
}
}
redis-service
- コンテナ数1で起動
{
"taskDefinition": "arn:aws:ecs:ap-northeast-1:NNNNNNNNNNNN:task-definition/redis-task:1",
"role": "ecsServiceRole",
"desiredCount": 1,
"serviceName": "redis-service",
"cluster": "default",
"deploymentConfiguration": {
"maximumPercent": 100,
"minimumHealthyPercent": 50
}
}
動作確認
awscliから
以下のように、desiredCount
=runningCount
になっていれば希望通りコンテナが起動しています。
pendingCount
が1以上であれば、今まさに起動中という状況なので少し待ちましょう。
$ aws ecs --region ap-northeast-1 describe-services --cluster default --services web-service redis-service | jq '.services[] | {serviceName, status, desiredCount, pendingCount, runningCount}'
{
"serviceName": "web-service",
"status": "ACTIVE",
"desiredCount": 1,
"pendingCount": 0,
"runningCount": 1
}
{
"serviceName": "redis-service",
"status": "ACTIVE",
"desiredCount": 1,
"pendingCount": 0,
"runningCount": 1
}
インスタンスから
インスタンスにsshログインできるようにしておけば、後は通常通りdockerコマンドが使えます。
$ ssh -i your-keypair-file ec2-user@container-instnce-ip
Last login: Mon Jun 6 08:32:06 2016 from 1.2.3.4
__| __| __|
_| ( \__ \ Amazon ECS-Optimized Amazon Linux AMI 2016.03.c
____|\___|____/
For documentation visit, http://aws.amazon.com/documentation/ecs
3 package(s) needed for security, out of 4 available
Run "sudo yum update" to apply all updates.
[ec2-user@ip-11-22-33-44 ~]$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
-snip-
1e288e62a5d1 amazon/amazon-ecs-agent:latest "/agent" 3 days ago Up 3 days 127.0.0.1:51678->51678/tcp ecs-agent
トラブルシューティング
runningCountが足りない
もしいつまで経ってもrunningCount
が0であったりdesiredCount
未満である場合は、なんらかの問題が発生している可能性があります。
そういう場合はエラーが出力されているので確認します。
$ aws ecs --region ap-northeast-1 describe-services --cluster default --services web-service redis-service | jq '.services[].events[].message'
"(service web) was unable to place a task because no container instance met all of its requirements. The closest matching (container-instance 8f078cd3-40a7-4873-ae04-223a14c74b9e) has insufficient memory available. For more information, see the Troubleshooting section of the Amazon ECS Developer Guide."
上記例だと、競合リソースとしてのメモリがこれ以上割り当てできなかったのでコンテナを起動できなかったことが分かります。
メモリ設定を変更するか、追加でコンテナインスタンスを起動する必要があります。
コンテナは起動しているが、うまく通信できていない
以下のように調査対象を絞っていくとよいと思います。
- コンテナ自体は正常に動作しているか?
- sshログインを可能にしているなら、ログイン後
docker ps
(ordocker ps -a
)コマンドを実行して、コンテナの稼働状況を確認する。 -
portMappings
でポートを開放しているなら、ELBを経由しないでインスタンスに対して直接通信したらどうか?- 別途セキュリティグループでアクセス権限を付与しておく必要がある
- sshログインを可能にしているなら、ログイン後
- web-appに問題があるのか?
-
web-appコンテナにエラーログが出力されていないか確認する
container-instance$ docker logs <web-app-container-id> I, [2016-06-03T10:26:15.859460 #1] INFO -- : Refreshing Gem list I, [2016-06-03T10:26:28.349481 #1] INFO -- : listening on addr=0.0.0.0:3000 fd=13 I, [2016-06-03T10:26:28.354816 #1] INFO -- : master process ready I, [2016-06-03T10:26:28.470681 #12] INFO -- : worker=0 ready
-
web-appコンテナにログインし、unicornやRoRの状態を確認する
container-instance$ docker exec -it <web-app-container-id> bash root@web-app-container-id:/app# bundle exec rails console # RoRが動作しているか root@web-app-container-id:/app# curl http://localhost:3000/ # unicornが動作しているか root@web-app-container-id:/app# redis-cli -h $DOCKER_REDIS_HOST info # Redisとの通信ができているか
-
- web-apacheに問題があるのか?
-
web-apacheコンテナにログインし、proxyの状態を確認する
container-instance$ docker exec -it <web-apache-container-id> bash root@web-apache-container-id:/app# curl http://app:3000/ # web-appとの通信ができているか root@web-apache-container-id:/app# curl http://localhost:80/ # web-appとのproxyが動作しているか
-
- ELBとコンテナは正しく通信できているか?
- ELBのインスタンス状態を確認し、該当のコンテナインスタンスが割り当てられているか?
- インスタンスの状態はHealthyか?
Blue Green Deploymentの方法
ECSでBlue Green Deploymentを実施するためには、Serviceの設定であるMinimum healthy percent
とMaximum percent
を理解する必要があります。
が、とりあえずシンプルかつ最小構成で実現するならば以下のように設定すればOKです。
内容について詳しい説明はこちらが参考になります。
下記の例では、2台のコンテナインスタンスにweb-task
が1つずつ起動しているコンテナ構成であるものとしています。
また、デプロイ時には一時的に起動コンテナ数が1つまで下がることを許容するものとします。
事前準備
- クラスタ内のコンテナインスタンスは2台にしておく
- Service
web-service
のMinimum healthy percent
を50
、Maximum percent
を100
にする
デプロイ時の作業と流れ
Task Definitionの内容を変更した場合、変更をServiceに反映する必要があります。
なお、Task Definitionの変更を伴わないような運用(Dockerイメージを更新してアップロードしたので、コンテナを再度起動し直せばよい、というようなケース)もあり得ると思いますが、その場合も下記のフローで かと思います。無駄にTask Definitionを変更することになりますが、フローが単純になるという利点がありますので。
作業内容
-
web-task:1
を再登録すると、リビジョンがインクリメントされweb-task:2
が登録される -
web-service
に関連付けられているweb-task:1
をweb-task:2
に変更する
ECS上の動き
-
web-task:1
×2の状態になっている -
web-task:1
×1が停止するが、全コンテナ個数のうち半分(つまり1台)はweb-task:1
×1が残る -
web-task:1
×1が停止し、代わりにweb-task:2
×1が起動する -
web-task:1
×1、web-task:2
×1の状態になる -
web-task:1
×1が再度停止し、これでweb-task:2
×1のみの状態になる -
web-task:2
×1が起動し、これでweb-task:2
×2の状態になる
全コンテナのうち半分がweb-task:1
のまま残るというのは、Minimum healthy percent
が50
であることに起因します。
ECSノウハウ
もしかするとノウハウというほどではないかもしれませんが、最初理解しにくい箇所があったので書いておきます。
登録済のTask Definitionを変更することができず、リビジョンを変えて再登録する必要がある
なんとなく、update-task-definition的なことができそうな気がしますが、register-task-definitionしか存在しません。
既存のTask Definitionに対して再登録すると、リビジョンがインクリメントされます。
Task Definitionを再登録してもServiceには反映されない
前述の通り、Task Definitionを再登録した場合リビジョンがインクリメントされますが、Serviceはリビジョン付きでTask Definitionを参照しているので、そのままではなにも起きません。
update-serviceで関連付けられたリビジョンを更新する必要があります。
Serviceを削除するにはコンテナを停止しておかなければならない
delete-serviceしようとすると
A client error (InvalidParameterException) occurred when calling the DeleteService operation: The service cannot be stopped while the primary deployment is scaled above 0.
のように怒られてしまうことがあります。これはすでにコンテナが起動している場合は削除できないためです。
じゃ、stop-taskすればいいんだな、と思って停止させた後に削除しようとしても、いつの間にかServiceがコンテナを自動的に起動しておりやっぱりできない、となることが多い。
正しくは、前もってServiceのdesiredCount
を0にしておけばそれ以上コンテナは自動起動されないので、コンテナがすべて停止してから削除すればOK。
注意点、要改善
現時点では、以下の問題が解消されていません。
Redisのデータストレージ場所
redis-task
によってストレージは永続化の設定をしました。これにより、redis-task
コンテナがなんらかの理由で停止しても、次に起動したredis-task
コンテナでもデータ内容は引き継がれるので、消失することはありません。
ただしこれは、同一コンテナインスタンス上で起動したならば、という前提条件が付きます。
ストレージの本体はインスタンス上に存在しているため、別のインスタンスとは共有されないためです。
もしredis-task
コンテナが停止後に再度起動される際、たまたま別のインスタンス上で起動されたならば、データは消失することになります。
これを避けるためには、redis-task
コンテナが起動するインスタンスを固定する必要がありますが、Serviceを用いる場合は起動するインスタンスを指定する方法が見当たりませんでした。
start-taskにはインスタンスを指定する手法があるのですが、これだとコンテナがなんらかの理由で停止しても再開することはできません。
結局、RedisについてはElastiCacheのような外部サービスを使うべきなのかなあ、というのが今の印象です。
Service Discoveryの方法
web-task
からredis-task
に接続するためにはIPアドレスを指定する必要がありますが、このIPアドレスはコンテナ内のものではなく、インスタンス自体のものでなければなりません。
インスタンスが複数存在する場合、宛先となるアドレスは一意には定まらないので動的に決定する必要があるのですが、ECSではこれはサポートされていません。
世の中にはいろんなService Discoveryの手法があると思いますが、Consulが一番利用しやすいように感じました。
Swapメモリが利用できない
前述の通り、ECSでは競合リソースの1つとしてメモリ割当量があるわけですが、制限事項としてSwapメモリを利用することができなくなっています。
ECSは各コンテナインスタンスのメモリ空き容量を把握していますが、これにはSwapメモリぶんは含まれないのです。
さらに、Task Definitionではメモリ割り当て量指定が必須となっており、コンテナ内ではこれを超えたサイズのメモリ確保はできません(当たり前ですが)。
結果として、コンテナインスタンスにSwapがあろうがなかろうが利用可能なメモリ容量は物理メモリのみであり、かつ割り当て指定が必須であるため、物理メモリ以上のメモリ確保は不可能ということになります。
素のDockerならメモリ割り当て量はオプション扱いで、省略時はメモリ確保のサイズ制限はホスト側のSwapメモリを含めたメモリ容量に従います。なので、物理メモリ以上のメモリを確保することができます。
Swapメモリが一切使えないというのは、場合によってはきつい制限になりそうです。
実際に自分の環境では、通常はそこまででもないが、一時的に物理メモリを超えてメモリを確保しようとするプログラムが動作しているサーバがあるので、これをECSで運用することはできないことになります。
これを問題視している人たちはやはりいるようでIssueが作られており、たくさんの+1コメントが付けられていますが、今のところ改善される気配はありません。