Bật một cái lò nướng, đèn nguồn sáng ngay lập tức — nhưng lò chưa nóng đến nhiệt độ cần thêm vài phút nữa. Cho thức ăn vào lúc đèn vừa sáng thì món ra sống. depends_on canh đúng cái đèn nguồn đó: nó chỉ cho biết tiến trình đã được khởi động, không phải đã đạt nhiệt để phục vụ. Dòng này:

services:
  app:
    depends_on:
      - db

nghe như "đợi db xong đã". Nó không phải vậy. Tôi viết một ứng dụng ghi lại chính xác lúc nào nó kết nối được, rồi đo trên MySQL 8.

MOC-TCP=4.98s (that bai 97 lan)
MOC-SQL=4.99s (that bai 0 lan)

Lệnh up -d trả về sau 0,5 giây. Cổng 3306 mở ra ở giây thứ 4,98. Trong khoảng giữa, ứng dụng thử kết nối và trượt 97 lần.

depends_on mặc định dùng điều kiện service_started — nó chỉ bảo đảm tiến trình trong container db đã được khởi động. Không phải cổng đã mở, càng không phải CSDL đã sẵn sàng.

Nhãn Compose ghi rõ điều đó, như phần 49 đã thấy:

com.docker.compose.depends_on = db:service_started:false

Lời giải thích phổ biến về chuyện này lại sai

Cách kể quen thuộc là: "cổng mở trước, nhưng CSDL chưa nhận truy vấn được". Tôi đo riêng hai mốc đó để kiểm chứng.

Mốc MySQL 8 PostgreSQL 16
Cổng TCP mở 4,98 s 0,62 s
Truy vấn SQL đầu tiên chạy được 4,99 s 0,62 s
Khoảng cách 0,01 s 0,01 s

Gần như bằng không, ở cả hai. Cả hai image chính thức đều chạy phần khởi tạo trên socket nội bộ trước, chỉ mở cổng TCP ra ngoài khi đã sẵn sàng nhận truy vấn thật.

Nên khoảng mù không nằm giữa "cổng mở" và "sẵn sàng". Nó nằm trước khi cổng mở, và độ dài của nó là thời gian khởi tạo của CSDL: khoảng 0,6 giây với PostgreSQL, khoảng 5 giây với MySQL trên cùng máy đó.

Điều này quan trọng khi bạn viết script chờ: kiểm tra cổng TCP là đủ cho hai CSDL này. Với phần mềm khác thì phải tự đo, đừng suy diễn.

Cách vá: service_healthy

services:
  db:
    image: mysql:8.0
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-pbimat"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
  app:
    depends_on:
      db:
        condition: service_healthy

Đo lại trên cùng hệ thống:

Điều kiện up -d trả về sau Số lần ứng dụng kết nối trượt
service_started (mặc định) 0,5 s 97 lần, trong 5 giây
service_healthy 7,9 s 0 lần — nối được ngay lần đầu

Lệnh up giờ chờ cho tới khi healthcheck báo khoẻ. Nó chậm hơn, và đó chính là điều bạn muốn: thời gian chờ chuyển từ chỗ ứng dụng phải tự xoay xở sang chỗ Compose lo giúp.

Nhớ đặt start_period: trong khoảng đó, healthcheck thất bại không bị tính vào retries. Thiếu nó, một CSDL khởi động chậm sẽ bị đánh dấu unhealthy trước khi kịp lên.

Còn một điều kiện thứ ba, hợp cho job chạy một lần như migrate:

depends_on:
  migrate:
    condition: service_completed_successfully

Nhưng vẫn phải thử lại trong ứng dụng

service_healthy giải quyết lúc khởi động. Nó không giải quyết:

  • CSDL khởi động lại giữa ca (phần 28 đo được vòng lặp restart).
  • Mất kết nối mạng tạm thời.
  • Chạy trên Kubernetes hay Swarm, nơi không có depends_on.

Ứng dụng phải chịu được việc CSDL biến mất rồi quay lại, ở mọi thời điểm chứ không riêng lúc khởi động. depends_on chỉ làm cho lần khởi động đầu tiên êm ái hơn.

Vòng lặp thử lại tối thiểu:

for lan in range(30):
    try:
        ket_noi(); break
    except Exception:
        time.sleep(min(2 ** lan * 0.1, 5))
else:
    raise SystemExit("khong noi duoc CSDL sau 30 lan")

Và nhớ ghi log số lần đã thử — chính con số đó cho bạn biết khoảng mù trên hệ thống thật dài bao nhiêu.

Hai tín hiệu "chưa sẵn sàng" giả mà tôi vấp phải

Khi dựng phép đo này, ứng dụng của tôi báo kết nối thất bại suốt bốn phút dù MySQL đã ghi ready for connections trong log. Nếu tin con số đó, tôi đã công bố một khoảng mù dài vô tận.

Lần thứ nhất:

ERROR 2026 (HY000): TLS/SSL error: self-signed certificate in certificate chain

Lần thứ hai, sau khi tắt TLS:

ERROR 1045 (28000): Plugin caching_sha2_password could not be loaded

Cả hai đều không phải vấn đề sẵn sàng — một cái là chứng chỉ tự ký, một cái là client mariadb thiếu plugin xác thực của MySQL 8. Máy chủ đã sẵn sàng từ lâu; chỉ có client là không nói chuyện được với nó.

Bài học cho mọi script chờ: đừng gộp "kết nối thất bại" thành một loại. Một vòng lặp until ket_noi_duoc; do sleep 1; done sẽ quay vô hạn với cả hai lỗi trên, và log của bạn chỉ nói "đang chờ CSDL". Hãy in ra thông báo lỗi thật ở lần thất bại đầu tiên — nó là khác biệt giữa "chờ thêm 3 giây" và "cấu hình sai, chờ mãi cũng không xong".

Muốn tự đo khoảng mù của hệ thống mình, bấm giờ từ lúc up -d trả về tới lúc CSDL thật sự sẵn sàng:

docker compose down -v
S=$(date +%s)
docker compose up -d
echo "up -d tra ve sau $(( $(date +%s) - S ))s"
until docker compose exec -T db pg_isready -q 2>/dev/null; do sleep 1; done
echo "csdl san sang sau $(( $(date +%s) - S ))s"

Hiệu hai con số là khoảng thời gian ứng dụng của bạn đang chạy mà chưa có CSDL. Nếu nó lớn hơn vài giây, hai việc cần làm: thêm healthcheck kèm condition: service_healthy, và kiểm tra ứng dụng có vòng lặp thử lại chưa.

Mẫu số chung

Bài học đầu tiên, đọc thẳng từ 97 lần trượt: "đã khởi động" không phải "đã sẵn sàng" — một phụ thuộc có mặt khác với nó dùng được, và bắt tay nhau bằng nhầm tín hiệu (tiến trình đã lên) là tự tạo một cuộc đua lúc khởi động. Sửa đúng có hai lớp: phối hợp bằng tín hiệu thật sự nghĩa là sẵn sàng (service_healthy), và — quan trọng hơn — làm cho bên tiêu thụ chịu được việc phụ thuộc vắng mặt ở bất kỳ lúc nào, không riêng lúc khởi động. depends_on chỉ làm lần khởi động đầu êm ả; thứ khiến hệ thống bền là vòng lặp thử-lại, vì CSDL còn có thể biến mất giữa ca, còn Kubernetes thì chẳng có depends_on. Cùng ý ấy ở khắp nơi: thử-lại-có-backoff thay cho ép thứ tự khởi động, tự-hồi- phục thay cho sắp xếp tỉ mỉ, nối lại kết nối trên hệ nhất quán-cuối-cùng. Nguyên tắc: phối hợp trên đúng tín hiệu sẵn sàng, nhưng thiết kế bền nhất là cái không phụ thuộc vào thứ tự — nó cứ thử lại cho tới khi phụ thuộc có mặt.

Điều thứ hai, chính là hai lỗi TLS/xác thực làm tôi tưởng CSDL chưa lên: một vòng lặp chờ/thử-lại bắt buộc phải phân biệt lỗi tạm thời với lỗi vĩnh viễn, nếu không nó quay vô hạn trên cái vĩnh viễn — trong im lặng. until ket_noi_duoc; do sleep 1; done sẽ chờ mãi với một chứng chỉ tự ký hay một plugin xác thực sai, mà log chỉ nói "đang chờ CSDL". Máy chủ đã sẵn sàng từ lâu; chỉ có cấu hình client là hỏng. Cùng cái bẫy "gộp mọi thất bại làm một" ở khắp nơi: phân biệt lỗi đáng-retry với lỗi-đừng-retry, HTTP 4xx (tự sửa) khác 5xx (thử lại), một except: pass nuốt cả lỗi lập trình lẫn lỗi mạng. Nguyên tắc: in ra thông báo lỗi thật ở lần thất bại đầu, và để vòng lặp chỉ thử lại cái tạm thời — vì khác biệt giữa "chờ thêm 3 giây" và "cấu hình sai, chờ mãi cũng vô ích" nằm đúng ở dòng lỗi mà một vòng lặp mù đã ném đi.

Phần sau đo bốn tầng biến môi trường của Compose, và chỗ giá trị của bạn bị đọc sai kiểu.