Edited at

AWS ECSでサービス運用するために必要(そう)なこと

More than 1 year has passed since last update.


2017/12/08 追記

この記事もだいぶ古くなってしまったので、今となっては必ずしも最新の状況をあらわしていない箇所があります。

というわけで、新しめの情報について書きました!

DELISH KITCHENをECS移行した話(前編)をご参照ください!:grin:


はじめに

若干タイトル詐欺になってしまいますが、結論から言うとまだサービス運用開始には至っていません。 :wink:

本当なら、実際にこういうふうに運用しているよ!という情報を発信できればよかったのですが、まだ経験値が足らないという結論に至りました。


これを書いた経緯



  1. Amazon EC2 Container Service(ECS)でサービス運用をしたいと思ってググってみた

  2. わりとたくさん情報が出てくるは出てくるんだけど、サンプルを動かしてみたとか簡単な紹介のみに終始しているものが多くて、具体的な実装例に言及しているものはあまりなかった(もしくは見つけられなかった)

  3. 実際にサービスで運用できる(くらいの)環境を構築するためにはいろいろと試行錯誤する必要があった

  4. 今後同じように調査する人たちの役に立つかと思い記事にしてみた ←今ココ


記事を読むにあたって

この記事中では、以下の前提知識が必要となります。特に説明は行っていないのでご了承ください。


  • Dockerそのものの知識


  • dockerコマンドの使用方法


  • awscliの使用方法


ECSでなにができるか、できないか


  • ECSとは、Amazon Web Serviceが提供するDocker用のオーケストレーションツールの1つ



  • Dockerコンテナの起動方法やコンテナ間の連携方法を定義できる

  • Dockerコンテナの起動状態を表示、変更できる


    • グラフィカルに可視化してくれるわけではないので、必要であれば自分で可視化ツールを導入しなければならない



  • 古いコンテナから新しいコンテナへの切り替えを、ダウンタイムなしで実現できる


    • いわゆるBlue Green Deployment




ECSで必要となる知識


  • Dockerの知識全般


  • dockerコマンドの使用方法


    • ECSダッシュボードをポチポチやるだけなら不要ですが、実際にはsshログインしてコマンドを叩かないと何が起きているか分からないことがよくありました




ECSで実施する作業フロー


  1. Clusterを定義


    • 0個以上のContainer Instanceで構成される



  2. Container Instanceを起動


    • コンテナが起動するEC2インスタンスを起動する

    • どのClusterに属するかを指定する



  3. Task Definitionを定義


    • 1つ、または複数のコンテナを起動するための定義を記述する

    • 同一Task Definition内のコンテナ間同士はお互いに通信が可能

    • 内容はリビジョンごとに保持されており、変更するとリビジョンが上がる



  4. Serviceを定義


    • Task Definitionの特定リビジョンを指定する

    • 希望する起動個数を指定する

    • 必要ならばELBとの関連付けを行う




構成の設計

以下で構成される、比較的シンプルなサービスを運用することを想定します。

実際にはもう少し複雑な構成である場合が多いと思いますが、下記の延長で考えれば :ok_hand:


サービスの構成


  • ELB


    • apache+app


      • 複数で構成される





  • redis


コンテナの構成



  • app


    • RoRベースのWEBアプリのコンテナ

    • unicornが起動する

    • コンテナ起動高速化のため、事前にassets precompileをしたものをイメージ化する


    • Dockerfileは以下のような感じ


      Dockerfile

      FROM 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は以下のような感じ


      Dockerfile

      FROM 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の仕組みを構築する必要がある




web-task.json

{

"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の箇所




redis-task.json

{

"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 percentMaximum percentという設定があるのですが、これについては後述のBlue Green Deploymentで説明します。

下記ではとりあえず、それぞれ50, 100を指定しておきます。


web-service


  • コンテナ数1で起動

  • ELBとの関連付けを行う


web-service.json

{

"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で起動


redis-service.json

{

"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(or docker ps -a)コマンドを実行して、コンテナの稼働状況を確認する。


    • portMappingsでポートを開放しているなら、ELBを経由しないでインスタンスに対して直接通信したらどうか?


      • 別途セキュリティグループでアクセス権限を付与しておく必要がある






  • 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 percentMaximum percentを理解する必要があります。

が、とりあえずシンプルかつ最小構成で実現するならば以下のように設定すればOKです。

内容について詳しい説明はこちらが参考になります。

下記の例では、2台のコンテナインスタンスにweb-taskが1つずつ起動しているコンテナ構成であるものとしています。

また、デプロイ時には一時的に起動コンテナ数が1つまで下がることを許容するものとします。


事前準備


  1. クラスタ内のコンテナインスタンスは2台にしておく

  2. Service web-serviceMinimum healthy percent50Maximum percent100にする


デプロイ時の作業と流れ

Task Definitionの内容を変更した場合、変更をServiceに反映する必要があります。

なお、Task Definitionの変更を伴わないような運用(Dockerイメージを更新してアップロードしたので、コンテナを再度起動し直せばよい、というようなケース)もあり得ると思いますが、その場合も下記のフローで :ok_hand: かと思います。無駄にTask Definitionを変更することになりますが、フローが単純になるという利点がありますので。


作業内容



  1. web-task:1を再登録すると、リビジョンがインクリメントされweb-task:2が登録される


  2. web-serviceに関連付けられているweb-task:1web-task:2に変更する


ECS上の動き



  1. web-task:1×2の状態になっている


  2. web-task:1×1が停止するが、全コンテナ個数のうち半分(つまり1台)はweb-task:1×1が残る


  3. web-task:1×1が停止し、代わりにweb-task:2×1が起動する


  4. web-task:1×1、web-task:2×1の状態になる


  5. web-task:1×1が再度停止し、これでweb-task:2×1のみの状態になる


  6. web-task:2×1が起動し、これでweb-task:2×2の状態になる

全コンテナのうち半分がweb-task:1のまま残るというのは、Minimum healthy percent50であることに起因します。


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コメントが付けられていますが、今のところ改善される気配はありません。