メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

Amazon ECSで動かすRailsアプリのDockerfileとGitHub Actionsのビルド設定

CTO室SREの@sinsokuです。

Dockerイメージのビルドを高速化するため、試行錯誤して分かった知見などをまとめて紹介します。

AWSのインフラ構成

assetsもECSから配信し、CloudFrontで /assets/packs をキャッシュする構成になっています。

f:id:sinsoku:20210527182858p:plain
Rails on ECS

デプロイ時にassetsが404になる問題

以前の記事に詳細が書かれているため、ここでは問題の紹介だけしておきます。

Rails等のassetsファイルをハッシュ付きで生成し配信するWebアプリケーションの場合、ローリングアップデートを行うと、アップデート時に404エラーが確立で発生してしまいます。

引用: メドピアのECSデプロイ方法の変遷

Dockerfile

実際のDockerfileには業務上のコード、歴史的な残骸などが含まれていたので、綺麗なDockerfileを用意しました。

rails new した直後の最小限のRailsアプリで動作確認していますが、業務のRailsアプリではapkでインストールするパッケージなどは少し変える必要があるかもしれません。

# == base
FROM ruby:3.0.1-alpine3.13 AS base

WORKDIR /app
ENV RAILS_ENV production
ENV BUNDLE_DEPLOYMENT true
ENV BUNDLE_PATH vendor/bundle
ENV BUNDLE_WITHOUT development:test

RUN gem install bundler --no-document --version 2.2.16

# == builder
FROM base AS builder

# Add packages
RUN apk update && apk add --no-cache --update \
      build-base \
      postgresql-dev \
      tzdata \
      git \
      yarn \
      shared-mime-info

# == bundle
FROM builder AS bundle

# Install gems
COPY Gemfile Gemfile.lock .
RUN bundle install \
      && rm -rf $BUNDLE_PATH/ruby/$RUBY_VERSION/cache/*

# == npm
FROM builder AS npm

# Install npm packages
COPY package.json yarn.lock .
RUN yarn install --production --frozen-lockfile \
      && yarn cache clean

# == assets
FROM builder AS assets

COPY . .

COPY --from=bundle /app/vendor/bundle /app/vendor/bundle
COPY --from=npm /app/node_modules node_modules

# Set a dummy value to avoid errors when building docker image.
# refs: https://github.com/rails/rails/issues/32947
RUN SECRET_KEY_BASE=dummy bin/rails assets:precompile \
      && rm -rf tmp/cache/*

# == main
FROM base AS main

# Add packages
RUN apk update && apk add --no-cache --update \
      postgresql-dev \
      tzdata \
      nodejs \
      shared-mime-info

COPY . .

# Copy files from each stages
COPY --from=bundle /app/vendor/bundle /app/vendor/bundle
COPY --from=assets /app/public/assets public/assets
COPY --from=assets /app/public/packs public/packs

ARG SHA
ENV SHA ${SHA}
ENV PORT 3000
EXPOSE 3000

CMD bin/rails server --port $PORT

rails new をMacで実行した場合

MacでBundler 2.2.3以上を使っている場合、Gemfile.lockにPLATFORMSが入ってしまいDockerイメージのビルドに失敗してしまいます。

 > [bundle 2/2] RUN bundle install       && rm -rf vendor/bundle/ruby/3.0.1/cache/*:
#12 0.953 Your bundle only supports platforms ["x86_64-darwin-20"] but your local platform
#12 0.953 is x86_64-linux-musl. Add the current platform to the lockfile with `bundle lock
#12 0.953 --add-platform x86_64-linux-musl` and try again.

ビルドできるようにするため x86_64-linux を追加する必要があります。1

$ bundle lock --add-platform x86_64-linux

マルチステージビルド

このDockerfileには以下のステージが含まれています。

  • base: 全てのステージの親ステージ
  • builder: bundle,npm,assetsの親ステージ
  • bundle: bundle install を実行する
  • npm: yarn install を実行する
  • assets: assets:precompile を実行する
  • main: 本番環境で使うステージ

ステージの依存関係は以下の通りです。

bundle ─┬- assets ─ main
        │
   npm ─┘

bundle stage

bundle install の引数を使うと、mainステージでも bundle config をする必要がありDRYでは無いため、baseステージで環境変数を指定しています。2

ENV BUNDLE_DEPLOYMENT true
ENV BUNDLE_PATH vendor/bundle
ENV BUNDLE_WITHOUT development:test

bundle installを実行します。

RUN bundle install \
      && rm -rf $BUNDLE_PATH/ruby/$RUBY_VERSION/cache/*

npm stage

特筆すべき点は特にないです。 yarn install を実行します。

RUN yarn install --production --frozen-lockfile \
      && yarn cache clean

assets stage

RAILS_ENV=production を設定しているので、 assets:precompile を実行するときに SECRET_KEY_BASE が存在しないというエラーが起きてしまいます。
Railsのissueを参考にSECRET_KEY_BASE=dummy を指定すれば回避できます。3

RUN SECRET_KEY_BASE=dummy bin/rails assets:precompile \
      && rm -rf tmp/cache/*

main stage

rails server の起動に必要なパッケージをインストールしたり、ソースコードや各ステージの成果物をコピーします。

assets:precompile でassetsファイルは結合されているため、 node_modules はサーバ起動時に不要です。

RUN apk update && apk add --no-cache --update \
      postgresql-dev \
      tzdata \
      nodejs \
      shared-mime-info

COPY . .

# Copy files from each stages
COPY --from=bundle /app/vendor/bundle /app/vendor/bundle
COPY --from=assets /app/public/assets public/assets
COPY --from=assets /app/public/packs public/packs

環境変数の定義位置には注意が必要です。 GitのSHAのように毎回変わる値をイメージに埋め込む場合、Dockerfileの下部に記載しておかないとレイヤーキャッシュが効かなくなってしまいます。

ARG SHA
ENV SHA ${SHA}

最後にポート番号とサーバの起動コマンドを設定します。

ENV PORT 3000
EXPOSE 3000

CMD bin/rails server --port $PORT

GitHub Actions

DockerイメージをビルドするGitHub ActionsのYAMLを紹介します。

name: build

on:
  pull_request:
    branches:
      - master
  push:
    paths:
      - '.github/workflows/build.yml'
      - 'Dockerfile'

env:
  ECR_REPOSITORY: example
  HEAD_SHA: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || github.sha }}

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
    - uses: actions/checkout@v2
      with:
        ref: ${{ env.HEAD_SHA }}

    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ap-northeast-1

    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v1

    - name: Cache Docker layers
      uses: actions/cache@v2
      with:
        path: /tmp/.buildx-cache
        key: ${{ runner.os }}-buildx-${{ env.HEAD_SHA }}
        restore-keys: |
          ${{ runner.os }}-buildx-

    - name: Build bundle stage
      uses: docker/build-push-action@v2
      with:
        context: .
        target: bundle
        cache-from: type=local,src=/tmp/.buildx-cache/bundle
        cache-to: type=local,dest=/tmp/.buildx-cache-new/bundle

    - name: Build npm stage
      uses: docker/build-push-action@v2
      with:
        context: .
        target: npm
        cache-from: type=local,src=/tmp/.buildx-cache/npm
        cache-to: type=local,dest=/tmp/.buildx-cache-new/npm

    - name: Build and push
      uses: docker/build-push-action@v2
      with:
        context: .
        build-args: |
          SHA=${{ env.HEAD_SHA }}
        tags: |
          ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, env.HEAD_SHA) }}
          ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, 'latest') }}
        push: ${{ github.event_name == 'pull_request' }}
        cache-from: |
          type=local,src=/tmp/.buildx-cache-new/bundle
          type=local,src=/tmp/.buildx-cache-new/npm
        cache-to: type=inline

    # Temp fix
    # https://github.com/docker/build-push-action/issues/252
    # https://github.com/moby/buildkit/issues/1896
    - run: |
        rm -rf /tmp/.buildx-cache
        mv /tmp/.buildx-cache-new /tmp/.buildx-cache

キャッシュ

docker/build-push-actionのCacheのページを参考に、GitHub cacheを使ってbundle,npmのステージをキャッシュします。4

エクスポートするのはbundle, npmのステージになるため、各ステージでもキャッシュを消してイメージサイズを小さく保つようにします。

マージした時のみpush

build-push-action ではレジストリへのプッシュを制御できるので、「git pushではレジストリにプッシュしないで、Actionsでビルド結果を確認してからプルリクを作る」という運用が簡単に実現できます。

- name: Build and push
  uses: docker/build-push-action@v2
  with:
    context: .
    build-args: |
      SHA=${{ env.HEAD_SHA }}
    tags: |
      ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, env.HEAD_SHA) }}
      ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, env.ECR_REPOSITORY, 'latest') }}
    push: ${{ github.event_name == 'pull_request' }}
    cache-from: |
      type=local,src=/tmp/.buildx-cache-new/bundle
      type=local,src=/tmp/.buildx-cache-new/npm
    cache-to: type=inline

改善の効果

MedPeerのリポジトリで改善したときは他に CodeBuild -> GitHub Actions化なども実施しているため、厳密な計測結果ではありませんが10%程度の高速化を実現できました。

  • 改善前: 🐢10〜11分程度(CodeBuild)
  • 改善後: 🚀8〜9分(Dockerfileの改善 + GitHub Actions化)

まとめ

Dockerイメージのビルドを高速化する知見をブログにまとめてみました。 これらの内容が何か参考になれば幸いです。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html



  1. Bundler 2.2.3+ and deployment of Ruby apps の記事を参考にしました。

  2. bundle configで使える環境変数を調べられる。

  3. https://github.com/rails/rails/issues/32947

  4. GitHubのキャッシュは5GBまでしか使えないため、Registry cacheを使う方が良いケースもあります。