Hình dung tấm biển "Trưởng phòng" gắn trên cửa một căn phòng. Bạn hẹn "3 giờ gặp Trưởng phòng", ghi vào sổ, yên tâm ra về. Nhưng tấm biển ấy chỉ là một miếng nhựa gắn tạm — đêm qua người ta đã tháo nó ra, gắn sang cửa phòng khác, và giờ "Trưởng phòng" là một con người hoàn toàn khác. Cái tên trong sổ của bạn không sai; nó chỉ đang trỏ tới một người mà bạn chưa từng gặp. Còn căn cước của từng người thì không ai tráo được — mỗi người một số, gắn chặt với đúng người đó. myapp:1.4.2 nghe như một danh tính, nhưng nó là tấm biển chức danh: một cái tên trỏ tới một nội dung, và cái tên đó có thể bị trỏ sang chỗ khác bất cứ lúc nào — kể cả sau khi bạn đã kiểm thử xong và đang chuẩn bị triển khai. Digest mới là căn cước.
Bài này dựng một registry thật (registry:2 chạy trên máy) rồi làm đúng chuyện tráo biển đó.
Đẩy đè lên chính một tag
Hai image chỉ khác nội dung một tệp, đẩy lần lượt lên cùng một tag:
day lan 1 | digest: sha256:4994d3d5...f692eb | noi dung: PHIEN BAN MOT
day lan 2 | digest: sha256:8dc1f84b...9b702c | noi dung: PHIEN BAN HAI
Tag giữ nguyên tên, digest đổi hẳn. Registry chấp nhận không một lời cảnh báo — vì đó là hành vi đúng theo thiết kế: tag là con trỏ có thể ghi được.
Hỏi thẳng registry xem tag đang trỏ đâu:
$ curl -I -H "Accept: application/vnd.oci.image.index.v1+json" \
http://localhost:5000/v2/ung-dung/manifests/moi-nhat
Docker-Content-Digest: sha256:8dc1f84b...9b702c
Máy đã có cache thì chạy bản nào
Đây là phần nguy hiểm, và cũng là lý do bài này tồn tại.
Tôi đặt một máy vào đúng tình huống thật: nó đã pull tag đó trước khi bản mới được đẩy lên.
docker run (khong pull) : PHIEN BAN MOT <-- cu
registry dang chua : PHIEN BAN HAI
sau docker pull : PHIEN BAN HAI
docker run không hỏi registry nếu tag đã có trong máy. Nó chạy thứ nó đang có, im lặng, và không có cách nào nhìn vào lệnh docker run mà biết được bạn đang chạy bản nào.
Đặt vào thực tế thì hình ảnh khá rõ: bạn đẩy bản vá lên myapp:1.4.2, ba máy chủ trong cụm đã có tag đó từ tuần trước, hai máy mới thì chưa. Sau khi triển khai, cụm của bạn chạy hai phiên bản khác nhau dưới cùng một cái tên — và mọi lệnh docker ps đều hiển thị giống hệt nhau. Đúng như tấm biển chức danh: cùng một chữ trên cửa, hai con người khác nhau bên trong.
Digest thì không đổi được
keo theo digest cu -> duoc | noi dung: PHIEN BAN MOT
Digest cũ vẫn kéo về được, và vẫn cho ra đúng nội dung cũ. Nó là băm của nội dung, nên không ai đẩy đè lên được: đổi một byte là ra một digest khác, không phải ghi đè lên digest cũ.
Đó là lý do digest — chứ không phải tag — mới là thứ dùng được khi bạn cần chắc chắn:
FROM localhost:5000/ung-dung@sha256:8dc1f84b...9b702c
Tôi dựng thử một image với FROM ...@digest và nó chạy đúng như mong đợi. Cú pháp này dùng được ở mọi chỗ nhận tên image: FROM, docker run, image: trong Compose, manifest Kubernetes.
Còn :latest thì sao
:latest không có nghĩa gì đặc biệt. Nó là một tag như mọi tag khác, chỉ khác ở chỗ Docker tự thêm nó vào khi bạn không ghi tag.
Phép thử: đẩy bản MỘT lên :latest, rồi đẩy bản HAI lên :1.0. Sau đó pull không ghi tag:
docker pull localhost:5000/thu
noi dung nhan duoc: PHIEN BAN MOT
Nhận được bản cũ hơn, vì :latest vẫn đang trỏ vào bản MỘT. "latest" không có nghĩa là "mới nhất" — nó chỉ có nghĩa là "cái tag tên là latest".
--pull=always đắt bao nhiêu
Cách chữa hiển nhiên cho chuyện cache cũ là luôn kiểm tra registry. Tôi đo cái giá của nó, với registry chạy ngay trên máy — tức là gần như không có độ trễ mạng:
| Thời gian | |
|---|---|
docker run bình thường |
239,3 ms |
docker run --pull=always |
1 904,8 ms |
Thêm 1 665 ms cho một registry ở localhost, khi không có gì thay đổi. Với registry thật ở xa, con số sẽ lớn hơn.
Nên --pull=always là công cụ đúng cho một số chỗ (máy CI, lệnh chạy một lần) nhưng là lựa chọn tệ nếu bạn khởi động container thường xuyên. Và nó vẫn không giải quyết được vấn đề gốc: bạn vẫn không biết mình đang chạy cái gì, chỉ biết là mình đang chạy cái mới nhất mà tag đó trỏ tới tại thời điểm đó.
Cách làm đúng
Ba mức, chọn theo mức độ nghiêm túc:
Mức tối thiểu — đừng bao giờ đẩy đè một tag phiên bản. 1.4.2 đã đẩy là bất biến; sửa gì thì ra 1.4.3. Tag di động như latest, stable, main thì được phép đổi, nhưng đừng dùng chúng để triển khai.
Mức thực dụng — ghi lại digest lúc dựng, triển khai bằng digest. Đường ống CI của bạn đẩy image, đọc digest trả về, rồi truyền digest đó xuống môi trường chạy:
digest=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' myapp:1.4.2)
echo "myapp@$digest"
Mức chặt — bật tính bất biến ở registry. Phần lớn registry thương mại có tuỳ chọn "immutable tags"; bật lên thì lần đẩy thứ hai vào cùng tag bị từ chối thẳng.
Và với image nền trong Dockerfile, ghim digest là cách duy nhất để build hôm nay và build sáu tháng sau cho ra cùng một thứ — FROM node:20-alpine hôm nay không phải FROM node:20-alpine của tháng trước.
Một điều đáng nhớ về docker images
Sau tất cả những phép đo trên, có một chỗ dễ nhầm: docker images hiển thị tag, không hiển thị digest. Muốn biết một image thật sự là gì:
docker inspect <ten>:<tag> --format '{{index .RepoDigests 0}}'
Nếu kết quả rỗng, image đó chưa từng được đẩy hoặc kéo từ registry nào — nó chỉ tồn tại trên máy bạn, và không có gì để đối chiếu.
Tự soi image đang chạy trên máy bạn
Xem các image đang chạy thật sự là bản nào:
docker ps --format '{{.Names}}\t{{.Image}}' | while read ten anh; do
printf "%-28s %s\n" "$ten" "$(docker inspect "$anh" --format '{{index .RepoDigests 0}}' 2>/dev/null || echo 'khong co digest')"
done
Hai container cùng hiện myapp:1.4.2 mà digest khác nhau nghĩa là chúng không chạy cùng một thứ — và đó chính là tình huống bài này vừa dựng lại.
Mẫu số chung
Bài học đầu tiên, chính là tấm biển chức danh trên cửa: một cái tên có thể trỏ lại được thì không bao giờ là một danh tính — muốn ghim đúng "cái này", hãy định danh bằng thứ bất biến, sinh ra từ chính nội dung, đừng bằng cái nhãn ai cũng gỡ dán được. Tag là con trỏ ghi được, digest là băm nội dung; chỉ digest mới chịu được câu hỏi "có chắc đúng bản đó không". Cùng cặp "con trỏ di động vs danh tính cố định" ở khắp nơi: trong Git, một branch là cái tên trườn theo mỗi commit còn SHA của commit thì đóng đinh; trong Java, một biến final chỉ tham chiếu là bất biến chứ đối tượng nó trỏ tới vẫn đổi ruột được, nên equals() (so nội dung) khác hẳn == (so danh tính ô nhớ); trong Go, go.mod ghi v1.4.2 cho dễ đọc nhưng chính go.sum băm nội dung mới là thứ chặn được chuyện ai đó đẩy đè lại cùng phiên bản; trong CSDL, một khoá chính nhân tạo bất biến mới an toàn để tham chiếu, còn cột "tên đăng nhập" hay "email" nhìn như định danh lại đổi được dưới chân bạn. Nguyên tắc: ở mọi ranh giới cần sự chắc chắn — triển khai, khoá ngoại, chữ ký, cache — hãy hỏi "định danh này có ghi đè lại được không"; nếu có, nó là cái tên tiện tay chứ không phải bằng chứng, và thứ bạn cần là một mã băm hoặc khoá bất biến.
Điều thứ hai, đọc từ docker run lặng lẽ chạy bản đã cache: một lớp tăng tốc bằng cách không hỏi lại nguồn thì đánh đổi luôn sự đúng-đắn — nó phục vụ nhanh cái nó đang giữ, kể cả khi nguồn đã đổi, mà không phát ra tín hiệu nào. Docker thấy tag đã có trong máy là chạy ngay, nên hai máy cùng một tag lại ôm hai nội dung khác nhau. Cùng cái bẫy "cache cũ im lặng" ở khắp nơi: trình duyệt chạy tệp JS cũ vì chưa hết TTL, CDN trả trang cũ ở rìa, DNS còn ghim IP cũ tới hết thời hạn, cache tầng hai của ORM trả entity đã lỗi thời sau khi hàng dưới đã đổi, một hàm memoize giữ kết quả cũ vì không ai báo cho nó biết đầu vào phụ đã thay. Nguyên tắc: khi một tầng đứng giữa bạn và nguồn để đi nhanh hơn, hãy hỏi ngay "nó kiểm lại nguồn lúc nào, và tôi buộc nó kiểm lại bằng gì, giá bao nhiêu" — vì mặc định của tốc độ là cũ mà không báo, và như 1 665 ms đo được, ép nó luôn hỏi lại không hề miễn phí; lời giải sạch không phải là bỏ cache mà là định danh bằng thứ bất biến để "trúng cache" đồng nghĩa với "đúng nội dung".
Phần sau mổ xẻ Dockerfile: mỗi lệnh sinh ra gì, và vì sao thứ tự viết quyết định thời gian build.