Madogiwa Blog

主に技術系の学習メモに使っていきます。

🚃Rails newで生成される本番用Dockerfileを読んでみる🐳

以下の通り、Rails v7.1 から新規アプリケーション生成時にDockerfileが生成されるようになりました。

2.1 新規RailsアプリケーションでDockerfileが生成されるようになった 新規Railsアプリケーションでは、デフォルトでDockerがサポートされるようになりました(#46762)。 新しいアプリケーションを生成すると、そのアプリケーションにDocker関連ファイルも含まれます。 https://railsguides.jp/7_1_release_notes.html#%E6%96%B0%E8%A6%8Frails%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%A7dockerfile%E3%81%8C%E7%94%9F%E6%88%90%E3%81%95%E3%82%8C%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F

今回は先日リリースされたRails v7.2で生成されるDockerfileがどんな感じなのか読んでみたいと思います。

edgeguides.rubyonrails.org

Rails v7.2で生成されるDockerfile

Rails v7.2で生成されるDockerfileは以下のTemplateを元に生成されます。

github.com

例えば以下のコマンエドRails newすると

$ rails -v
Rails 7.2.0
$ rails new myapp

以下のようなDockerfileが生成されます。

# syntax = docker/dockerfile:1

# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t my-app .
# docker run -d -p 80:80 -p 443:443 --name my-app -e RAILS_MASTER_KEY=<value from config/master.key> my-app

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.3.1
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base

# Rails app lives here
WORKDIR /rails

# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libjemalloc2 libsqlite3-0 libvips && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

# Throw-away build stage to reduce size of final image
FROM base AS build

# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential git pkg-config && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile

# Copy application code
COPY . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile




# Final stage for app image
FROM base

# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER 1000:1000

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

RailsのDockerfileのtemplateを読んでみる

github.com

RailsのDockerfileのtemplateは以下のようなマルチステージビルドを行うような構成になっています。

  • base : イメージの指定と環境変数、ベースとなるライブラリのinstall
  • build : gem/npmのinstall及びビルド
  • final stage : ビルド成果物のコピーとカスタムユーザーへの権限追加、起動コマンドの指定

それぞれサラッとみていきます。

base

base部分のDockerfileは以下の通りです。

ARG RUBY_VERSION=<%= gem_ruby_version %>
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base

# Rails app lives here
WORKDIR /rails

# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y <%= dockerfile_base_packages.join(" ") %> && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

イメージにはruby:<version>-slimRubyのイメージが利用されます。Rubyに実行に必要な最低限のパッケージのみが含まれているためイメージサイズを小さくすることができます。

ruby:<version>-slim
This image does not contain the common packages contained in the default tag and only contains the minimal packages needed to run ruby. Unless you are working in an environment where only the ruby image will be deployed and you have space constraints, we highly recommend using the default image of this repository.

https://hub.docker.com/_/ruby

イメージに利用するRubyのバージョンはARGでビルド時に指定可能になっていますが、 デフォルトではgem_ruby_versionが実行されRails newした際に利用しているRubyのバージョンが設定されます。

apt-getでinstallされるライブラリはdockerfile_base_packagesで設定され、基本はcurllibjemalloc2がインストールされ、あとはActiveRecordを使う場合にはdatabase関連の必要なライブラリ、ActiveStrageを利用するならlibvipsがインストールされます。

その後は環境変数としてRAILS_ENV及びBubdler周りの設定用の環境変数が設定されます。

build

build部分のDockerfileは以下の通りです。gemのinstallやアセットのビルド等を行い最終的なイメージに必要となる成果物を作成します。

# Throw-away build stage to reduce size of final image
FROM base AS build

# Install packages needed to build gems<%= using_node? ? " and node modules" : "" %>
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y <%= dockerfile_build_packages.join(" ") %> && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

<% if using_node? -%>
# Install JavaScript dependencies
ARG NODE_VERSION=<%= node_version %>
ARG YARN_VERSION=<%= dockerfile_yarn_version %>
ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
    /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
    npm install -g yarn@$YARN_VERSION && \
    rm -rf /tmp/node-build-master

<% end -%>
<% if using_bun? -%>
ENV BUN_INSTALL=/usr/local/bun
ENV PATH=/usr/local/bun/bin:$PATH
ARG BUN_VERSION=<%= dockerfile_bun_version %>
RUN curl -fsSL https://bun.sh/install | bash -s -- "bun-v${BUN_VERSION}"

<% end -%>
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git<% if depend_on_bootsnap? -%> && \
    bundle exec bootsnap precompile --gemfile<% end %>

<% if using_node? -%>
# Install node modules
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

<% end -%>
<% if using_bun? -%>
# Install node modules
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile

<% end -%>
# Copy application code
COPY . .

<% if depend_on_bootsnap? -%>
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

<% end -%>
<% unless dockerfile_binfile_fixups.empty? -%>
# Adjust binfiles to be executable on Linux
<%= "RUN " + dockerfile_binfile_fixups.join(" && \\\n    ") %>

<% end -%>
<% unless options.api? || skip_asset_pipeline? -%>
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

<% end -%>

<% if using_node? -%>
RUN rm -rf node_modules
<% end %>

まず先ほどのbaseをベースイメージとしてbuildと名前をつけます。 そしてdockerfile_build_packagesで必要となるライブラリをインストールします。

基本はnative拡張等のgemをinstallするためのbuild-essential,git,pkg-configがinstallされますが、Node.jsが必要となる場合にはnpmのnative modulesをinstallするためのnode-gyppython-is-python3もインストールされます。

Node.jsが必要となるかどうかは、using_node?で判定されており、実態はimportmapを利用せずにJavaScriptを利用するかで判定しています。

Node.jsを使った環境構築を行う場合にはnpm packageのinstallを行うために必要なpackage.jsonyarn.lockをホストからコピーしyarn installを行います。(bunを使う場合にはbun installが行われる)

その後COPY . .でホストからすべてのファイルをコピーし、bootsnapに依存していればbootsnap precompileを行いapp,limのcacheを生成します。

dockerfile_binfile_fixupsでの処理は、OS間でのbin系ファイルの互換性を保持するようにしているようです。

https://github.com/rails/rails/pull/46944

その後Asset Pipelineを利用している場合にはSECRET_KEY_BASE_DUMMY=1を指定して、bin/rails assets:precompileを実行して必要なアセット類のビルドを行います。

ENV["SECRET_KEY_BASE_DUMMY"]を設定すると、一時ファイルに保存されるランダム生成のsecret_key_baseが使われるようになります。これは、ビルド中にproduction用のsecretsにアクセスせずに、production用のアセットをプリコンパイルしたい場合に便利です。 https://railsguides.jp/asset_pipeline.html

最後にRUN rm -rf node_modulesを実行してビルドも終わり不要になったnode_modulesを削除しています。(最終的ステージでCOPY --from=build /rails /railsをした際にnode_modulesが最終イメージに含まれないようにするため)

final stage

final stageのDockerfileは以下の通りです。buildで生成した成果物を取得し、カスタムユーザーに必要な権限を与え、起動コマンドを設定しています。

# Final stage for app image
FROM base

# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails <%= dockerfile_chown_directories.join(" ") %>
USER 1000:1000

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

COPY --from=buildでbundle installしたgemとprojectのrootディレクトリである/railsをbuidステージから取得します。(ここで実行時に必要なファイルだけ取得してるので最終的なイメージサイズを削減できます)

その後、カスタムユーザーrailsを作成しchowndockerfile_chown_directoriesで取得したディレクトリの所有者を変更し権限を付与し、実行時のユーザーとしてrailsを設定します。

カスタムユーザー(non-root user)での実行は以下のPRによって導入されており、

github.com

Docker公式でもセキュリティ面で推奨されているようです。

docs.docker.com

ENTRYPOINT ["/rails/bin/docker-entrypoint"]が設定されイメージからコンテナが起動された際にdocker-entrypointが実行され、デフォルトではjemallocの有効化とbin/rails db:prepareが行われます。

最後に3000番ポート公開し、デフォルトの実行コマンドとしてRailsの起動コマンドであるbin/rails serverを設定します。

おわりに

RailsのDockerfileを見てみましたが、今までおまじない的にインストールしていたライブラリ群をRails公式が整理してくれたことにより自信を持てるようになったのと、並列実行のためにbundle installとnpmはステージを分けたりとカスタマイズのしがいはありそうですが、マルチステージビルドでいい感じに最終的なイメージサイズを削減してくれていたり初期の段階では十分そうな印象を持ちました。

また、Railsで本番デプロイする際のDockerfileがテンプレートとして管理されるようになったことによって、今まで各社で個別に行われていたDockerfile周りの知見が集まっていくのは非常にありがたいなと思いました🙏

おわり