TOYOKUMO Tech Blog

トヨクモ株式会社の開発本部のブログです。

Symfony アプリを Elastic Beanstalk Multi-Container Docker で運用する

北川です。

Symfony アプリを AWS Elastic Beanstalk Multi-Container Docker を用いて構築・運用する事例の紹介です。

弊社が提供するフォームブリッジというサービスは実際にこの記事の内容に基づき作られています。

なおベストプラクティスを模索中だったり、生々しい妥協案も出てくるので暖かい目で読んでいただきますと幸いです。

構成

  • Nginx
  • Symfony, Composer
  • Vue, Yarn

フロントエンドとバックエンドがある程度分離されたSPA

Symfonyアプリでの事前準備

EB での運用を快適にするためにはアプリ側にもいくつか要件があります。

環境変数の設定

アプリの振る舞いを変える方法を環境変数のみにしておきます。 Symfony では parameters.yaml を配置して振る舞いを変えますが、parameters.yaml から環境変数を読み込ませる機能がありますのでこれを利用します。Symfony 3系でも使えます。

# parameters.yaml
parameters:
    database_host: '%env(DATABASE_HOST)%'
    database_port: '%env(int:DATABASE_PORT)%'
    database_name: '%env(DATABASE_NAME)%'
    database_user: '%env(DATABASE_USER)%'
    database_password: '%env(DATABASE_PASSWORD)%'

注意点として、環境変数にセットした値がデフォルトでは string でパースされるのでポート番号など型を指定したい場合は '%env(int:DATABASE_PORT)% のように記述する必要があります。 環境変数は EB の環境プロパティでセットします。

ログを標準出力に出す

必要なログを標準出力に出し CloudWatch にストリーミングするようしておくと ssh で接続してファイルを取得といったことをする必要が無くなります(後述)。 また Docker でコンテナ内のログファイルを永続化するのは面倒です。 このためアプリケーションログはファイルでは無く標準出力に出しておくと便利です。

monolog を使用した例です。

# config.yaml
monolog:
    handlers:
        main:
            type: fingers_crossed
            action_level: error
            handler: nested
        nested:
            type: stream
            path: "php://stdout"
            level: debug
        console:
            type: console

CI やテストがやかましくなるので test env ではファイルに出力するようにしておくと良いでしょう。

ジョブのAPI化

バッチサーバーなどを EB で運用する場合、Worker環境を使うことになります。 詳細はドキュメントを読めば分かりますが、Woker環境の仕組み上、HTTPリクエストを受けジョブを実行する流れになります。 逆に言えば、Web環境とまったく同じ構成でWorker環境を作ることもできます。FormBridge でも Web と Worker は同じ構成です。

例えば、バッチサーバーで以下の cron を実行している場合、Symfony の Controller にアクションを追加し処理を移行すれば良いです。スケジューリングは cron.yaml という仕組みが用意されています(後述)。

0 9 * * * php /var/www/html/app/bin/console app:hoge

非同期ジョブなどはWorker環境では以下のように処理します。

  • SQS にメッセージを送信
  • aws-sqsd がメッセージを取得し指定のpathにHTTPリクエスト
  • ジョブを実行

この場合、SQS にメッセージを送信する部分とジョブのAPI化の2箇所を対応する必要があります。

Docker のビルド

ECR

イメージのリポジトリには ECR を利用しています。タグ運用をする場合はイメージタグの変更可能性を変更不可にしておくと事故が減ります。

PHP Dockerfile

FROM composer:version as composer
WORKDIR /app

COPY . /app/

RUN composer install --no-dev

FROM php:7.2-fpm
WORKDIR /app

RUN apt-get update && apt-get install -y ...
RUN docker-php-ext-install ...
RUN pecl install ...

ADD ./path/to/ini/*.ini $PHP_INI_DIR/conf.d/

COPY . /app/

COPY --from=composer /app/vendor /app/vendor

RUN cp app/config/parameters_env.yaml app/config/parameters.yaml
RUN chown -R www-data:www-data /app

ところどころぼかしていますが、これが Symfony アプリのイメージビルドに使用している Dockerfile です。ソースコードを丸ごとコピーしているように見えますが .dockerignore で不要なディレクトリをコピーしないようにしています。parameters_env.yaml は、先ほどの環境変数を読み込むようにした parameters.yaml のことです。最後にデフォルトの PHP-FPM 実行ユーザーの www-data にパーミッションを変更しています。

Nginx Dockerfile

FROM node:version as node
WORKDIR /app

ADD ./package.json /app/
ADD ./yarn.lock /app/

RUN yarn install
RUN yarn build

FROM nginx:version

ADD ./path/to/nginx.conf /etc/nginx/nginx.conf
ADD ./path/to/default.conf /etc/nginx/conf.d/default.conf

COPY ./document-root /app/document-root
COPY --from=node /app/document-root/dist /app/document-root/dist

ところどころぼかしていますが、これが Nginx での最も単純な Dockerfile です。Nginx には静的コンテンツを管理している document-root と webpack でのビルドの成果物のみを配置します。

ところが、使用しているライブラリによってはこう単純にはいかず、実際には以下のような Dockerfile を使っています。

FROM composer:version as composer
WORKDIR /app

ADD . /app

RUN composer install

FROM node:version as node
WORKDIR /app

ADD . /app/

RUN yarn install

COPY --from=composer /app/document-root/js /app/document-root/js

RUN yarn build

FROM nginx:version

ADD ./path/to/nginx.conf /etc/nginx/nginx.conf
ADD ./path/to/default.conf /etc/nginx/conf.d/default.conf

ADD ./document-root /app/document-root
COPY --from=composer /app/vendor /app/vendor
COPY --from=composer /app/document-root/bundles /app/document-root/bundles
COPY --from=node /app/document-root/dist /app/document-root/dist

node のマルチステージビルドでは、 composer でインストールしたライブラリに同梱された js/css が document-root/js に落ち、その中から必要な js/css を yarn build の成果物に含めるようにしています。どんな状況だよという感じですね。

下の方では composer でインストールしたライブラリに同梱された js/css が document-root/bundles に落ちてくるパターンに対応しています。document-root/bundles からさらにシンボリックリンクが vendor 以下に貼られる場合もあるので vendor もイメージに含めています。vendor は js/css のみを目当てに配置しているということです。つらい。

Elastic Beanstalk 設定ファイルの管理

ディレクトリ構成

EB 管理用リポジトリは下記のようなディレクトリ構成を取ります。

.gitignore
eb
├── production
│   ├── worker
│   │   ├── .ebextensions
│   │   │   ├── ...
│   │   │   └── cache_clear.config
│   │   ├── .elasticbeanstalk
│   │   │   └── config.yaml
│   │   ├── Dockerrun.aws.json
│   │   └── cron.yaml
│   └── web
│       ├── .ebextensions
│       │   ├── cache_clear.config
│       │   ├── ...
│       │   └── migrate_db.config
│       ├── .elasticbeanstalk
│       │   └── config.yaml
│       └── Dockerrun.aws.json
└── staging
    ├── worker
    │   ├── .ebextensions
    │   │   ├── ...
    │   │   └── cache_clear.config
    │   ├── .elasticbeanstalk
    │   │   └── config.yaml
    │   ├── Dockerrun.aws.json
    │   └── cron.yaml
    └── web
        ├── .ebextensions
        │   ├── cache_clear.config
        │   ├── ...
        │   └── migrate_db.config
        ├── .elasticbeanstalk
        │   └── config.yaml
        └── Dockerrun.aws.json

production と staging の2種類のEBアプリケーションがあり、それぞれのEBアプリケーションはworker環境とweb環境を持ちます。 各環境には .ebextensions/, .elasticbeanstalk/, Dockerrun.aws.json が配置されています。

config.yaml

デプロイの利便性のため .elasticbeanstalk/config.yaml をソース管理に含めています。EB CLI コマンドeb init を実行した場合、デフォルトではソース管理から外されますがあえてソース管理しています。

# .gitignore
# Elastic Beanstalk Files
eb/**/.elasticbeanstalk/*
!eb/**/.elasticbeanstalk/config.yaml #追加

config.yaml にはEBアプリケーション名および環境名を記述します。

# config.yaml
branch-defaults:
  default:
    environment: web
    ...
global:
  application_name: formbridge-production
  ...

各環境ディレクトリに対して eb deploy を実行することでデプロイを行います。

弊社では完全な自動化はしていませんが、さらに .ebextensions 以下に config を追加したりJSONドキュメントを活用することで eb deploy だけでなく eb initeb create による自動化も達成できるはずです。

参考

Dockerrun.aws.json

各環境毎の Dockerrun.aws.json では ECR リポジトリとイメージタグの指定やログの設定を行なっています。

# Dockerrun.aws.json
{
  "AWSEBDockerrunVersion": 2,
  "containerDefinitions": [
    {
      "name": "php",
      "essential": true,
      "memory": 1024,
      "image": "xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/php-repository-name:tag",
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-group": "/aws/elasticbeanstalk/containers/php"
        }
      }
    },
    {
      "name": "nginx",
      "essential": true,
      "memory": 256,
      "image": "xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/nginx-repository-name:tag",
      "links": [
        "php"
      ],
      "portMappings": [
        {
          "containerPort": 80,
          "hostPort": 80
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-group": "/aws/elasticbeanstalk/containers/nginx"
        }
      }
    }
  ]
}

Dockerrun.aws.json のコンテナ定義セクションとボリュームセクションは、Amazon ECS タスク定義ファイルの対応するセクションと同じ形式を使用できますので、logConfiguration を設定し CloudWatch へログをストリーミングしています。awslogs-group に対応するロググループを CloudWatch 上で手で作成する必要があります。

cron ジョブ

Worker環境に cron.yaml を配置すると cron のように定期的なタスクを実行することができます。

# cron.yaml
version: 1
cron:
  - name: "hoge"
    url: "/hoge"
    schedule: "0 0 * * *"

注意点としては、スケジューリングをUTCで指定する必要があることと、FIFOキューとの併用ができないことです(2019.8 時点)。実際に動かしてみると cron.yaml を元にキューにメッセージを詰める aws-sqsd がエラーを出すので単に対応していないようです。このためFIFOキューを使うWorker環境が欲しい場合、cronジョブを捌く環境とは別の環境を用意すると良さそうです。

.ebextensions

Symfony アプリの運用では cach_clear.configmigrate_db.config があると便利なので紹介します。それぞれDockerコンテナ起動後に実行されるコマンドです。

上記のディレクトリ構成ではこれらのファイルが重複し冗長な構成となっていますが、別にいいんじゃないかなという気持ちです。

# cache_clear.config
files:
  "/opt/elasticbeanstalk/hooks/appdeploy/post/99_cache_clear.sh":
    mode: "000755"
    owner: root
    group: root
    content: |
      #!/usr/bin/env bash
      docker exec `docker ps --no-trunc | grep container-name | awk '{print $1}'` php bin/console cache:clear -e prod
      docker exec `docker ps --no-trunc | grep container-name | awk '{print $1}'` chown -R www-data:www-data /app/var/cache

Symfony は Cache まわりがよく問題となるので、どこかのタイミングで php bin/console cache:clear を実行し Cache を削除しておくと安心です。この例では root ユーザーで実行しているので PHP-FPM の実行ユーザー www-data にパーミッションを戻しています。

# migrate_db.config
files:
  "/opt/elasticbeanstalk/hooks/appdeploy/post/10_post_migrate.sh":
    mode: "000755"
    owner: root
    group: root
    content: |
      #!/usr/bin/env bash
      if [ -f /tmp/leader_only ]
      then
        rm /tmp/leader_only
        docker exec `docker ps --no-trunc | grep container-name | awk '{print $1}'` php bin/console doctrine:database:create --if-not-exists
        docker exec `docker ps --no-trunc | grep container-name | awk '{print $1}'` php bin/console doctrine:migrations:migrate --allow-no-migration
      fi

container_commands:
  01_migrate:
    command: "touch /tmp/leader_only"
    leader_only: true

migrate_db.config のポイントは冗長化されていても1回だけ実行するためにリーダーインスタンスのみで実行していることです。

デプロイ

ローリングデプロイ

EB コンソールで次のような設定をしておくとデプロイ時にコンテナ起動中の状態で公開されてしまい 502 Bad GateWay といった事態を無くすことができます。

f:id:kitagawasyunta:20190815144316p:plain
ヘルスにもとづくローリングデプロイ

手順

最後にこれまでのまとめとしてデプロイの手順を紹介します。例として eb/production/web の環境にデプロイします。

# イメージタグを更新する
vi eb/production/web/Dockerrun.aws.json
# 本番用イメージのビルド
docker build -t nginx-image /path/to/nginx/Dockerfile
docker build -t php-image /path/to/php/Dockerfile
# ECRにログイン
aws ecr get-login --no-include-email --region ap-northeast-1
 # イメージを ECR にプッシュ
docker tag nginx-image:latest xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/nginx-repository:tag
docker tag php-image:latest xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/php-repository:tag
docker push xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/nginx-repository:tag
docker push xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/php-repository:tag
# eb へデプロイ
cd eb/production/web
eb deploy

あとはこれと似たようなものをそれぞれの環境についても記述し、お好みのツールとビルド環境で自動化すればおしまいです。


トヨクモでは一緒に働いてくれるエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。