エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

AWS FargateでElixirのコンテンツ配信システムを動かしてみた (実装編)

こんにちは、エムスリーエンジニアの園田です。

この記事は先日のAWS FargateでElixirのコンテンツ配信システムを本番運用してみた - エムスリーテックブログの続きです。

エムスリーでは医療・ヘルスケアサイト向けのコンテンツ配信システムであるChuoiというサービスを運用しています。先日のポストで、ElasticBeanstalkからFargateに運用を切り替えたことについて書きました。

www.m3tech.blog

今回はその実装編で、実際のコードを見ながら説明します。

まずは構成のおさらいです。

f:id:ryoheisonoda:20180713112117p:plain

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ディレクトリ直下にあるシェルはAWSCodeBuildで実行されるビルドスクリプトで、Dockerイメージには含まれません。*2

コンテンツの取得・描画にTypeScriptを利用しているため、tsディレクトリやwebpack.config.jsがありますが、これらはDockerイメージビルド時にトランスパイルされ、Phoenixによってダイジェストが付与されます。

Dockerfile、エントリポイント、設定ファイル

appコンテナのDockerfile

app コンテナイメージの作成は大きく分けて以下のステップとなります。

  1. 依存ライブラリのインストール
  2. TypeScript のトランスパイルと minify, gzip 圧縮
  3. 静的コンテンツに対してダイジェスト付与
  4. 配布可能なリリースビルドの生成
  5. リリースビルドを含むイメージの作成

以下が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)が生成されます。distilleryErlangのランタイムを含めてビルドできるため、このリリースバイナリの実行イメージには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 StoreSecret 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を構築するまでの手順はかなり多く、大まかに以下の通りです。

  1. ECRリポジトリを作成
  2. ECRリポジトリにイメージをpush
  3. ECSサービスとタスク用のIAMロールを作成
  4. タスク定義を作成
  5. ECSサービス用のセキュリティグループを作成
  6. ECSサービス用のALBとターゲットグループなどを作成
  7. 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)

    • 外部から 80/tcp, 443/tcp へのアクセス許可

上記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で、スケーリングポリシーは設定しません。
ポイントは以下です。

  1. 起動タイプでFARGATEを選択、タスク定義を選択してサービス名を入力、タスク数には1を入力
  2. 作成済みの VPC と Private サブネット、作成したセキュリティグループを選択し、パブリックIP割り当ては Private サブネットなのでDISABLEDを選択
  3. 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等)でも同様です。

  1. ElasticBeanstalkのRedisコネクション数を半分程度にする (縮退運転)
  2. FargateタスクのRedisコネクション数を本来の半分程度にする
  3. Fargateサービスを作成する
  4. Route53のALIASレコードをElasticBeanstalkからFargateのALBに変更する
  5. ElasticBeanstalkのRedisコネクション数をさらに少なくする
  6. 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 やカジュアル面談にお越しください。 お問い合わせは以下フォームより、お待ちしております!!

jobs.m3.com

*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 になっていたかはわからずじまいでした