Hãy tưởng tượng bạn để lại cho người bảo vệ ca đêm một tờ dặn dò: nếu cái máy dưới xưởng ngừng chạy thì phải làm gì. no là "kệ nó, sáng tôi tính". on-failure:3 là "nếu nó chết vì trục trặc thì bật lại, nhưng thử ba lần không lên được thì thôi". always là "cứ ngừng là bật, kể cả khi nó chạy xong việc và tự tắt". unless-stopped là "bật lại, trừ khi chính tôi là người tắt nó". Nhưng có một chỗ mù mà cả bốn tờ dặn đều chung: người bảo vệ chỉ liếc xem động cơ còn quay không — máy vẫn chạy mà nhả ra toàn phế phẩm thì anh ta không hề hay biết. Ứng dụng trong container hỏng lúc 3 giờ sáng cũng vậy: chuyện gì xảy ra tiếp theo hoàn toàn phụ thuộc vào một chuỗi ký tự bạn gõ lúc docker run — và ba trong bốn lựa chọn có hành vi mà tài liệu mô tả gọn tới mức dễ hiểu sai.

Tôi cho một ứng dụng chạy 0,2 giây rồi exit 1, chạy đủ bốn chính sách, quan sát 30 giây:

--restart RestartCount sau 30 s Trạng thái cuối
no (mặc định) 0 exited
on-failure:3 3 exited
always 9 restarting
unless-stopped 9 restarting

always và unless-stopped giống hệt nhau ở đây. Khác biệt của chúng nằm ở chỗ khác, đo ở cuối bài.

Khoảng nghỉ nhân đôi sau mỗi lần

Docker không quay vòng liên tục. Tôi đo mốc thời gian của từng lần khởi động lại:

Lần Cách lần trước
1 0,18 s
2 0,39 s
3 0,49 s
4 0,67 s
5 1,09 s
6 1,90 s
7 3,47 s
8 6,71 s
9 13,10 s
10 25,89 s

Từ lần 5 trở đi nó nhân đôi đều đặn, và trần là 60 giây. Sau 54 giây container mới thử được 10 lần; sau 5 phút chỉ thêm khoảng 4 lần nữa.

Hệ quả thực tế: container crash-loop trông như đã chết hẳn. Bạn nhìn docker ps thấy nó restarting, docker logs không có dòng mới, và bạn kết luận nó treo — trong khi thật ra nó đang chờ hết 60 giây để thử lần nữa. Cứ docker logs mà đọc từ đầu, thông báo lỗi thật nằm ở đó, lặp lại y hệt hàng chục lần.

Khoảng nghỉ nhân đôi này là thứ bảo vệ máy chủ: không có nó, một ứng dụng hỏng vì thiếu biến môi trường sẽ quay vòng vài nghìn lần mỗi phút và đốt sạch CPU cho việc dựng namespace.

always khởi động lại cả khi thoát thành công

Điểm khác nhau lớn nhất giữa on-failure và always không phải là số lần:

RestartCount sau 20 s Trạng thái
on-failure:3 0 exited (mã 0)
always 8 restarting

Ứng dụng thoát với mã 0 — chạy xong việc, không lỗi gì cả. on-failure để yên, đúng như tên gọi. always kéo dậy 8 lần trong 20 giây.

Nghĩa là đừng dùng always cho tác vụ có kết thúc: một job xử lý dữ liệu, một lệnh migrate CSDL, một script backup. Chúng chạy xong rồi thoát 0, và always biến chúng thành vòng lặp vô hạn. Với job, dùng no hoặc on-failure.

Docker cũng chặn một tổ hợp vô nghĩa:

$ docker run --rm --restart on-failure:5 ...
docker: conflicting options: cannot specify both --restart and --rm

Hợp lý — --rm xoá container ngay khi thoát, thì không còn gì để khởi động lại.

Ba chuyện restart policy KHÔNG làm

1. Không phản ứng với unhealthy

Đây là hiểu nhầm phổ biến nhất. Tôi chạy một container treo vĩnh viễn với healthcheck luôn hỏng, kèm --restart always, chờ 25 giây:

suc khoe = unhealthy
trang thai = running
RestartCount = 0
so lan healthcheck da chay = 5

Healthcheck chạy đủ 5 lần, tuyên bố unhealthy, và không có gì xảy ra cả. Container vẫn running, restart policy vẫn im.

Restart policy chỉ nhìn tiến trình PID 1 có thoát hay không. Healthcheck chỉ ghi một nhãn. Như phần 14 đã nói, cần một bộ điều phối (Swarm, Kubernetes, hoặc depends_on: condition: service_healthy trong Compose) mới có ai đó đọc nhãn đó và hành động.

Ứng dụng treo mà không chết là kịch bản xấu nhất cho Docker đơn lẻ: cổng vẫn mở, tiến trình vẫn sống, restart policy vẫn hài lòng, và mọi request đều treo.

2. Không reset hạn mức của on-failure:N

on-failure:2 với ứng dụng sống 15 giây rồi mới hỏng:

Thời điểm RestartCount Trạng thái
~20 s 1 running
~40 s 2 running
~60 s 2 exited
~100 s 2 exited

Hai lần khởi động lại là hạn mức của cả đời container, không phải "2 lần mỗi khoảng thời gian". Mười lăm giây chạy khoẻ mạnh không mua lại được lượt nào.

Điều đó có nghĩa là on-failure:5 cho một dịch vụ hỏng mỗi tuần một lần sẽ đứng hẳn sau năm tuần — vào một đêm ngẫu nhiên nào đó, chẳng liên quan gì tới sự cố nào cả.

3. docker start cũng không xoá hạn mức đó

Tôi thử tiếp: container đã cạn hạn mức, docker start bằng tay:

truoc:              RestartCount=2  exited
sau 'docker start': RestartCount=2  exited

Nó chạy đúng một lần rồi hỏng, và không khởi động lại nữa. Muốn xoá bộ đếm thì phải tạo lại container (docker rm rồi run, hoặc docker compose up --force-recreate).

Vì hai điều trên, với dịch vụ chạy dài tôi dùng unless-stopped chứ không dùng on-failure:N. Cái tên on-failure:5 nghe như "có bảo hiểm", thực tế nó là "hỏng lần thứ sáu thì tắt vĩnh viễn".

always khác unless-stopped ở đúng một chỗ

Cả hai chính sách này đều không kéo container dậy sau khi bạn dừng nó bằng tay:

Sau docker stop, chờ 5 s
always exited
unless-stopped exited

Khác biệt chỉ lộ ra khi daemon khởi động lại — máy chủ reboot, hoặc bạn cập nhật Docker. Tôi đo bằng một daemon Docker lồng trong container để không đụng tới máy thật: dừng tay cả hai, rồi khởi động lại daemon.

=== SAU KHI KHOI DONG LAI DAEMON ===
  c-unless: Exited (137) 28 seconds ago
  c-always: Up 5 seconds

always quên mất là bạn đã cố tình dừng nó và kéo dậy sau reboot. unless-stopped nhớ.

Đó là lý do unless-stopped gần như luôn là lựa chọn đúng cho dịch vụ chạy dài: bạn dừng một container để bảo trì, máy chủ reboot vì lý do khác, và bạn không muốn nó tự lên lại sau lưng bạn.

Chọn cái nào

Loại việc Chính sách
Job có kết thúc (migrate, backup, build) no
Job cần thử lại vài lần rồi thôi on-failure:3
Dịch vụ chạy dài unless-stopped
Dịch vụ hạ tầng phải lên cùng máy, kể cả sau khi dừng tay always

Trong Compose:

services:
  web:
    image: vi-du:1.0
    restart: unless-stopped
  migrate:
    image: vi-du:1.0
    command: ["/migrate.sh"]
    restart: "no"

Lưu ý restart: "no" phải có ngoặc kép. YAML đọc no trần thành giá trị boolean false, và Compose sẽ báo lỗi kiểu.

Kiểm tra container của bạn

docker inspect <ten> --format \
  'chinh sach={{.HostConfig.RestartPolicy.Name}} han muc={{.HostConfig.RestartPolicy.MaximumRetryCount}} da restart={{.RestartCount}}'

RestartCount lớn hơn 0 mà bạn không biết là dấu hiệu ứng dụng đã hỏng và tự dậy — không ai báo cho bạn cả. Ba con số đáng để đưa vào giám sát:

  • RestartCount tăng → có gì đó đang hỏng lặp lại.
  • Trạng thái restarting kéo dài → crash-loop, và khoảng nghỉ đã lên tới hàng chục giây.
  • Mã thoát 137 kèm OOMKilled=true → không phải lỗi ứng dụng, là giới hạn bộ nhớ (phần 25).

Và nhớ rằng khởi động lại chỉ hữu ích khi ứng dụng dừng tử tế — nếu nó bị SIGKILL mỗi lần như phần 27 đo được, mỗi lần restart là một lần mất dữ liệu trong bộ đệm.

Mẫu số chung

Cái bẫy đầu tiên, đúng cái chỗ mù của người bảo vệ: cơ chế tự hồi phục chỉ nhìn một tín hiệu rất thô — "tiến trình còn sống không" — và người ta hay lẫn nó với "nó còn làm được việc không". Bảng ở mục healthcheck nói tất cả: container unhealthy mà restart policy vẫn im, vì nó chỉ đọc việc PID 1 có thoát hay chưa, còn cái nhãn sức khoẻ thì phải có bộ điều phối khác đọc mới thành hành động. Ứng dụng treo nhưng chưa chết là kịch bản tệ nhất: cổng vẫn mở, tiến trình vẫn sống, mọi cái đèn báo sống vẫn xanh, mà request nào cũng treo. Cùng khoảng cách "còn sống" với "còn phục vụ" ấy ở khắp nơi: TCP keepalive thấy kết nối mở nhưng ứng dụng đầu kia đã deadlock, một luồng chưa chết nhưng đang chờ khoá mãi mãi, một endpoint / trả 200 trong khi CSDL phía sau đã rụng, liveness của Kubernetes khác readiness chính vì lẽ đó. Nguyên tắc: tín hiệu sống thô nhất gần như không bao giờ là tín hiệu bạn thật sự quan tâm — muốn biết một thứ có làm được việc không thì phải đo chính cái việc đó, đừng suy ra từ "nó chưa tắt".

Điều thứ hai, một khác biệt tinh vi mà đắt: "N lần trong một khoảng" và "N lần cho cả đời" là hai thứ hoàn toàn khác, và nhầm chúng là tự gài một quả bom hẹn giờ. on-failure:5 không phải "năm lần thử mỗi khi có sự cố" mà là "năm lần cho tới khi container này chết hẳn" — mười lăm giây chạy khoẻ không mua lại lượt nào, docker start tay cũng không xoá bộ đếm. Nên một dịch vụ hỏng-rồi-tự-dậy mỗi tuần một lần sẽ đứng vĩnh viễn sau năm tuần, vào một đêm chẳng liên quan gì tới sự cố nào. Cùng cái bẫy "hạn mức trọn đời giả dạng hạn mức theo nhịp" ở retry budget cạn dần không reset, ở circuit breaker cần tay gạt lại, ở token bucket so với một bộ đếm cố định, ở tuổi thọ tối đa của một kết nối trong pool. Nguyên tắc: với mọi giới hạn, hỏi cho rõ "nó có tự nạp lại không, và nạp theo cái gì" — một cái trần không bao giờ hồi lại trông hệt như an toàn cho tới đúng cái ngày nó lặng lẽ chạm đáy.

Phần sau bắt đầu nhóm bài về mạng: container nói chuyện với nhau bằng cách nào, và vì sao localhost trong container không phải localhost của bạn.