DockerでRuby on Railsをデプロイする:Multi-stage Build、Asset Precompile、Sidekiq Worker実践ガイド

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

Dockerで6ヶ月Railsを運用して直面した実際の問題

去年の初めに初めてRailsアプリをコンテナ化した。単純に思えた——rubyイメージをpullして、コードをコピーして、bundle installを実行するだけ。確かに動いたが、その方法だとイメージが1.8GBになり、CIのpushに15分かかり、imageのpullが遅すぎてproductionへのデプロイは手動でやる羽目になった。

チームが実際に直面した3つの具体的な問題:

  • 不要なイメージの肥大化:dev dependencies、Node.js、yarn、build toolsがすべてproductionイメージに含まれてしまう
  • Asset precompileが頻繁にエラーになる:Rails 7 + Sprocketsは一部のgemにNodeが必要だが、コンテナ内のセットアップでパスエラーや環境変数の不足が起きやすい
  • SidekiqがWebと一緒に落ちる:SidekiqをProcfileに入れてPumaと同じコンテナで実行すると、webコンテナがクラッシュしたとき処理中のジョブも一緒に消える

分析:なぜこうなるのか?

イメージが肥大化する問題は、従来のシングルステージビルドに起因している——すべてが一つのレイヤーにまとめられてしまう:

# 間違ったやり方 — シングルステージ、すべてがproductionに漏れ込む
FROM ruby:3.3
RUN apt-get install -y nodejs npm yarn build-essential
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .
RUN bundle exec rails assets:precompile
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

このタイプのイメージはNode.js、npm、コンパイラ、ヘッダーファイルをすべてproductionに持ち込む——これらはビルド時にしか必要ないのに。Rails productionはNodeを実行時には必要とせず、コンパイル済みのassetsさえあれば十分だ。

SidekiqをPumaと同じコンテナで実行するのは明らかなアンチパターンだ:2つのワークロードはリソースプロファイルがまったく異なる。WebはConcurrencyが高く素早いレスポンスが必要で、Sidekiqは安定したメモリと長時間処理が必要だ。一緒にすると適切にスケールできず、一方がクラッシュするともう一方も巻き込まれる。

解決策

方法1:コンテナ外でassetsをprecompileしてCOPYする

最もシンプルな方法は、CI/CDマシン上でprecompileしてからその結果をイメージにコピーすることだ:

yarn build && bundle exec rails assets:precompile
docker build -t myapp:latest .

この方法はイメージサイズを削減できるが、CI環境に必要なツールがそろっている必要がある。ポータブルではなく、再現性も低い——2週間試してから諦めた。

方法2:Multi-stage build(正しいアプローチ)

builderステージでツールをインストールしてコンパイルし、productionステージには本当に必要なものだけをコピーする。これはDockerが推奨する方法であり、現在チームで採用している:

# Stage 1: Builder — インストールとコンパイル
FROM ruby:3.3-slim AS builder

RUN apt-get update -qq && apt-get install -y \
    build-essential \
    libpq-dev \
    curl \
    git

# Node.jsはbuilderステージにのみインストール
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
    apt-get install -y nodejs && \
    npm install -g yarn

WORKDIR /app

# Dockerレイヤーキャッシュを活用するためGemfileを先にコピー
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' && \
    bundle install --jobs 4 --retry 3

COPY . .
RUN SECRET_KEY_BASE=dummy \
    RAILS_ENV=production \
    bundle exec rails assets:precompile

# Stage 2: Productionイメージ — 軽量、ビルドツールなし
FROM ruby:3.3-slim AS production

RUN apt-get update -qq && apt-get install -y \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# builderからgemsとコンパイル済みassetsのみをコピー
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder /app/public/assets /app/public/assets
COPY --from=builder /app/public/packs /app/public/packs
COPY . .

ENV RAILS_ENV=production
ENV RAILS_LOG_TO_STDOUT=true

EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

重要な注意点SECRET_KEY_BASE=dummyはCI/CDでprecompileするときに必須だ。deviseのような一部のgemはRails起動時にこのキーを検証する——これがないと即エラーになり、原因を突き止めるのにかなり時間がかかった。

最善の方法:WebとSidekiqを完全に分離したDocker Composeスタック

優れたDockerfileができたら、次のステップはSidekiqを独立したサービスとして実行するようDocker Composeを設定することだ。同じイメージを使いつつ、commandをオーバーライドする:

# docker-compose.yml
version: '3.9'

services:
  db:
    image: postgres:16-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: myapp_production
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  web:
    build:
      context: .
      target: production
    image: myapp:latest
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://myapp:${DB_PASSWORD}@db:5432/myapp_production
      REDIS_URL: redis://redis:6379/0
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      RAILS_ENV: production
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: bundle exec puma -C config/puma.rb

  sidekiq:
    image: myapp:latest     # ビルド済みイメージを再利用、再ビルドしない
    environment:
      DATABASE_URL: postgres://myapp:${DB_PASSWORD}@db:5432/myapp_production
      REDIS_URL: redis://redis:6379/0
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      RAILS_ENV: production
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: bundle exec sidekiq -C config/sidekiq.yml
    deploy:
      resources:
        limits:
          memory: 512M

volumes:
  postgres_data:
  redis_data:

重要なポイント:sidekiqサービスはbuild:の代わりにimage: myapp:latestを使う——ゼロから再ビルドせず、commandを変えるだけだ。WebとSidekiqが確実に同じコードバージョンで動作し、スケール時も互いに独立している:

# WebはそのままにSidekiqを3 workerにスケール
docker compose up -d --scale sidekiq=3 --no-deps sidekiq

SidekiqとQueue優先度の設定

# config/sidekiq.yml
:concurrency: 5
:queues:
  - [critical, 3]
  - [default, 2]
  - [low, 1]
:max_retries: 3

データベースマイグレーション——CMDで実行しない

マイグレーションはコンテナ起動時に自動実行すべきではない。複数のwebコンテナにスケールすると、マイグレーションが並列実行されてコンフリクトが起きる。正しい方法はデプロイスクリプト内の独立したステップとして分離することだ:

# deploy.sh
docker compose pull
docker compose run --rm web bundle exec rails db:migrate
docker compose up -d --no-deps web sidekiq

実際のデバッグ

スタックに複数のサービスがある場合、RailsのAPIレスポンスはネストされたJSONが長くなりがちだ。toolcraft.appのJSON Formatterに貼り付けて読みやすくしている——拡張機能をインストールするより速く、特にGUIのないVPSでデバッグするときに便利だ。

SidekiqがRedisに接続しているか確認するには:

docker compose exec redis redis-cli info | grep connected_clients
docker compose exec web bundle exec rails runner "puts Sidekiq::Stats.new.to_json"

6ヶ月のproduction運用での結果

全体的なセットアップをリファクタリングする前後の比較:

  • イメージサイズ:1.8GB → 380MB(79%削減)
  • CI pushの時間:15分 → 4分
  • SidekiqのスケールとリスタートがWebから完全に独立
  • webコンテナのクラッシュや再デプロイ時にバックグラウンドジョブが失われない

最も見落とされがちな部分はdbredishealthcheckだ。これがないとwebsidekiqコンテナがデータベースの準備ができる前に起動し、初回に即クラッシュして、Dockerが延々とリスタートループを繰り返す。初めてこれに遭遇したとき、2時間ほどデバッグに費やした。

Share: