Deploy Ruby on Rails với Docker: Multi-stage Build, Asset Precompile và Sidekiq Worker thực chiến

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

Vấn đề thực tế sau 6 tháng chạy Rails trên Docker

Mình container hóa ứng dụng Rails lần đầu vào đầu năm ngoái. Tưởng đơn giản — pull image ruby, copy code vào, chạy bundle install là xong. Đúng là chạy được, nhưng chạy theo kiểu đó thì image nặng 1.8GB, CI push mất 15 phút, và production thì deploy bằng tay vì pull image quá lâu.

Ba vấn đề cụ thể team mình gặp phải:

  • Image nặng không cần thiết: dev dependencies, Node.js, yarn, build tools đều lọt vào production image
  • Asset precompile lỗi liên tục: Rails 7 + Sprockets cần Node cho một số gem, nhưng setup trong container hay bị lỗi đường dẫn hoặc thiếu biến môi trường
  • Sidekiq chết cùng web: nhét Sidekiq vào Procfile rồi chạy chung container với Puma — khi web container crash thì job đang xử lý cũng bay màu theo

Phân tích: Tại sao lại như vậy?

Vấn đề image nặng xuất phát từ cách build một-giai-đoạn truyền thống — mọi thứ gộp vào một layer duy nhất:

# Cách làm sai — single stage, mọi thứ lọt vào 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"]

Image kiểu này mang cả Node.js, npm, compiler, header files vào production — toàn bộ những thứ chỉ cần lúc build. Rails production không cần Node để chạy, chỉ cần assets đã được compile sẵn.

Còn Sidekiq chạy chung Puma là anti-pattern rõ ràng: hai workload hoàn toàn khác nhau về resource profile. Web cần concurrency cao và response nhanh, Sidekiq cần memory ổn định và xử lý lâu dài. Gộp chung thì scale không được, và khi một bên crash thì kéo theo bên kia.

Các cách giải quyết

Cách 1: Precompile assets ngoài container rồi COPY vào

Cách đơn giản nhất là precompile trên máy CI/CD rồi copy kết quả vào image:

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

Cách này giảm image size nhưng phụ thuộc môi trường CI phải có đủ tool. Không portable, khó reproduce — mình dùng thử 2 tuần rồi bỏ.

Cách 2: Multi-stage build (đúng hướng)

Dùng builder stage để cài tool và compile, production stage chỉ copy những gì thực sự cần. Đây là cách Docker khuyến nghị và team mình đang dùng:

# Stage 1: Builder — cài đặt và compile
FROM ruby:3.3-slim AS builder

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

# Cài Node.js chỉ trong builder stage
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
    apt-get install -y nodejs && \
    npm install -g yarn

WORKDIR /app

# Copy Gemfile trước để tận dụng Docker layer cache
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 image — nhẹ, không có build tools
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

# Chỉ copy gems và compiled assets từ builder
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"]

Lưu ý quan trọng: SECRET_KEY_BASE=dummy là bắt buộc khi precompile trong CI/CD. Một số gem như devise validate key này ngay lúc khởi động Rails — thiếu là lỗi ngay, mất khá lâu mình mới tìm ra nguyên nhân.

Cách tốt nhất: Docker Compose Stack tách web và Sidekiq hoàn toàn

Sau khi có Dockerfile tốt, bước tiếp theo là cấu hình Docker Compose để Sidekiq chạy như service độc lập, dùng chung image nhưng override 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     # Dùng lại image đã build, không build lại
    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:

Điểm mấu chốt: sidekiq service dùng image: myapp:latest thay vì build: — không build lại từ đầu, chỉ đổi command. Đảm bảo web và Sidekiq chạy đúng cùng code version, và khi scale cũng độc lập nhau:

# Scale Sidekiq lên 3 worker mà không đụng đến web
docker compose up -d --scale sidekiq=3 --no-deps sidekiq

Cấu hình Sidekiq và queue priority

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

Database migration — không chạy trong CMD

Migration không nên tự động chạy khi container khởi động. Nếu scale lên nhiều web container, migration sẽ chạy song song và conflict. Cách đúng là tách thành bước riêng trong deploy script:

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

Debug trong thực tế

Khi stack có nhiều service, API response từ Rails hay trả về JSON lồng nhau khá dài. Mình hay paste vào JSON Formatter tại toolcraft.app để đọc cho dễ — nhanh hơn cài extension, nhất là khi debug trên VPS không có GUI.

Để check Sidekiq có đang kết nối Redis không:

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

Kết quả sau 6 tháng production

So sánh trước và sau khi refactor toàn bộ setup:

  • Image size: 1.8GB → 380MB (giảm 79%)
  • CI push time: 15 phút → 4 phút
  • Sidekiq scale và restart độc lập với web hoàn toàn
  • Background job không bị mất khi web container crash hay deploy lại

Phần dễ bị bỏ qua nhất là healthcheck cho dbredis. Thiếu phần này thì websidekiq container sẽ khởi động trước khi database sẵn sàng, crash ngay lần đầu, và Docker sẽ cứ restart loop mãi. Mình mất khoảng 2 tiếng debug lần đầu gặp cái này.

Share: