こんにちは、エムスリーエンジニアの園田です。
この記事は先日のAWS FargateでElixirのコンテンツ配信システムを本番運用してみた - エムスリーテックブログの続きです。
エムスリーでは医療・ヘルスケアサイト向けのコンテンツ配信システムであるChuoi
というサービスを運用しています。先日のポストで、ElasticBeanstalk
からFargate
に運用を切り替えたことについて書きました。
今回はその実装編で、実際のコードを見ながら説明します。
まずは構成のおさらいです。
Fargate化のためにやったこと
AWS Fargateで運用するために実際にやった作業は大まかに以下の通りです。
- Elixir/PhoenixアプリのDocker化
- Docker化したアプリのFargate動作確認
- 社内GitlabからのCI/CDパイプライン構築
- Terraformテンプレート作成
今回はこのうち Fargate での動作確認までを、弊社事例をサンプルに説明したいと思います。 なお、 Fargate より Elixir の内容が多めです。 Elixir なんて使ってないぜ、という方は次回からご覧いただいて大丈夫です。
デプロイパイプラインの構築とTerraformテンプレートについてはおって投稿します。乞うご期待。
2018/08/01 追記 デプロイパイプラインの構築記事を書きました。 www.m3tech.blog
Elixir/PhoenixアプリのDocker化
Fargateで動かすためには当然のことながら、Dockerイメージを作成する必要があります。
元々の構成では、Elixirソースを直接EC2に配置し、ElasticBeanstalkのデプロイフックでコンパイルを行い、Phoenixの起動コマンドにmix phx.server
というコマンドで起動していました。*1
しかし、これだとDocker化するにあたりイメージサイズが肥大化してしまうため、リリースビルドによって生成された配布可能なバイナリを含むDockerイメージを作成しました。
なお、このサービスではクリックカウントなど業務メトリクスをログファイルに出力してfluentd
でデータレイクに送信しているため、Dockerボリューム上にログを吐き出し、同一タスク内のfluentd
コンテナでログを配信可能にする必要がありました。
また、静的コンテンツをnginxのgzip_static
で配信したかったので、nginxのコンテナも作成しました。
最終的に作成したDockerfileはアプリ(app)、ウェブ(web)、ログ配信(log)の3つとなりました。
Dockerイメージの作成に関連するディレクトリ構成は以下のようになっています。
アプリケーションディレクトリ構成
. ├── config/ │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ ├── qa.exs │ └── test.exs ├── dockerfiles/ │ ├── app/ │ │ ├── Dockerfile │ │ └── entrypoint.sh │ ├── log/ │ │ ├── Dockerfile │ │ ├── entrypoint.sh │ │ └── fluent.conf │ ├── web/ │ │ ├── Dockerfile │ │ ├── entrypoint.sh │ │ └── nginx.conf │ ├── archive-and-upload-src.sh │ ├── build-docker-images.sh │ └── push-to-ecr.sh ├── lib/ ├── priv/static/ ├── rel/config.exs ├── ts/ ├── buildspec.yml ├── mix.exs ├── mix.lock ├── package.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock
dockerfilesディレクトリに作成するイメージごとのディレクトリを作成し、Dockerfileやentrypoint、設定ファイル等を格納しています。
dockerfilesディレクトリ直下にあるシェルはAWSのCodeBuild
で実行されるビルドスクリプトで、Dockerイメージには含まれません。*2
コンテンツの取得・描画にTypeScript
を利用しているため、ts
ディレクトリやwebpack.config.js
がありますが、これらはDockerイメージビルド時にトランスパイルされ、Phoenixによってダイジェストが付与されます。
Dockerfile、エントリポイント、設定ファイル
appコンテナのDockerfile
app コンテナイメージの作成は大きく分けて以下のステップとなります。
- 依存ライブラリのインストール
- TypeScript のトランスパイルと minify, gzip 圧縮
- 静的コンテンツに対してダイジェスト付与
- 配布可能なリリースビルドの生成
- リリースビルドを含むイメージの作成
以下がElixir/PhoenixアプリのDockerfile(dockerfiles/app/Dockerfile
)です。
# -------------------------------------- # # Multi Stage Build - Build Stage # -------------------------------------- # FROM elixir:1.6.0-alpine AS builder ARG MIX_ENV=dev # ・・・(1) ARG SECRET_KEY_BASE="" # ・・・(2) ARG LOG_DIR=/var/log/chuoi # mix.exs と mix.lock を環境にコピーして依存パッケージを取得/コンパイル COPY mix.* /work/ WORKDIR /work RUN mix do local.hex --force, \ local.rebar --force, \ deps.get --only=${MIX_ENV}, \ compile # Chuoi では brunch を使わず yarn を使っている COPY package.json yarn.lock /work/ RUN apk add --no-cache yarn \ && yanr install --production --non-interactive # TypeScript のコンパイルと、ダイジェスト付与 COPY ts /work/ts COPY priv/static /work/priv/static COPY config /work/config COPY webpack.config.js tsconfig.json /work/ RUN yarn run webpack \ && MIX_ENV=${MIX_ENV} mix phx.digest # ・・・ (3) # 残りのソースをコピーしてリリースアーカイブ(tar.gz)をビルド ・・・ (4) COPY . /work RUN SECRET_KEY_BASE=${SECRET_KEY_BASE} LOG_DIR=${LOG_DIR} \ mix release --env=${MIX_ENV} \ && mv _build/${MIX_ENV}/rel/chuoi/releases/*/chuoi.tar.gz / # -------------------------------------- # # Multi Stage Build - Runtime Stage # -------------------------------------- # FROM alpine:3.7 # ・・・ (5) ENV APP_ROOT=/var/app EXPOSE 4000 # tzdata, bash, openssl をインストール ・・・ (6) RUN apk add --no-cache tzdata bash openssl \ && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime # アプリ起動スクリプト(内容は後述) ・・・ (7) COPY dockerfiles/chuoi/entrypoint.sh /entrypoint.sh CMD [ "/bin/bash", "/entrypoint.sh" ] # 前段で作成したイメージからビルド済みのパッケージを取得 COPY --from=builder /chuoi.tar.gz /chuoi.tar.gz # パッケージを展開 # 静的ファイルディレクトリにシンボリックリンクで別名をつける ・・・ (7) RUN mkdir -p $APP_ROOT \ && tar -zxf /chuoi.tar.gz -C $APP_ROOT \ && rm -f /chuoi.tar.gz \ && ln -s $APP_ROOT/lib/chuoi-*/priv/static /web-static WORKDIR $APP_ROOT
最終的に生成されるDockerイメージを小さくするため、マルチステージビルドによりビルドイメージとランタイムイメージを分けています。*3
各ステップのポイントは以下の通りです。
(1) リリースビルドの生成には Phoenix 公式にもある
distillery
を利用しています。distillery
を利用した場合、config.exs
の設定情報は 静的にコンパイルされる ため、設定値に環境変数を利用している場合、たとえば MIX_ENV*4などはビルド時に与える必要があります。実行時には MIX_ENV を必要としません。実際にはこの環境変数はCodeBuild
のプロジェクト環境変数として設定されていて、docker build --build-args
コマンドでビルドプロセスに注入されます。(2) SECRET_KEY_BASE も MIX_ENV 同様、
CodeBuild
からパラメータとして受け取りますが、こちらはCodeBuild
に直接設定されているわけではなく、Parameter Store
に暗号化されて格納されています。CodeBuild
ではParameter Store
のパラメータを環境変数として利用可能な機能が備わっている ため、シークレット情報を安全にDockerイメージに埋め込むことが可能です。(3) TypeScriptをトランスパイルして生成されたjsファイルにPhoenixの機能でダイジェストを付与しています。ここでは、WebpackによるMinifyとGzip圧縮も行っており、圧縮された静的ファイルをappコンテナを介さずNginxの
gzip_static
で直接配信できる ようにしています。リリースビルド時に生成されるアーカイブファイルに含めるため、生成コンテンツはpriv/static
ディレクトリに出力しています。(4)
distillery
を使ったリリースバイナリのビルドです。実際にはバイナリや静的ファイル、ライブラリおよび起動スクリプトが同梱されたtarボール(.tar.gz)が生成されます。distillery
は Erlangのランタイムを含めてビルドできるため、このリリースバイナリの実行イメージにはErlangをインストールする必要がなくなります 。これのおかげで、ランタイムイメージは非常にコンパクトなサイズにすることが可能です。*
指定しているパスは実際にはバージョン番号が入りますが、Docker上でビルドする場合は常にシングルバージョンとなるため*
で問題ありません。後でコピーしやすいようにルートパスに移動しています。(5) ランタイムイメージのベースイメージは、ビルドイメージのベースイメージと同じイメージを利用します。ここでは、
elixir:1.6.0-alpine
のベースイメージerlang:21-alpine
のさらにベースとなるalpine:3.7
を指定しています。(6)
distillery
で生成される起動スクリプトはbash
で実装されているため、ランタイムイメージにbash
をインストールする必要があります。openssl
はないとエラーになったので入れました。(7) アプリ起動時にGzip圧縮済みの静的ファイルをNginxのドキュメントルートにコピーします。そのため、静的ファイルのディレクトリにはシンボリックリンクでわかりやすい別名を付けています。Nginxのドキュメントルートは、読み取り専用のDockerボリュームとしてWebコンテナ(Nginxコンテナ)にマウントされます。*5
出来上がったイメージは40MBで、だいぶコンパクトにできました。
なお、webpack
ビルドもステージを分けようかと思いましたが、pullするイメージサイズが増えてかえって遅くなったのでやめました。
SECRET_KEY_BASE など環境ごとに異なるパラメータをイメージに埋め込むことはベストプラクティスではないです。 実行時の環境変数として渡せるのであればそれに越したことはないですが、 distillery を利用するため仕方なくそうしているのと、 セッションに機密性がないシステムであるため、このシステムに限ってはイメージに埋め込んでいます。
機密度の高いシークレット情報はイメージには埋め込まず、実行時にParameter Store
やSecret Manager
から取得できるようにした方が良いでしょう。 Railsであれば以前紹介した拙作のgemが利用できます。 また、ECRの利用者が限られていて、イメージ利用に関する監査などが行われているのであれば、イメージに埋め込んでも問題ないはずです。
appコンテナのentrypoint
# このディレクトリを app コンテナに書き込み可能でマウントしておく # また、このディレクトリは web コンテナにも読み取り専用でマウントされる WEB_DOC_DIR=/var/app/public cp -r /web-static/* $WEB_DOC_DIR/ # distillery によって生成された起動スクリプト実行 exec bin/chuoi foreground
web コンテナのDockerfile
web コンテナのDockerファイルはオフィシャルのnginxを利用するため、かなりシンプルです。
FROM nginx:alpine # ヘルスチェックコマンド用に curl をインストール RUN apk add --no-cache tzdata curl \ && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime COPY nginx.conf /etc/nginx/nginx.conf COPY entrypoint.sh /entrypoint.sh # CMD は entrypoint.sh で上書き CMD [ "/bin/sh", "/entrypoint.sh" ]
web コンテナの nginx.conf と entrypoint.sh
nginx.conf はポイントだけ記載します。
upstream appserver { server %APP_SERVER%:4000; # nginx起動時に環境変数の値で置換する }
Fargate を利用する場合、コンテナ間通信の宛先は必ず localhost
になります。
ただしそのままだとローカル環境や通常のDockerホストでの開発やテストができないため、APP_SERVER
という環境変数に app コンテナのホスト名を入れることで
開発時やテスト時にも通信可能なようにしました。*6
そのため、entrypoint.sh では以下のように nginx.conf を書き換えてから nginx を起動しています。
# デフォルトは localhost # 実際には 127.0.0.1 を指定すること、詳しくは後述 [ -z "$APP_SERVER" ] && APP_SERVER=localhost # nginx.conf の該当設定箇所を文字列置換 sed "s|%APP_SERVER%|$APP_SERVER|g" -i /etc/nginx/nginx.conf /usr/sbin/nginx -g "daemon off;"
log コンテナのDockerfile
log コンテナもほぼ公式のイメージそのままでシンプルです。
FROM fluent/fluentd:v1.2 RUN apk add --no-cache tzdata \ && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime \ && gem install fluent-plugin-s3 -v 1.0.0 --no-document COPY fluent.conf /fluentd/etc/fluent.conf
fluentd の設定ファイルで注意しなければいけないのは、コンテナなので終了時にバッファをフラッシュする必要があることです。
ECS ではコンテナの停止時にSIGTERM
が送出されますが、fluentd の file バッファではSIGTERM
で flush されないので、
file バッファを利用している場合はflush_at_shutdown true
を設定します。
ここまででFargate上で動かすためのファイルが揃いました。
CodePipeline
を構築する前に、実際にFargate上で動作確認を行います。
Docker化したアプリをFargateで動かす
Fargateを構築するまでの手順はかなり多く、大まかに以下の通りです。
- ECRリポジトリを作成
- ECRリポジトリにイメージをpush
- ECSサービスとタスク用のIAMロールを作成
- タスク定義を作成
- ECSサービス用のセキュリティグループを作成
- ECSサービス用のALBとターゲットグループなどを作成
- ECSクラスタとFargateサービスを作成
以降の説明において、マネジメントコンソールのスクリーンショットは、面倒なので どうせすぐに変わるので、あえて貼っていません。
ECR リポジトリの作成とファーストイメージの push
事前にAWSの開発環境に ECR のリポジトリを 3 つ (app/web/log) 作成しておきます。
ここではまだ Terraform は使わず、マネジメントコンソールで作成しました。
ここはCLIで作成したほうが楽なので、コマンドも載せておきます。
for repo in app web log; do aws ecr create-repository --repository-name $repo done
Dockerfileをローカルでビルド
docker build -t app:latest \ --build-args SECRET_KEY_BASE="xxxx..." \ --build-args MIX_ENV=qa \ -f dockerfiles/app/Dockerfile ./ docker build -t web:latest ./dockerfiles/web docker build -t log:latest ./dockerfiles/log
Dockerイメージを ECR にプッシュ
AWS_ACCOUNT_ID="xxxxxx" AWS_REGION=ap-northeast-1 ECR_REPO_URI=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com # ECR にログイン $(aws ecr get-login --region ${AWS_REGION} --no-include-email) # 各イメージを各リポジトリに push for repo in app web log; do docker tag "${repo}:latest" "${ECR_REPO_URI}/${repo}:latest" docker push "${ECR_REPO_URI}/${repo}:latest" done
これでECRにファーストイメージが latest
というタグで登録されました。(ややこしいな)
なお、予めイメージを登録しておかないと、ECSサービスを作成した際にタスクが起動せずにエラーになります。 エラーが出てもあとから登録すれば勝手に起動してくれるので実害はないですが、エラーが出るのが気持ち悪いので先にイメージを登録しておく手順としました。
Fargate(ECSサービスとタスク)用のIAMロールを作成
Fargateを実行するために以下の2つのIAMロールを作成します。
- ECSタスク実行ロール (ecsTaskExecutionRole)
- ECSタスクロール
ECSタスク実行ロールは、ECSサービスに割り当てられるIAMロールで、ECSタスクを実行するための認可を与えます。こちらはマネージドポリシー(AmazonECSTaskExecutionRolePolicy
)が用意されているので、IAMロールを作成してポリシーをアタッチすればOKです。IAMロールのAssumeRolePolicyにはecs-tasks.amazonaws.com
の信頼関係を設定しておきます。*7
ECSタスクロールは、実行されたタスクのコンテナ内部で利用するIAMロールです。log コンテナで利用する S3 への書き込み権限などを付与しておきます。こちらもAssumeRolePolicyはecs-tasks.amazonaws.com
となります。
タスク定義 (TaskDefinition) の作成
AWSのマネジメントコンソールで3つのコンテナを起動するタスク定義を作成します。
ここでのポイントはボリュームの定義です。以下の3つのボリュームを定義しました。
- アプリのログを格納するボリューム
app-log
- gzip圧縮された静的ファイルのボリューム
app-static
- fluentdのbuffer/posファイルのボリューム
log-tmp
静的ファイルはアプリのイメージに含まれているので、アプリの起動コマンドの中でボリュームにコピーするのは前述の通りです。web コンテナではそれを読み取り専用でマウントします。 log コンテナが再起動した際にログを重複送信しないように、bufferファイルとpositionファイルはDockerボリュームに格納します。*8
マネジメントコンソールでタスク定義を作成しますが、最終的に出来上がった TaskDefinition は以下のようになります。(コメントが書けるようにyamlにしていますが、実際にはjsonです)
# Fargate の場合は以下2行は固定 requiresCompatibilities: [ FARGATE ] networkMode: awsvpc # ECSタスク実行ロール executionRoleArn: arn:aws:iam::999999999999:role/ecs-task-execution-role # 実行されたタスクのコンテナ内部で利用するIAMロール taskRoleArn: arn:aws:iam::999999999999:role/task-role # CPUとメモリ とりあえず最低限を確保 # Fargate では CPU とメモリの組み合わせに制限があるので注意 cpu: '256' memory: '512' # Dockerボリュームの定義 # Fargate では sourcePath の指定をしていはいけない volumes: - { name: app-log, host: { sourcePath: } } - { name: app-static, host: { sourcePath: } } - { name: log-tmp, host: { sourcePath: } } containerDefinitions: # app コンテナの定義 - # コンテナ名 # CodePipelineでのデプロイ時にこのコンテナ名を参照するため、わかりやすい名前にする name: app # ECRに登録されているイメージのURIを指定 # CodePipeline によるデプロイでは、ここだけが書き換わる image: 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest # essential を true にすると、このコンテナがエラーとなった場合にタスク全体が失敗する essential: true # ポート指定 `docker run -p`のパラメータ # コンテナ間通信が必要な場合は必ず定義する # Fargateの場合、hostPort と containerPort は必ず同じでなければならない portMappings: [ { hostPort: 4000, containerPort: 4000, protocol: tcp } ] # コンテナに直接渡される環境変数 `docker run -e`のパラメータ。 # DB_PASSWORD などのシークレット値はコンテナ内のプロセスから取得するようにし、ここには埋め込まない。 environment: - { name: REDIS_HOST, value: redis.xxxxxx.ng.0001.apne1.cache.amazonaws.com } - { name: REDIS_PORT, value: '6379' } # Dockerボリュームのマウント指定 `docker run -v`のパラメータ。 mountPoints: # 静的コンテンツ格納ボリューム: web コンテナでは読み取り専用マウントする - { containerPath: /var/app/public, sourceVolume: app-static, readOnly: } # アプリログ格納ボリューム: log コンテナでは読み取り専用マウントする - { containerPath: /var/log/chuoi, sourceVolume: app-log, readOnly: } # Fargateではawslogsのみをサポート # CloudWatch Logs にコンテナの標準出力と標準エラー出力を配信する logConfiguration: logDriver: awslogs options: awslogs-group: /chuoi/ecs/tasks/app awslogs-region: ap-northeast-1 awslogs-stream-prefix: qa # ulimit ulimits: - { name: nofile, softLimit: 65536, hardLimit: 65536 } # web コンテナの定義 - name: web image: 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/web:latest essential: true portMappings: [ { hostPort: 80, containerPort: 80, protocol: tcp } ] mountPoints: # 静的コンテンツ格納ボリューム: 読み取り専用マウントする # app コンテナが起動時にファイルを書き込む - { containerPath: /var/app/public, sourceVolume: app-static, readOnly: true } logConfiguration: logDriver: awslogs options: awslogs-group: /chuoi/ecs/tasks/web awslogs-region: ap-northeast-1 awslogs-stream-prefix: qa ulimits: - { name: nofile, softLimit: 65536, hardLimit: 65536 } # log コンテナの定義 - name: log image: 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/log:latest essential: true mountPoints: # アプリログ格納ボリューム: 読み取り専用でマウントする - { containerPath: /var/log/chuoi, sourceVolume: app-log, readOnly: true } # fluentd一時ファイル格納ボリューム: 書き込み可能でマウントする - { containerPath: /fluentd/log, sourceVolume: log-tmp, readOnly: } environment: - { name: S3_REGION, value: ap-northeast-1 } - { name: S3_BUCKET, value: hogehoge-log-bucket } logConfiguration: logDriver: awslogs options: awslogs-group: /chuoi/ecs/tasks/log awslogs-region: ap-northeast-1 awslogs-stream-prefix: qa ulimits: - { name: nofile, softLimit: 65536, hardLimit: 65536 }
セキュリティグループの作成
FargateをWeb利用する上で必要なセキュリティグループは以下の2つです。
- ECSサービスのセキュリティグループ
- ALBのセキュリティグループ
実現したいことは以下の通りです。
- app コンテナから ElastiCache Redis の 6379/tcp に対するアクセス許可
- ALB から web コンテナの 80/tcp に対するアクセス許可
- インターネットから ALB の 80/tcp, 443/tcp に対するアクセス許可
上記を実現するためのセキュリティグループを実装します。 なお、ECSではサービスに対してセキュリティグループを割り当てるため、コンテナごとにセキュリティグループを分けることはできません。
今回作成したセキュリティグループ
今回作成したセキュリティグループを例に説明しますが、実現方法は1つではないのであくまでも一例としてとらえてください。
ElastiCache Redis のセキュリティグループはすでに作成済みで、以下のルールとなっています。
- Redisのセキュリティグループ (sg-redis)
- 自身のセキュリティグループから 6379/tcp へのアクセス許可
それとは別に、以下のセキュリティグループを作成しました。
ECSサービスのセキュリティグループ (sg-ecs-service)
- 自身のセキュリティグループから 80/tcp へのアクセス許可
ALBのセキュリティグループ (sg-ecs-alb)
上記3つのセキュリティグループに対し、ECSサービスとALBを以下のように所属させます。
- ECSサービス => sg-ecs-service, sg-redis
- ALB => sg-ecs-alb, sg-ecs-service
余談ですが、私はセキュリティグループを作成するときは自身からのアクセス許可のみで作成し、そこにリソースを追加する形式で実装します。
こうすることで、セキュリティグループが他のセキュリティグループやリソースに依存しなくなり、Terraformで作成する際にモジュール化しやすくなります。
ネットではよくセキュリティグループ間に対して通信許可を与えるパターンを目にしますが、これはTerraform利用においてはアンチパターンです。
ECSサービス用のALBとターゲットグループを作成
こちらの手順は通常のALB作成手順のため割愛しますが、ターゲットグループはip
をターゲットとした空のターゲットグループを作成します。
セキュリティグループは前述のとおりに割り当てます。
ALBを利用せずにECSサービスをPublicサブネットに配置する場合はこの手順は不要です。 動作確認するだけであればALBを使わずにIP直アクセスでも事足りることが多いでしょう。
ECSクラスタとFargateサービスを作成
必要なリソースができたら ECS クラスタを作成し、そのクラスタに Fargate サービスを作成します。
まだ動作確認だけなので、タスク数は1で、スケーリングポリシーは設定しません。
ポイントは以下です。
- 起動タイプで
FARGATE
を選択、タスク定義を選択してサービス名を入力、タスク数には1
を入力 - 作成済みの VPC と Private サブネット、作成したセキュリティグループを選択し、パブリックIP割り当ては Private サブネットなので
DISABLED
を選択 Application Load Balancer
を選択し、作成した ALB とターゲットグループを選択、ターゲットに web コンテナを選択
ECSサービスを作成すると、自動的にタスクが開始されます。 開始されたタスク内の各コンテナログは CloudWatch Logs に配信されているのでそちらで動作確認を行います。
Fargate上で起動した際に発生したエラー
Redisにつながらない
これはdistillery
のドキュメントをよく読んでいなかったために発生したエラーです。
config.exs
の設定がリリースビルド時に静的にコンパイルされますが、それを知らなかったため発生しました。
もともとのconfig.exs
では以下のようにRedisのホスト名やポートを環境変数から取得していたのですが、
これらはDockerイメージのビルド時に評価されるため、localhost
に対して接続しようとしてエラーになっていました。
redis_host = System.get_env("REDIS_HOST") || "localhost" redis_port = (System.get_env("REDIS_PORT") || "6379") |> String.to_integer() redis_connection_size = (System.get_env("REDIS_CONNECTION_SIZE") || "1000") |> String.to_integer() config :redix, args: [ host: redis_host, port: redis_port, database: 0 ], max_connection: redis_connection_size
こちらは実行時(コネクション接続時)に環境変数から取得するように関数化しました。 以下はRedisコネクションプール作成時の実装(簡易版)です。
defp get_env_of_integer(name, default_value) do (System.get_env(name) || default_value) |> String.to_integer() end def init([]) do redix_args = [ host: System.get_env("REDIS_HOST"), port: get_env_of_integer("REDIS_PORT", "6379"), database: 0 ] pool_option = [ name: {:local, :redis_pool}, worker_module: Redix, size: get_env_of_integer("REDIS_CONNECTION_SIZE", "1000") ] children = [ :poolboy.child_spec(:redis_pool, pool_option, redix_args) ] supervise(children, strategy: :one_for_one) end
log コンテナがDockerボリュームにファイルを書き込めない
log (fluentd) コンテナが buffer ファイルの書き込みに失敗していました。
fluentd の実行ユーザをroot
に変更したら書き込めるようになりました。
公式fluentイメージの entrypoint.sh
を以下のように修正して、DockerイメージにCOPYします。
- exec su-exec fluent "$@" + exec su-exec root "$@"
COPY entrypoint.sh /bin/entrypoint.sh
ここらへんはFargateというよりDockerの制約ですね。
nginx がまれに 502 エラーになる
これは運用開始後に気づいたのですが、およそ1時間に1回くらいの頻度で 502 エラーが発生していました。
ALB -> web (nginx) -> app (phoenix) というフローで、 app に到達せずに web で 502 を ALB に返していました。
詳しくは調査していませんが、NginxでのProxy先ホストをlocalhost
としていたため、127.0.0.1
だけでなく[::1]
にProxyしようとしてエラーになっているようでした。
2018/07/13 15:05:51 [crit] 7#7: *3 connect() to [::1]:4000 failed (99: Address not available) while connecting to upstream, client: 10.0.0.116, server: _, request: "GET /system/healthcheck HTTP/1.1", upstream: "http://[::1]:4000/system/healthcheck", host: "10.0.7.111"
参考: qiita.com
なので、明示的に127.0.0.1
にProxyするように web コンテナの entrypoint.sh を以下のように書き換えました。
- [ -z "$APP_SERVER" ] && APP_SERVER=localhost + [ -z "$APP_SERVER" ] && APP_SERVER=127.0.0.1
これで 502 のエラーが発生しなくなり、スループットも向上しました。*9
Redisコネクション数が急増してRedisが詰まる
これは単純なオペミスですが、ElasticBeanstalkの旧環境が起動したままFargateを起動したため、一時的にRedisのコネクション数が倍増してRedisが詰まりました。
なので、正しい手順として以下のようにデプロイする必要があります。Redisだけでなく、DB(RDS等)でも同様です。
- ElasticBeanstalkのRedisコネクション数を半分程度にする (縮退運転)
- FargateタスクのRedisコネクション数を本来の半分程度にする
- Fargateサービスを作成する
- Route53のALIASレコードをElasticBeanstalkからFargateのALBに変更する
- ElasticBeanstalkのRedisコネクション数をさらに少なくする
- Fargateのコネクション数を増やす
なお、当然のことながらRedisコネクション数を環境変数等で制御できるようになっている前提です。
一時的に縮退運転となるため、トラフィックが安定している時間帯に行います。もともとRedisのコネクション数に余裕があればこの対応は不要です。
Fargate云々ではなく、一般的なシステム移行では当たり前のことですね。凡ミスでした。
まとめ
- Dockerfile のマルチステージビルドで Elixir の実行コンテナイメージを軽量化できる。
- Elixir のリリースビルドを生成する場合、設定値の環境変数をビルド時に渡すか、実行時評価のための関数に変更する。
- 同一タスク内のコンテナ間通信では
127.0.0.1
を利用する。 - コンテナ停止時のシグナルは SIGTERM なので、必要に応じてトラップする。
- Fargate ではDockerボリュームもタスク内だけの揮発性。ファイルの永続化を行う場合は S3 などを利用する。
- セキュリティグループを作成するときは自身からのアクセス許可のみで作成し、そこにリソースを追加する。
- 自動生成されるリソースも、自前で作成したほうがTerraformフレンドリー。
今回はここまでです。次回は AWS Fargate へのデプロイパイプラインの構築について書きたいと思います。
2018/08/01 追記 デプロイパイプラインの構築記事を書きました。 www.m3tech.blog
エンジニア積極募集!!
エムスリーではAWSの新サービスを試したいエンジニアを募集しています!!エムスリーならわずか1週間で試せちゃいます!! AWS に限らず、Firebase や GCP なども積極的に利活用しています。興味が湧いた方はぜひ Tech Talk やカジュアル面談にお越しください。 お問い合わせは以下フォームより、お待ちしております!!
*1:サーバ上でコンパイルを行うElasticBeanstalkのお作法に則ったのと、起動時のトラブルや環境のトラブルを少なくするため
*2:実際には buildspec.yml でそれらのファイルを実行しています
*3:最終的なイメージサイズが小さくなればいいため、ビルドイメージはあえてステップを細かく刻んでいます
*4:MIX_ENVはElixirプロジェクトの環境を表す変数で、RAILS_ENVのElixir版みたいなものです。
*5:こちらもDockerイメージに含めてしまえばよかったのですが、ちょっとイメージの管理が煩雑になりそうだったのでやめました
*6:同じやりかたで、worker数なども環境ごとに変更できるようになります。
*7:マネジメントコンソールでECSサービスを作成した場合、ecsTaskExecutionRole というロールが自動生成されますが、Terraformフレンドリーではないので自前で作成します
*8:どれか1つでもコンテナが落ちたらタスク全体が失敗するように設定するためこの設定は不要かもしれませんが、このシステムではログが重要なため、念のためこうしています。
*9:ただ、 [::1] で接続できなければ 127.0.0.1 にフォールバックするはずなので、なぜ1時間に1回だけ 502 になっていたかはわからずじまいでした