ImageHai mươi chỉ thị đi vào, tám lớp đi ra — và chỗ chênh nằm ở đâu
Tờ 03 để lại một câu hỏi: vì sao đúng tám dòng Pull complete? Tờ này trả lời bằng số kiểm chứng được, rồi đi tiếp tới chỗ thứ tự hai dòng trong Dockerfile quyết định bạn chờ hai giây hay mười ba giây.
Hai mươi chỉ thị, tám lớp
Tờ 03 kết thúc bằng một câu hỏi treo lại. Lúc kéo nginx:1.29-alpine về, màn hình in ra tám dòng Pull complete. Không phải mười, không phải hai mươi. Tám. Con số đó từ đâu ra?
$ docker image history nginx:1.29-alpine IMAGE CREATED CREATED BY SIZE 5616878291a2 4 months ago RUN /bin/sh -c set -x && … 51.8MB <missing> 4 months ago ENV ACME_VERSION=0.3.1 0B <missing> 4 months ago ENV NJS_RELEASE=1 0B <missing> 4 months ago CMD ["nginx" …] 0B <missing> 4 months ago STOPSIGNAL SIGQUIT 0B <missing> 4 months ago EXPOSE map[80/tcp:{}] 0B <missing> 4 months ago COPY 30-tune-worker… 16.4kB <missing> 4 months ago COPY docker-entrypoint… 8.19kB <missing> 4 months ago RUN /bin/sh -c addgroup… 5.59MB <missing> 4 months ago ENV NGINX_VERSION=1.29.8 0B <missing> 4 months ago ADD alpine-minirootfs… 9.11MB # 20 dòng. Chỉ 8 dòng có SIZE khác 0B.
Chỉ những chỉ thị chạm vào hệ thống tệp mới sinh ra lớp
Đây là luật trung tâm của cả bài, và nó gọn tới mức đáng học thuộc: lớp là ảnh chụp thay đổi của hệ thống tệp. Chỉ thị nào không đụng tới tệp thì không có gì để chụp.
Lớp 1, mã băm 29df493baa13, dung lượng 9.11MB. Sinh ra bởi chỉ thị ADD alpine-minirootfs. Đang hiện đủ hai mươi chỉ thị, gồm cả mười hai chỉ thị không sinh lớp.
$ docker image history nginx:1.29-alpine --no-trunc \ --format '{{.Size}}\t{{.CreatedBy}}' | grep -v '^0B' 51.8MB RUN /bin/sh -c set -x && apkArch=… 16.4kB COPY 30-tune-worker-processes.sh … 12.3kB COPY 20-envsubst-on-templates.sh … 12.3kB COPY 15-local-resolvers.envsh … 12.3kB COPY 10-listen-on-ipv6-by-default.sh … 8.19kB COPY docker-entrypoint.sh / # buildkit 5.59MB RUN /bin/sh -c set -x && addgroup -g 101 … 9.11MB ADD alpine-minirootfs-3.23.4-x86_64.tar.gz /
$ docker image history nginx:1.29-alpine --format '{{.Size}}' | wc -l 20 $ docker image inspect nginx:1.29-alpine --format '{{len .RootFS.Layers}}' 8
Hai mươi dòng lịch sử, tám dòng có SIZE khác 0B. Và RootFS.Layers — danh sách containerd thật sự dùng — cũng cho đúng tám. Đó chính là tám dòng Pull complete mà Tờ 03 để lại chưa giải thích.
Chồng giấy can
Image là một chồng giấy can trên bàn vẽ
Người vẽ kỹ thuật không vẽ lại cả bản vẽ mỗi lần sửa. Họ đặt một tờ giấy can mới lên trên và chỉ vẽ phần thay đổi. Tờ nền là mặt bằng, tờ thứ hai là hệ điện, tờ thứ ba là hệ nước. Nhìn thẳng từ trên xuống qua cả chồng thì thấy một bản vẽ hoàn chỉnh, dù chưa tờ nào chứa trọn nó.
| Ở bàn vẽ | Trong image |
|---|---|
| Tờ nền dưới cùng | ADD alpine-minirootfs… — 9.11MB |
| Mỗi tờ can đặt thêm | Một lớp, sinh bởi RUN, COPY hoặc ADD |
| Nhìn từ trên xuống | Hệ thống tệp mà tiến trình trong container thấy |
| Ghi chú viết ở lề chồng giấy | ENV, CMD, EXPOSE — siêu dữ liệu, không phải tờ nào cả |
| Tờ giấy trắng đặt trên cùng để nháp | Lớp ghi của container, đã gặp ở Tờ 03 |
| Hai bản vẽ dùng chung mấy tờ nền | Hai image dùng chung lớp trên cùng một máy |
Ba chỗ, và chỗ đầu tiên là chỗ làm lộ bí mật.
Một — giấy can trong suốt, tờ trên không xoá được tờ dưới. Lớp image thì che khuất. Bạn RUN rm /app/khoa.pem ở một lớp sau, nhìn vào container thì tệp đã biến mất — nhưng nó vẫn nằm nguyên trong lớp dưới, vẫn được tải về cùng image, và ai kéo image đó về cũng trích ra được. Bí mật lỡ đi vào một lớp thì phải dựng lại image, không có cách xoá tại chỗ.
Hai — ở bàn vẽ bạn rút một tờ ở giữa ra rồi đặt tờ khác vào. Lớp image thì không. Mỗi lớp được định danh bằng một mã băm tính trên toàn bộ chồng bên dưới nó, nên sửa một tờ ở giữa là mọi tờ nằm trên nó thành tờ khác. Đây chính là cơ chế đứng sau chuyện bộ nhớ đệm bị phá ở mục 06.
Ba — chồng giấy can là của riêng bản vẽ đó. Lớp image thì dùng chung, và dùng chung thật chứ không phải nói cho vui: hai image khác nhau trên cùng một máy trỏ vào cùng một tệp lớp trên đĩa. Mục 05 có bằng chứng bằng mã băm.
Tờ công thức, đọc từ trên xuống
Dockerfile là tệp văn bản liệt kê các chỉ thị, mỗi dòng một bước. BuildKit đọc từ trên xuống, chạy từng bước trong một container tạm, rồi đóng gói kết quả thành lớp.
FROM, nhóm sinh lớp. Cú pháp FROM tên image kèm tag. Chỗ hay sai: bỏ tag thì thành latest, và latest di chuyển được.
| Chỉ thị | Việc | Sinh lớp |
|---|---|---|
FROM | Chọn image nền để xây tiếp | Kế thừa lớp của image nền |
WORKDIR | Đặt thư mục làm việc cho các bước sau | Có, nhưng chỉ vài kB |
COPY | Chép tệp từ ngữ cảnh dựng vào image | Có |
RUN | Chạy lệnh lúc dựng, giữ lại kết quả | Có |
ENV | Đặt biến môi trường, còn hiệu lực lúc chạy | Không |
EXPOSE | Ghi chú cổng mà image dự định nghe | Không |
CMD | Lệnh mặc định khi tạo container | Không |
Nó là một dòng ghi chú trong siêu dữ liệu. Tờ 03 đã gặp hậu quả của việc hiểu nhầm điều này: cột PORTS in ra 80/tcp đứng trơ một mình chính là một EXPOSE không ai công bố. Muốn mở cổng thì vẫn phải có -p lúc chạy.
| Dạng | Viết | Chạy ra sao |
|---|---|---|
| exec | CMD ["python", "app.py"] | Chạy thẳng, tiến trình của bạn là PID 1, nhận đủ tín hiệu |
| shell | CMD python app.py | Bọc trong /bin/sh -c, shell là PID 1, tín hiệu dừng có thể không tới ứng dụng |
Một ứng dụng nhỏ, dựng từ đầu
Lý thuyết đủ rồi. Dựng một ứng dụng web Python thật, ba tệp, và đọc từng dòng BuildKit in ra.
$ cat requirements.txt flask==3.1.0 $ cat Dockerfile FROM python:3.12-alpine WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . EXPOSE 8000 CMD ["python", "app.py"]
Thứ tự năm chỉ thị giữa FROM và CMD không phải ngẫu nhiên. Mục 06 sẽ đo bằng giây cái giá của việc xếp sai thứ tự đó.
$ docker build --no-cache -t sotay:1 . #1 [internal] load build definition from Dockerfile #1 transferring dockerfile: 202B done #3 [internal] load .dockerignore #4 [internal] load build context #4 transferring context: 63B done #5 [1/5] FROM docker.io/library/python:3.12-alpine@… #6 [2/5] WORKDIR /app #6 CACHED #7 [3/5] COPY requirements.txt . #7 DONE 0.4s #8 [4/5] RUN pip install --no-cache-dir -r requirements.txt #8 7.888 Successfully installed flask-3.1.0 Jinja2-3.1.6 … #8 DONE 8.7s #9 [5/5] COPY app.py . #10 exporting to image #10 naming to docker.io/library/sotay:1 0.0s done
Đọc nhãn [3/5], [4/5]: BuildKit đánh số các bước sinh lớp. Chú ý là thứ tự in ra không theo số — #7 [2/5] có thể hiện sau #6 [3/5], vì BuildKit chạy song song những bước không phụ thuộc nhau.
$ diff <(docker image inspect sotay:1 --format '…') \ <(docker image inspect sotay:3 --format '…') 8c8 < sha256:b3f7deb3f6b1c57d5ee23d7c017ce5b07ff5ee17049a63ac0eaa635c… --- > sha256:0d1ee240a4be017e6f557c7311ee6f75bd53d1aa39023bcfd60fec1e… # Chỉ lớp thứ tám khác — đó là lớp chứa app.py.
Sửa một dòng mã, chờ hai giây hay mười ba giây
Bộ đệm dựng làm việc theo một luật đơn giản và tàn nhẫn: đi từ trên xuống, gặp bước đầu tiên có gì đó đổi thì bước đó và toàn bộ bước phía dưới đều phải chạy lại.
Nấc chưa sửa gì. Bản xếp đúng: không bước nào chạy lại. Bản xếp sai: không bước nào chạy lại. Cả hai hết khoảng 0,9 giây.
$ docker build -t sotay:3 . #6 [3/5] COPY requirements.txt . #6 CACHED #8 [4/5] RUN pip install --no-cache-dir -r requirements.txt #8 CACHED #9 [5/5] COPY app.py . #9 DONE 0.2s real 0m2.122s
$ docker build -f Dockerfile.xau -t sotay:xau2 . #6 [2/4] WORKDIR /app #6 CACHED #7 [3/4] COPY . . #8 [4/4] RUN pip install --no-cache-dir -r requirements.txt #8 8.944 Successfully installed flask-3.1.0 … real 0m12.983s
COPY . . chép cả app.py vào cùng một lớp với requirements.txt. Sửa app.py là lớp đó đổi, nên pip install nằm ngay dưới buộc phải chạy lại dù danh sách gói không đổi một chữ.
Thứ ít đổi đặt lên trên, thứ hay đổi đặt xuống dưới. Khai báo phụ thuộc đổi vài tuần một lần; mã nguồn đổi vài phút một lần. Vậy khai báo phụ thuộc phải nằm trên.
Dấu chấm ở cuối lệnh không phải trang trí
docker build -t sotay:1 . — dấu chấm đó là ngữ cảnh dựng. Trước khi chạy chỉ thị nào, Docker gói cả thư mục đó lại và gửi sang bộ dựng. Cả thư mục, kể cả thứ bạn quên mất là mình có.
Bước 1. Dấu chấm cuối lệnh docker build là ngữ cảnh dựng. Docker gói cả thư mục đó lại và gửi sang bộ dựng, kể cả thư mục .git nặng bốn mươi mê-ga. Không có tệp lọc. Ngữ cảnh dựng là 54,54 mê-ga, và rác nằm lại trong image, nặng 203 mê-ga.
$ du -sh . 53M . $ docker build -f Dockerfile.xau -t sotay:beo . #4 [internal] load build context #4 transferring context: 54.54MB 0.6s done $ cat .dockerignore .git __pycache__ *.pyc $ docker build -f Dockerfile.xau -t sotay:gon . #4 [internal] load build context #4 transferring context: 188B 0.0s done
$ docker image ls sotay WARNING: This output is designed for human readability. IMAGE ID DISK USAGE CONTENT SIZE sotay:beo 79cf08ca91e9 203MB 77.6MB sotay:gon ff0faebae84a 94.3MB 23MB
Từ 54,54 MB xuống 188 B. Và vì Dockerfile đó dùng COPY . ., đống rác kia không chỉ đi qua đường ống — nó nằm lại trong image.
latest không có nghĩa là mới nhất
Tag là một cái nhãn dán, và nhãn thì gỡ ra dán sang chỗ khác được. Digest là mã băm của chính nội dung, không đổi được.
Trước khi phát hành. Cả bốn cách gọi tên đều trỏ vào bản kê khai A, digest sha256 bắt đầu bằng 5616878291a2. Lúc này chúng cho ra cùng một image.
$ docker image inspect nginx:alpine --format 'alpine -> {{.ID}}' alpine -> sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d… $ docker image inspect nginx:1.29-alpine --format '1.29 -> {{.ID}}' 1.29 -> sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e…
Máy này kéo nginx:alpine về vào một ngày, nginx:1.29-alpine vào ngày khác. Hai mã băm khác nhau nghĩa là hai image khác nhau. Ai viết nginx:alpine trong tệp triển khai đang giao cho người khác quyền đổi phần mềm chạy trên máy chủ của mình.
Năm sai lầm tốn thời gian nhất khi dựng image
Đặt COPY . . lên trước bước cài phụ thuộc
Sai lầm đắt nhất trong bài này, vì nó không báo lỗi — nó chỉ lặng lẽ lấy của bạn mười một giây mỗi lần dựng. Mã nguồn đổi là lớp chứa mã nguồn đổi, và mọi bước nằm dưới nó chạy lại từ đầu.
Cách sửaChép tệp khai báo phụ thuộc trước, cài, rồi mới chép mã nguồn. Kiểm bằng cách sửa một dòng rồi dựng lại: bước cài phụ thuộc phải in ra CACHED.
Tưởng RUN rm xoá được tệp khỏi image
Ba lớp: lớp một chứa khoá, lớp ba dán một mẩu giấy đục lên nó. Khoá vẫn nằm trong image, vẫn được tải về cùng image, ai cũng trích ra được.
Cách sửaĐừng để bí mật đi vào lớp nào. Dùng --mount=type=secret của BuildKit, hoặc nạp bí mật lúc chạy. Đã lỡ đẩy image lên kho ảnh thì coi như khoá đã lộ và phải thu hồi khoá, không phải xoá image.
Dùng ADD cho việc mà COPY làm được
ADD có hai hành vi ngầm mà COPY không có: tự giải nén tệp nén cục bộ, và tải được URL. Cả hai đều là bất ngờ khó chịu khi bạn chỉ định chép một tệp.
Cách sửaMặc định dùng COPY. Chỉ dùng ADD khi bạn cố ý muốn giải nén một tệp tar. Cần tải từ mạng thì RUN với curl rõ ràng hơn, và kiểm tra được mã băm.
Dán lại MAINTAINER từ hướng dẫn cũ
Chỉ thị này đã bị bỏ từ lâu. Tài liệu năm 2017 còn đầy, nên nó vẫn được chép đi chép lại từ bài blog này sang bài blog khác.
Cách sửaLABEL org.opencontainers.image.authors="…". Nhãn theo chuẩn OCI thì công cụ khác đọc được, còn MAINTAINER thì không.
Dựng bằng bộ dựng cũ mà không biết
Dòng DEPRECATED: The legacy builder is deprecated trôi qua mà không ai đọc. Bộ dựng cũ không chạy song song, không có --mount=type=secret, và sắp bị gỡ hẳn.
Cách sửaCài gói docker-buildx-plugin từ kho chính thức của Docker, theo đúng cách Tờ 02 đã dựng. Kiểm bằng docker buildx version.
Kiểm tra hiểu bài
Năm câu, không giới hạn thời gian. Mỗi câu giải thích cả đáp án đúng lẫn vì sao các đáp án kia sai — đó mới là chỗ đổi được cách nghĩ.
docker image history nginx:1.29-alpine in ra hai mươi dòng, nhưng lúc kéo image về chỉ có tám dòng Pull complete. Vì sao?Chọn 1app.py rồi dựng lại. Với Dockerfile nào dưới đây thì bước RUN pip install in ra CACHED?Chọn 1RUN, dùng nó, rồi RUN rm khoa.pem ở bước sau. Khoá đó đã được xoá khỏi image.Đúng / Sai.dockerignore.Chọn nhiềuNăm điều mang về
Lớp là ảnh chụp thay đổi hệ thống tệp. Chỉ RUN, COPY, ADD sinh lớp. Hai mươi chỉ thị của nginx cho ra tám lớp — đúng tám dòng Pull complete của Tờ 03.
Lớp dùng chung được và không sửa tại chỗ được. Bảy trong tám lớp của hai image chỉ nằm một bản trên đĩa. Đổi một lớp là mọi lớp trên nó thành lớp khác.
Thứ tự chỉ thị là quyết định về tốc độ. Ít đổi lên trên, hay đổi xuống dưới. Đo được: 2,1 giây so với 13,0 giây cho cùng một thay đổi.
Dấu chấm cuối lệnh gửi cả thư mục đi. .dockerignore là tệp bắt buộc, không phải tuỳ chọn. 54,54 MB xuống 188 B.
Tag di chuyển được, digest thì không. latest là cái nhãn, không phải lời hứa. Trên máy chủ, ghim phiên bản là mức tối thiểu.
Còn một câu hỏi bài này cố tình chưa trả lời. Image chỉ đọc, lớp ghi của container thì mất cùng container — vậy dữ liệu người dùng, cơ sở dữ liệu, tệp tải lên sống ở đâu? Tờ 05 đóng chương một bằng đúng câu đó.