Vấn đề: Khi ứng dụng “vọt” nhanh hơn Database
Lần đầu triển khai dự án thực tế với Docker Compose, mình từng nếm trái đắng vì một lỗi cực kỳ cơ bản. Vừa gõ xong docker-compose up -d, thấy Docker báo các container đều đã “Started” xanh mướt, mình hí hửng truy cập web. Kết quả? Một cú tát mang tên Internal Server Error (500) hiện ra ngay trước mắt.
Mở log lên kiểm tra, mình thấy dòng thông báo quen thuộc: Relation "users" does not exist. Hóa ra, dù container Database đã chạy, nhưng cấu trúc bảng (Schema) vẫn trống rỗng hoặc chưa kịp cập nhật. Lúc đó, giải pháp thủ công duy nhất là docker exec vào container để chạy migration. Cách này làm một lần thì vui, chứ làm 10 lần trên Production hay hệ thống CI/CD thì đúng là thảm họa.
Tại sao depends_on chỉ là lời hứa suông?
Sai lầm phổ biến nhất của các bạn mới (và cả mình ngày xưa) là tin tưởng tuyệt đối vào depends_on: - db. Thực tế, Docker Compose chỉ đảm bảo container DB khởi chạy trước container App.
Tuy nhiên, container “đang chạy” không có nghĩa là Database engine đã sẵn sàng nhận kết nối. Một instance PostgreSQL thường mất từ 5 đến 10 giây để khởi tạo bộ nhớ và kiểm tra file hệ thống. Trong khi đó, ứng dụng Node.js hay Go chỉ mất chưa đầy 1 giây để khởi động. Hệ quả là App sẽ cố kết nối vào một DB đang bận “khởi động”, dẫn đến crash ngay lập tức.
3 giải pháp thực tế để tự động hóa Migration
Để giải quyết bài toán này, chúng ta cần một cơ chế kiểm tra trạng thái thực của DB trước khi cho phép ứng dụng chính hoạt động.
1. Sử dụng Entrypoint Script (Gọn nhẹ & Phổ biến)
Thay vì khởi động trực tiếp bằng npm start, hãy bọc nó trong một shell script. Script này đóng vai trò như một người gác cổng: Chờ DB mở cổng kết nối -> Chạy Migration -> Khởi động App.
Đây là file entrypoint.sh mình thường dùng cho các project Node.js:
#!/bin/sh
# Chờ Database mở cổng 5432
echo "Waiting for postgres..."
while ! nc -z db 5432; do
sleep 0.1
done
echo "PostgreSQL is up - executing migrations"
# Thực hiện cập nhật schema
npm run db:migrate
# Bàn giao quyền kiểm soát cho ứng dụng chính
exec "$@"
Đừng quên cấp quyền thực thi trong Dockerfile:
COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
CMD ["npm", "start"]
2. Tận dụng công cụ wait-for-it
Nếu ngại viết script thủ công, wait-for-it.sh là sự lựa chọn thay thế hoàn hảo. Công cụ này cực kỳ ổn định và hỗ trợ tính năng timeout, giúp container không bị treo vô tận nếu DB gặp sự cố.
Cấu hình trong docker-compose.yml sẽ trông như thế này:
services:
app:
build: .
command: ["./wait-for-it.sh", "db:5432", "--timeout=30", "--", "npm", "run", "migrate-and-start"]
depends_on:
- db
3. Tách Migration thành Service riêng (Chuẩn chuyên gia)
Với các hệ thống lớn cần Scale ngang (chạy nhiều container App cùng lúc), việc nhồi nhét migration vào quá trình khởi động sẽ gây lãng phí tài nguyên. Cách tốt nhất là tách migration thành một “One-off container” chạy một lần duy nhất.
services:
db:
image: postgres:15
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
interval: 5s
migration:
build: .
command: npm run db:migrate
depends_on:
db:
condition: service_healthy
app:
build: .
command: npm start
depends_on:
migration:
condition: service_completed_successfully
Luồng hoạt động rất chặt chẽ: Docker đợi db vượt qua bài test sức khỏe, sau đó kích hoạt container migration. Chỉ khi migration thoát với Exit Code 0 (thành công), app mới bắt đầu phục vụ người dùng.
Kinh nghiệm thực chiến tránh “bay màu” dữ liệu
Sau nhiều lần xử lý sự cố, mình rút ra 3 nguyên tắc vàng bạn cần nhớ:
-
Tính Idempotent: Script migration phải chạy được nhiều lần mà không gây lỗi. Hãy luôn dùng
CREATE TABLE IF NOT EXISTShoặc sử dụng các thư viện như Flyway, Alembic, Sequelize. -
Luôn có phương án Backup: Trước khi chạy migration trên Production, hãy đảm bảo hệ thống đã tự động snapshot DB. Docker không thể cứu bạn nếu script lỡ tay thực hiện lệnh
DROP COLUMN. -
Log là cứu cánh: Đảm bảo container migration in log rõ ràng. Khi
docker-compose upbị đứng, bạn cần biết ngay đó là do DB chưa sẵn sàng hay do lỗi cú pháp SQL.
Lời kết: Chọn cách nào cho dự án của bạn?
Nếu bạn đang phát triển dự án cá nhân hoặc giai đoạn Development, Cách 1 (Entrypoint) là lựa chọn nhanh gọn nhất. Nó giữ cho file compose đơn giản và dễ hiểu.
Ngược lại, nếu mục tiêu là môi trường Production hoặc triển khai trên Kubernetes, Cách 3 (Tách service) là hướng đi bắt buộc. Việc tách biệt trách nhiệm giúp hệ thống của bạn ổn định và dễ dàng quản lý hơn rất nhiều. Chúc các bạn có những bản deploy mượt mà!
