CTO室SREの@sinsokuです。
Dockerイメージのビルドを高速化するため、試行錯誤して分かった知見などをまとめて紹介します。
AWSのインフラ構成
assetsもECSから配信し、CloudFrontで /assets
と /packs
をキャッシュする構成になっています。
デプロイ時にassetsが404になる問題
以前の記事に詳細が書かれているため、ここでは問題の紹介だけしておきます。
Rails等のassetsファイルをハッシュ付きで生成し配信するWebアプリケーションの場合、ローリングアップデートを行うと、アップデート時に404エラーが確立で発生してしまいます。
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
-
Bundler 2.2.3+ and deployment of Ruby apps の記事を参考にしました。↩
-
bundle configで使える環境変数を調べられる。↩
-
GitHubのキャッシュは5GBまでしか使えないため、Registry cacheを使う方が良いケースもあります。↩