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 db và redis. Thiếu phần này thì web và sidekiq 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.

