amakanの本番環境をDockerに移行した

https://amakan.net/ のこの辺の改善の続き。


image

本番環境で使うDockerイメージ

これまで開発環境でのみDockerを動かしていたが、本番環境でDockerを動かすには、本番環境で利用できるようなDockerイメージを用意する必要がある。そこでamakanでは、こういう方法を取った。

  • 開発環境と本番環境で同じDockerイメージを使う
  • 本番環境に必要な全てのファイルを一旦イメージに含める
  • 開発環境で変更されるファイルはマウントして上書きする

Rails用のDockerfileは次のような感じで、Gemやソースコードを全てイメージに含めていることが分かる。

FROM ruby:2.4.0-preview3

RUN apt-get update \
  && apt-get install --yes --no-install-recommends mysql-client \
  && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY \
  Gemfile \
  Gemfile.lock \
  /app/
RUN bundle install --jobs=4 --path=/bundle \
  && mkdir -p /app/tmp/cache \
  && mkdir -p /app/tmp/pids \
  && mkdir -p /app/tmp/sockets

COPY . /app

開発環境で利用する docker-compose.yml は次のような感じで、変更される可能性のある部分がホスト側からマウントされていることが分かる。

version: "2"
services:
  puma:
    build:
      context: .
      dockerfile: ./docker/rails/Dockerfile
    command: bundle exec puma --config config/puma.rb --environment development
    volumes:
      - ./app:/app/app
      - ./bin:/app/bin
      - ./config:/app/config
      - ./config.ru:/app/config.ru
      - ./db:/app/db
      - ./Gemfile:/app/Gemfile
      - ./Gemfile.lock:/app/Gemfile.lock
      - ./lib:/app/lib
      - ./public:/app/public
      - ./Rakefile:/app/Rakefile
      - ./scripts:/app/scripts
      - bundle:/bundle
volumes:
  bundle:

ソースコードだけでなく、Gemも頻繁に変更される部分なので、ボリュームを /bundle にマウントしている。これは以下のような影響を及ぼす。

  • 開発環境では環境構築時に docker-compose run --rm puma bundle install を実行する必要がある
  • Gemfileを変更したときに全てのGemを再インストールする必要はない (上記のコマンドで差分だけ入る)

CSSJavaScriptコンパイル

amakanではSSRを行う都合から、Railsを動かす環境にコンパイル済みのJavaScriptのファイルが存在している必要がある。デプロイ時にコンパイルを行う方法を取る場合、仮にRailsのWebサーバを動かす環境が複数台存在したとして、デプロイ時に全ての環境でコンパイルするか、少なくとも1つの環境でコンパイルしたあと、全ての環境にそれを配布するなどの工夫が必要になる。

そこでRails用のDockerイメージの中には、コンパイル済みのCSSJavaScriptも含めることにした。最初からイメージの中にコンパイル済みのファイルを含めておけば、前述した苦労は回避できる。また、問題が確認されたときに本番環境と同じ環境ですぐにデバッグを行えるなど、開発環境と本番環境との乖離を避けるという観点でも都合が良い。

コンパイルにはNode.jsの環境が必要になるが、これは別途Node.js用のコンテナを用意して、コンパイルされた成果物をRails用のコンテナに渡している。Node.js用イメージのDockerfileは次のような感じで、前述したイメージと同様に、必要な全てのファイルをイメージに含めつつ、開発環境では変更されるソースコードや依存ライブラリ用のディレクトリをマウントして運用している。

FROM node:6.9.1

ENV PATH /root/.yarn/bin:$PATH
RUN curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 0.18.1

WORKDIR /app

COPY \
  package.json \
  yarn.lock \
  /app/
RUN yarn install

COPY . /app

結果的に、イメージをビルドするときはこういうコードが走ることになる。

docker build --file ./docker/node/Dockerfile --tag ${DOCKER_TAG_NODE} .
docker run --volume $(pwd)/public/assets:/app/public/assets ${DOCKER_TAG_NODE} yarn run build
docker build --file ./docker/rails/Dockerfile --tag ${DOCKER_TAG_RAILS} .

静的ファイルの配信

コンパイル済みのassetsがイメージに含まれるようになったことに関連して、静的ファイルをS3から配信する運用もやめることにした。これまでは asset_sync を利用して、デプロイ時に静的ファイルをS3にアップロードした後、CDNを通して配信していた。しかし Please Do Not Use Asset Sync | Heroku Dev Center という記事にもある通り、この方法には次のように色々と不都合が多い。

代わりに、Webサーバから直接静的ファイルを配信しつつ、前段にCDNを置いてキャッシュさせることにした。具体的には、CloudFrontに /assets/* に対するGETリクエストとHEADリクエストをキャッシュさせている。以下はHEADリクエストを送ってみた例。

$ curl https://amakan.net/assets/client-383ee728fc39c4060b0f.js -I
HTTP/1.1 200 OK
Content-Type: application/javascript
Content-Length: 617037
Connection: keep-alive
Date: Sun, 25 Dec 2016 18:50:27 GMT
Last-Modified: Sun, 25 Dec 2016 16:05:31 GMT
Strict-Transport-Security: max-age=15552000
Vary: Accept-Encoding
X-Cache: Miss from cloudfront
Via: 1.1 4218e6d02a86bf6e99855f3eddfab7a8.cloudfront.net (CloudFront)
X-Amz-Cf-Id: DtSOAjXZ4OP8cVw23LZhEJ3JVdS0z4TudfOi2pDfPsGXtkPCAhqekA==

X-Cacheヘッダなどで分かるように、1度目のリクエストはCloudFrontにキャッシュが無く、Webサーバにリクエストが飛んでいる。

$ curl https://amakan.net/assets/client-383ee728fc39c4060b0f.js -I
HTTP/1.1 200 OK
Content-Type: application/javascript
Content-Length: 617037
Connection: keep-alive
Date: Sun, 25 Dec 2016 18:50:27 GMT
Last-Modified: Sun, 25 Dec 2016 16:05:31 GMT
Strict-Transport-Security: max-age=15552000
Vary: Accept-Encoding
Age: 6
X-Cache: Hit from cloudfront
Via: 1.1 ebc77629c4b26fbccc1b57ff75631a1c.cloudfront.net (CloudFront)
X-Amz-Cf-Id: JBIQSgCoiBex6eGEONFnvD4K0R43DlDbd8dbYdXT2fXSq9aM3WUi5A==

2度目のリクエストでは、CloudFrontがキャッシュを返している。ちなみにCloudFrontのcompressオプションを有効化してあるので、リクエストするときに Accept-Encoding: gzip を付ければ、自動的にgzip圧縮されたファイルが配信される。

リバースプロキシを排除

前述したようにWebサーバの前段にCloudFrontを配置するようになった訳だが、これまでNginxが担当することになっていた静的ファイルの配信も、今回からRailsに任せることにした。今までamakanがNginxを利用していたのは、次のような理由からだった。

  1. 静的ファイルを配信するため
  2. Let's EncryptのSSL証明書を利用するため
  3. スロークライアント対策のため

これは以下のような理由で不要になったため、Nginxの必要性が薄まった。

  1. Railsに任せることにした + 前段にCDNを設置した
  2. AWS Certificate Managerの証明書を利用することにした
  3. UnicornからPumaに移行した

更に今回のDocker化に際して、Nginx用のコンテナを管理し、Rails用のコンテナと協調させるのも大変だったので、この機会にNginxは使わないことにした。The Twelve-Factor App に倣うと、アプリケーションの動作に必要な静的ファイルの配信も、アプリケーション自体が責任を持つべきである。まあそこまで深く考えてなくて、その方が楽だったから変えた。

イメージのビルド

CIでは毎回イメージをビルドし直すようにしていて、都度ビルドされたイメージを利用してテストが実行される。masterブランチでテストが通った場合は、そのイメージがレジストリに登録され、のちのデプロイに利用される。逆に言うと、デプロイするとmasterブランチでCIが通った最新のイメージがデプロイされるということになる。

CIにはCircleCIを使っている。参考までに、circle.yml は次のような感じ。プライベートなDockerイメージレジストリとして、Amazon ECRを使っている。

machine:
  services:
    - docker
database:
  override:
    - echo "Skip database phase"
dependencies:
  override:
    - docker build --file ./docker/node/Dockerfile --tag ${DOCKER_TAG_NODE} .
    - docker run --volume $(pwd)/public/assets:/app/public/assets ${DOCKER_TAG_NODE} yarn run build
    - docker build --file ./docker/rails/Dockerfile --tag ${DOCKER_TAG_RAILS} .
deployment:
  docker:
    branch: master
    commands:
      - eval $(aws ecr get-login --region ap-northeast-1)
      - docker push ${DOCKER_TAG_RAILS}
test:
  override:
    - >
      if [[ "${CIRCLE_BRANCH}" != "production" ]]; then
        docker run --rm ${DOCKER_TAG_NODE} yarn run test
        docker run --rm ${DOCKER_TAG_RAILS} bundle exec rspec
      fi

ECS

本番環境でDockerを動かすために、Amazon ECSを利用した。Amazon ECSを使うと、ざっくり言うと「このイメージを利用して3つほどコンテナを起動しておいてくれ」「ロードバランサから適当に繋ぐんでよろしく頼む」ということができる。

amakanでは、amakan_pumaとamakan_sidekiqというコンテナをそれぞれ1つずつ起動してもらっている。ECSでは複数の仕事をクラスタという単位にまとめて管理するようになっている。以下の図では、amakanというクラスタがあり、サービスというのが2つ動いており、CPUはほとんど使ってないわりにメモリを沢山使っていることが分かる。

image

amakanクラスタの詳細を表示すると、amakan_pumaとamakan_sidekiqというサービスが動いているということが分かる。

image

amakan_pumaサービスの詳細を表示すると、amakan_pumaというタスク定義のv1を元にしたタスクが動作中で、Dockerコンテナが3000番ポートを露出させており、これがamakanロードバランサに紐付いていることが分かる。

image

タスク定義というのは「amakan_railsというDockerイメージで bundle exec puma コマンドを動かしてくれ」というのが定義してあるもので、サービスには対応するタスク定義を設定することになっている。「amakan_pumaサービスではamakan_pumaというタスク定義を元にコンテナが常に1つ起動されるようにしておいてください」という設定がなされている。実際に起動されるコンテナは、タスク定義を元に動作するタスクという単位で管理されている。

タスクは、実際にはクラスタに登録されたEC2インスタンス上で実行される。EC2インスタンスでecs-agentを動かして所属すべきクラスタの名前を教えてあげると、クラスタを見つけてそこに所属してくれる。amakanだとオートスケーリングを利用してEC2インスタンスが自動的に起動するようにしてあって、ecs-agent が最初から入っているAMIを利用してインスタンスを起動し、起動時に所属すべきクラスタ名を教えている。

Terraform

ECS導入にあたりVPCを利用する必要があったので、この機会にVPCを積極的に使うような構成に移行することに。これまではRoute53、EC2、RDSを触るだけで規模も大きくなかったので手作業で登録していたが、扱うリソースも増えてきたのでterraformを利用することにした。

例えばECSのところで説明した設定は、terraformのコードでこういう風に書かれている。

resource "aws_launch_configuration" "amakan" {
  name                        = "amakan"
  instance_type               = "t2.micro"
  image_id                    = "ami-08f7956f"
  iam_instance_profile        = "${aws_iam_instance_profile.amakan.id}"
  associate_public_ip_address = true
  security_groups             = ["${aws_security_group.amakan_web.id}"]
  key_name                    = "amakan"
  user_data                   = "#!/bin/bash\necho ECS_CLUSTER='${aws_ecs_cluster.amakan.name}' >> /etc/ecs/ecs.config"
}

定義されている全てのリソースを洗い出すと、こういう感じ。規模感としては、全体で500行程度のコードになっている。

aws_alb_listener.amakan
aws_alb_target_group.amakan
aws_autoscaling_group.amakan
aws_cloudfront_distribution.amakan
aws_db_instance.amakan
aws_db_parameter_group.amakan
aws_db_subnet_group.amakan
aws_ecr_repository.amakan_rails
aws_ecs_cluster.amakan
aws_ecs_service.amakan_puma
aws_ecs_service.amakan_sidekiq
aws_ecs_task_definition.amakan_puma
aws_ecs_task_definition.amakan_sidekiq
aws_elasticache_cluster.amakan
aws_elasticache_subnet_group.amakan
aws_iam_instance_profile.amakan
aws_iam_policy_attachment.amakan_ecs_ec2_instance
aws_iam_policy_attachment.amakan_ecs_service
aws_iam_role.amakan_ec2
aws_iam_role.amakan_ecs
aws_iam_user_policy.amakan_circle_ci
aws_iam_user.amakan_circle_ci
aws_internet_gateway.amakan
aws_launch_configuration.amakan
aws_route_table_association.amakan_a
aws_route_table_association.amakan_c
aws_route_table.amakan
aws_route53_record.amakan_root_a
aws_route53_zone.amakan
aws_security_group.amakan_alb
aws_security_group.amakan_elasticache
aws_security_group.amakan_rds
aws_security_group.amakan_web
aws_subnet.amakan_private_a
aws_subnet.amakan_private_c
aws_subnet.amakan_public_a
aws_subnet.amakan_public_c
aws_vpc.amakan

VPC環境への引越しのとき、DBだけは状態を持っているのでデータをコピーする必要があった。これはこういう感じで処理した。

  1. terraformを利用して空のRDSのインスタンスを作る
  2. コンソール上からそれを削除する
  3. 稼働中の方のRDSのインスタンスからスナップショットを取る
  4. 取得したスナップショットを元にVPC上に再度RDSインスタンスを作る
  5. 接続先のRDSインスタンスVPC上に作成したものに切り替える
  6. terraform import を利用してterraformの状態を合わせる

おわり

長くなってしまったけど、本番環境のDocker移行でやった作業は以上。長くなりそうなので触れなかった話題としてこの辺りの話があるが、また後日。

  • ログ
  • デプロイ
  • スケーリング