Hình dung đứa lớn được giao trông nhà khi bố mẹ đi vắng. Nó không còn là "một đứa trẻ bình thường" nữa, mà mang hai bổn phận lạ. Thứ nhất: khi có tiếng gọi "về đi, đóng cửa lại" (tín hiệu dừng), một đứa trẻ thường phản xạ nghe lời ngay; nhưng "người trông nhà" thì mặc định phớt lờ — trừ khi được dặn trước phải làm gì. Thứ hai: nếu có đứa em nào bị bỏ rơi giữa chừng, người trông nhà có trách nhiệm dọn dẹp sau chúng. PID 1 trong container chính là đứa được giao trông nhà đó, và nhân Linux đối xử với nó khác hẳn mọi tiến trình khác: nó không cài trình xử lý mặc định cho tín hiệu. Với tiến trình thường, SIGTERM không có handler nghĩa là chết. Với PID 1, không có handler nghĩa là bỏ qua.
Mà trong container, ứng dụng của bạn thường chính là PID 1.
Cùng chương trình, hai vị trí
Một script Python không đăng ký handler nào cho SIGTERM:
| PID 1 là gì | docker stop |
Mã thoát | |
|---|---|---|---|
| Chạy thẳng | python /khong-xu-ly.py |
5,15 s | 137 |
Có --init |
/sbin/docker-init -- python ... |
0,11 s | 143 |
Với --init, Docker chèn một init tối giản làm PID 1 và ứng dụng thành tiến trình con — nơi ngữ nghĩa tín hiệu bình thường áp dụng trở lại. SIGTERM giết nó ngay.
Hai mã thoát cũng nói lên chuyện đó:
- 137 = 128 + 9 =
SIGKILL. Docker hết kiên nhẫn và giết. - 143 = 128 + 15 =
SIGTERM. Tiến trình chết vì tín hiệu dừng, đúng như thiết kế.
Nhanh hơn 47 lần, chỉ bằng một cờ.
Việc thứ hai của init: dọn tiến trình mồ côi
PID 1 còn một nhiệm vụ nữa — nhận nuôi và dọn xác những tiến trình mất cha. Tôi viết một chương trình cố tình sinh ra 20 tiến trình cháu mồ côi rồi không bao giờ gọi wait():
| Zombie | Tổng tiến trình | |
|---|---|---|
Không --init |
20 | 24 |
Có --init |
0 | 5 |
Hai mươi mục zombie chiếm chỗ trong bảng tiến trình của nhân. Chúng không dùng CPU hay bộ nhớ, nhưng chúng chiếm PID — và một container chạy nhiều ngày với kiểu này sẽ chạm giới hạn PID rồi không fork được nữa, với triệu chứng là Resource temporarily unavailable ở một chỗ hoàn toàn không liên quan.
Ứng dụng nào hay dính? Bất cứ thứ gì gọi tiến trình ngoài: shell script chạy curl, ứng dụng gọi ffmpeg, thư viện sinh worker. Nếu ứng dụng của bạn không bao giờ fork thì không sao — nhưng bạn thường không biết chắc thư viện mình dùng có làm hay không.
--init không cứu được dữ liệu
Đây là chỗ dễ hiểu nhầm nhất. Tôi cho một ứng dụng ghi vào tệp có bộ đệm trong 3 giây rồi dừng container, và so số bản ghi ứng dụng đã viết với số thật sự nằm trên đĩa:
docker stop |
Mã | Đã viết | Trên đĩa | Mất | |
|---|---|---|---|---|---|
Không xử lý TERM, không --init |
5,2 s | 137 | 1 476 | 1 094 | 382 |
Không xử lý TERM, có --init |
0,2 s | 143 | 1 527 | 1 094 | 433 |
| Có xử lý TERM | 0,1 s | 0 | 1 520 | 1 520 | 0 |
--init làm container dừng nhanh gấp 26 lần — và mất nhiều dữ liệu hơn, vì nó chết sớm hơn nên còn ít thời gian để bộ đệm tự đầy rồi tràn xuống đĩa.
Chỉ hàng cuối cho số 0. Ứng dụng phải tự xử lý SIGTERM và tự flush:
def dong(a=None, b=None):
tep.flush()
os.fsync(tep.fileno())
tep.close()
sys.exit(0)
signal.signal(signal.SIGTERM, dong)
Nói cách khác: --init giải quyết vấn đề cơ chế (tín hiệu tới được nơi cần tới), còn việc dừng tử tế là vấn đề của ứng dụng. Cần cả hai.
STOPSIGNAL khi ứng dụng nghe tín hiệu khác
Một số chương trình dùng SIGQUIT hay SIGHUP để dừng tử tế — nginx và một số máy chủ cũ chẳng hạn. Tôi thử một ứng dụng chỉ nghe SIGHUP:
docker stop |
Mã thoát | Log cuối | |
|---|---|---|---|
Không khai STOPSIGNAL |
5,14 s | 137 | chi nghe SIGHUP... |
STOPSIGNAL SIGHUP |
0,10 s | 0 | NHAN SIGHUP, dong tu te |
STOPSIGNAL SIGHUP
Một dòng, và container chuyển từ "bị giết sau 5 giây" sang "dừng tử tế trong 0,1 giây".
Khai được ở cả ba chỗ: STOPSIGNAL trong Dockerfile, --stop-signal khi docker run, và stop_signal: trong Compose.
Bốn thứ cần đúng
Gộp lại từ bài này và phần 8:
- Dùng dạng exec cho
CMDvàENTRYPOINT, và entrypoint script kết thúc bằngexec "$@". Thiếu nó thìshlàm PID 1 và nuốt tín hiệu. - Thêm
--init(hoặcinit: truetrong Compose) trừ khi ứng dụng của bạn tự nó là một init tử tế. Nó rẻ và sửa cả hai vấn đề của PID 1. - Xử lý
SIGTERMtrong ứng dụng — đóng kết nối, ghi nốt bộ đệm, rời bộ cân bằng tải. - Đặt
--stop-timeouttường minh. Phần 3 đo được mặc định trên máy tôi là 1 giây, không phải 10 như tài liệu vẫn ghi.
Đọc mã thoát
Bảng nhỏ đáng dán lên tường:
| Mã | Nghĩa |
|---|---|
| 0 | tự thoát bình thường |
| 137 | SIGKILL — hết giờ ân hạn, hoặc bị OOM giết |
| 143 | SIGTERM — chết vì tín hiệu dừng, chưa chắc đã dừng tử tế |
| 139 | SIGSEGV, hoặc V8 chạm giới hạn heap (phần 26) |
Phân biệt hai nguyên nhân của 137 bằng OOMKilled, như phần 25 đã đo.
Tự đo container của bạn
docker run -d --name thu --stop-timeout 10 <anh-cua-ban>
docker exec thu ps -o pid,args | head -3
time docker stop thu
docker inspect thu --format 'ExitCode={{.State.ExitCode}}'
docker rm thu
Ba dấu hiệu cần nhìn:
- PID 1 là
/bin/sh→ thiếu dạng exec hoặc thiếuexectrong entrypoint script. timebáo gần bằng--stop-timeout→ ứng dụng không phản ứng vớiSIGTERM.ExitCode=137→ bị giết, và mọi bộ đệm chưa ghi đã mất.
Mẫu số chung
Bài học đầu tiên, chính là đứa được giao trông nhà: phần tử đầu tiên/gốc của một hệ thống thường mang ngữ nghĩa riêng, và cái luật đúng với mọi phần tử thường lại không còn đúng với nó. Với tiến trình thường, thiếu handler tín hiệu là chết; với PID 1, thiếu handler là phớt lờ — cùng một "mặc định" nhưng đảo ngược hậu quả. Cùng cái bẫy "vai trò gốc có luật riêng" ở khắp nơi: thread chính khác thread con (chỉ nó chạm được UI, chỉ nó kết thúc thì tiến trình mới thoát), node gốc của cây không có cha để trỏ về, phần tử canh (sentinel) đầu danh sách không phải dữ liệu thật, tài khoản root bỏ qua mọi kiểm tra quyền. Nguyên tắc: khi một phần tử giữ vai trò khởi đầu hay gốc, đừng bê nguyên trực giác về "phần tử thường" áp lên nó — hãy đọc kỹ ngữ nghĩa riêng mà hệ thống dành cho vai trò đó, vì đúng chỗ ấy cái mặc định an toàn cho số đông thành cái bẫy.
Điều thứ hai, đọc từ việc --init sửa được cơ chế nhưng không cứu được dữ liệu: hạ tầng đưa tín hiệu tới nơi là một chuyện, còn ứng dụng phản ứng đúng với tín hiệu là chuyện khác — đừng nhầm cơ chế với chính sách. --init đảm bảo SIGTERM chạm được ứng dụng, nhưng chỉ ứng dụng mới biết phải flush rồi mới thoát; thiếu vế sau, container tắt nhanh hơn mà lại mất nhiều dữ liệu hơn. Cùng ranh giới "cơ chế vs chính sách" ở khắp nơi: TCP lo gói tin tới đích còn xử lý nội dung là của bạn, CSDL cho bạn transaction còn commit/rollback đúng lúc là của bạn, framework treo sẵn cái hook onShutdown còn logic dọn dẹp là của bạn, khoá cho bạn công cụ loại trừ còn thứ tự khoá tránh deadlock là của bạn. Nguyên tắc: khi một lớp hạ tầng hứa "chuyển được tín hiệu/sự kiện tới", hãy hỏi ngay ai chịu trách nhiệm hành xử đúng khi nó tới — vì hai việc đó gần như không bao giờ do cùng một lớp lo, và thiếu vế nào cũng hỏng.
Phần sau đo bốn chính sách khởi động lại, và chuyện gì xảy ra khi ứng dụng hỏng vĩnh viễn.