Hình dung gọi đồ ở một quán cà phê. ENTRYPOINT là món bạn gọi — một ly cà phê, cái phần cố định mà không lời dặn thêm nào biến nó thành bánh mì. CMD là tuỳ chọn mặc định ghi sẵn trên máy tính tiền: cỡ vừa, một đường. Bạn dặn "cỡ lớn, không đường" là ghi đè phần tuỳ chọn đó, nhưng vẫn là ly cà phê. Còn dạng shell của ENTRYPOINT giống một cái máy bán hàng hàn cứng vào đúng một sản phẩm: bạn bấm nút nào nó cũng nhả ra thứ cũ, và lời dặn của bạn bốc hơi không dấu vết. ENTRYPOINT và CMD đều nói cho Docker biết chạy gì; chúng khác nhau ở chỗ tham số dòng lệnh của người dùng đi đâu, và bốn tổ hợp cho ra bốn hành vi mà đọc tài liệu xong vẫn dễ nhầm.

Tôi dùng một script in ra chính tham số nó nhận được, rồi chạy từng tổ hợp hai lần: một lần không truyền gì, một lần truyền x y.

Bốn tổ hợp

1. Chỉ có CMD — CMD ["/in-ra.sh","a","b"]

khong tham so : so tham so=2 | tham so: a b
them 'x y'    : Error: exec: "x": executable file not found in $PATH

Tham số người dùng thay thế toàn bộ CMD. Docker cố chạy x như một chương trình và không tìm thấy. Đây là hành vi đúng của CMD: nó là mặc định có thể ghi đè hoàn toàn.

2. Chỉ có ENTRYPOINT — ENTRYPOINT ["/in-ra.sh","a","b"]

khong tham so : so tham so=2 | tham so: a b
them 'x y'    : so tham so=4 | tham so: a b x y

Tham số người dùng được nối thêm vào sau. ENTRYPOINT là phần cố định, không ghi đè bằng dòng lệnh thường được.

3. Cả hai — ENTRYPOINT ["/in-ra.sh","co-dinh"] + CMD ["mac-dinh"]

khong tham so : so tham so=2 | tham so: co-dinh mac-dinh
them 'x y'    : so tham so=3 | tham so: co-dinh x y

Đây là tổ hợp có ích nhất: ENTRYPOINT là chương trình, CMD là tham số mặc định cho nó, và người dùng ghi đè phần tham số mà không đụng tới chương trình. Đó là cách docker run postgres chạy PostgreSQL còn docker run postgres --version vẫn chạy PostgreSQL nhưng in phiên bản.

4. ENTRYPOINT dạng shell — ENTRYPOINT /in-ra.sh co-dinh + CMD ["mac-dinh"]

khong tham so : so tham so=1 | tham so: co-dinh
them 'x y'    : so tham so=1 | tham so: co-dinh

CMD bị bỏ qua hoàn toàn, và tham số x y của người dùng biến mất không dấu vết. Không lỗi, không cảnh báo, container chạy bình thường và làm sai thứ bạn yêu cầu.

Đây là lý do thật sự để luôn viết ENTRYPOINT ở dạng exec — không phải vì tín hiệu (chuyện đó phức tạp hơn, xem phần 5), mà vì dạng shell làm mọi tham số truyền vào bốc hơi trong im lặng.

Bảng tóm tắt

Không truyền gì Truyền x y
Chỉ CMD chạy CMD CMD bị thay hoàn toàn bởi x y
Chỉ ENTRYPOINT chạy ENTRYPOINT x y nối thêm vào cuối
Cả hai ENTRYPOINT + CMD CMD bị thay bởi x y
ENTRYPOINT dạng shell chỉ ENTRYPOINT x y bị bỏ qua

Script khởi động: một chữ exec đổi kết cục

Rất nhiều image dùng một script làm ENTRYPOINT để chuẩn bị trước khi chạy — chờ CSDL, sinh tệp cấu hình, đổi quyền. Cách viết script đó quyết định container có dừng được tử tế hay không.

#!/bin/sh
# kieu SAI
echo "chuan bi..."
python /app.py
#!/bin/sh
# kieu DUNG
echo "chuan bi..."
exec python "$@"

Đo với một ứng dụng bắt SIGTERM và in ra khi dừng tử tế:

PID 1 docker stop Mã thoát Log cuối
Không exec /bin/sh /vao.sh 5,13 s 137 dang chay
Có exec python /app.py 0,12 s 0 DUNG-TU-TE

Bản không có exec để sh ở lại làm PID 1, python thành tiến trình con PID 7. Docker gửi SIGTERM cho PID 1; sh nhận, không làm gì, và không chuyển tiếp cho con. Sau khi hết thời gian ân hạn, container bị SIGKILL — mã 137, ứng dụng chưa kịp đóng kết nối hay ghi nốt dữ liệu.

Bản có exec thì sh tự thay thế mình bằng python, nên python trở thành PID 1 và nhận tín hiệu trực tiếp. Dừng trong 0,12 giây với mã thoát 0.

"$@" đi cùng exec cũng quan trọng: nó chuyển tiếp mọi tham số mà CMD hoặc người dùng đưa vào, nên bạn giữ được toàn bộ hành vi của tổ hợp số 3 ở trên.

Nếu bạn chỉ nhớ một câu từ bài này: dòng cuối cùng của mọi entrypoint script phải bắt đầu bằng exec.

Ghi đè lúc chạy

ENTRYPOINT không phải là bất khả xâm phạm — chỉ là không ghi đè được bằng tham số thường:

$ docker run --rm --entrypoint /bin/echo ep2:dung "da ghi de"
da ghi de

Cờ --entrypoint rất hữu ích khi gỡ lỗi: image không chạy được thì mở shell vào xem có gì bên trong.

docker run --rm -it --entrypoint sh <anh-cua-ban>

Trong Compose thì tương ứng là entrypoint: và command: — và chúng theo đúng luật ở bảng trên, nên hiểu bảng đó là hiểu luôn Compose.

Chọn thế nào

  • Image là một công cụ (nginx, postgres, một CLI): dùng ENTRYPOINT cho chương trình, CMD cho tham số mặc định. Người dùng truyền cờ khác mà không phải nhớ tên chương trình.
  • Image là ứng dụng của bạn, chạy một cách duy nhất: chỉ CMD dạng exec là đủ, và người khác dễ dàng docker run ... sh để soi khi cần.
  • Cần chuẩn bị trước khi chạy: ENTRYPOINT ["/vao.sh"] + CMD [...], và script kết thúc bằng exec "$@".
  • Đừng bao giờ dùng dạng shell cho ENTRYPOINT.

Muốn tự kiểm image của mình, soi ba thứ cùng lúc — Entrypoint/Cmd, PID 1, và mã thoát khi stop:

docker inspect <anh> --format 'Entrypoint={{.Config.Entrypoint}} Cmd={{.Config.Cmd}}'
docker run -d --name thu <anh>
docker exec thu ps -o pid,args | head -3
docker stop thu && docker inspect thu --format 'ExitCode={{.State.ExitCode}}'
docker rm thu

Dòng đầu mà hiện Entrypoint=[/bin/sh -c ...] nghĩa là bạn đang ở tổ hợp thứ tư, và mọi tham số người dùng truyền vào đang bị bỏ qua. PID 1 là /bin/sh cộng ExitCode=137 nghĩa là entrypoint script của bạn thiếu exec.

Mẫu số chung

Bài học đầu tiên, chính là tổ hợp có ích nhất (ENTRYPOINT + CMD): thiết kế tốt tách phần bất biến khỏi phần người gọi được tinh chỉnh, rồi phơi điểm chỉnh ra ở đúng độ hạt an toàn. ENTRYPOINT cố định chương trình, CMD là tham số mặc định mà người dùng ghi đè được — nên docker run postgres chạy Postgres, docker run postgres --version vẫn là Postgres nhưng in phiên bản; bạn không lỡ tay biến nó thành thứ khác. Cùng khuôn "mặc định hợp lý + điểm ghi đè đúng chỗ" ở khắp nơi: tham số hàm có giá trị mặc định + ghi đè theo tên, cấu hình có env-var đè lên, template method (khung cố định + bước cho phép override), API nhận options. Nguyên tắc: tách rõ cái không được đổi khỏi cái người gọi được phép chỉnh, và mở chỗ chỉnh ở đúng mức an toàn để đổi — không quá cứng (chẳng ai tuỳ biến được) cũng không quá lỏng (một tham số lạc làm hỏng cả chương trình).

Điều thứ hai, đọc từ dạng shell nuốt tham số và thiếu exec gây mã 137: một lớp đứng giữa phải trong suốt với thứ đi xuyên qua nó — chuyển tiếp trung thực tham số, tín hiệu, lỗi — nếu không nó âm thầm đổi hành vi, và "âm thầm nuốt mất" là kiểu hỏng tệ nhất. Script entrypoint là một wrapper; thiếu exec "$@" là nó giữ sh ở giữa, chặn SIGTERM không cho tới ứng dụng (137), và bỏ rơi tham số. Cùng cái bẫy "lớp trung gian không trong suốt" ở khắp nơi: một decorator đánh rơi kwargs, một middleware nuốt ngoại lệ, một reverse proxy tước header, một hàm bao bọc quên trả lại giá trị. Nguyên tắc: một lớp bao chỉ nên biến đổi đúng cái nó cố ý biến đổi và chuyển tiếp trung thực mọi thứ còn lại — dùng exec để nhường chỗ PID 1 cho tiến trình thật, dùng "$@" để không đánh rơi tham số, và luôn hỏi "cái gì đi vào lớp này có ra nguyên vẹn ở đầu kia không".

Phần sau là multi-stage build — cách tách môi trường build khỏi runtime, và image nhỏ đi bao nhiêu.