6. Containerize một ứng dụng
Mục lục
- 1. Containerize một ứng dụng — quy trình tổng quan
- 2. Lấy code & đọc hiểu Dockerfile
- 3. Build image, chạy & test
- 4. Nhìn kỹ hơn: instruction nào tạo layer, instruction nào chỉ tạo metadata
- 5. Multi-stage build — giữ image production nhỏ gọn
- 6. BuildKit, .dockerignore & HEALTHCHECK — build nhanh hơn, image đáng tin hơn
- 7. Volume — lưu dữ liệu bền vững đúng cách
Phần 5 đã nói qua vòng đời container, và có một lời hứa còn treo: cách bền vững thật sự để lưu dữ liệu là dùng volume, chưa nói kỹ ở đâu cả — phần này sẽ trả nợ đúng chỗ đó. Nhưng trước hết, quay lại việc chính: tự tay biến 1 ứng dụng thành Docker image, thay vì chỉ pull image có sẵn như các phần trước.
1. Containerize một ứng dụng — quy trình tổng quan
Docker xoay quanh việc lấy ứng dụng và chạy nó dưới dạng container. Quá trình cấu hình 1 ứng dụng để chạy được như container gọi là "containerize" (đôi khi gọi là "Dockerize"). Container luôn xoay quanh app — mục tiêu là làm cho việc build, ship, và run ứng dụng trở nên đơn giản.
Quy trình containerize 1 ứng dụng gồm 4 bước:
- Bắt đầu từ code ứng dụng.
- Viết 1 Dockerfile mô tả ứng dụng, dependency của nó, và cách chạy nó.
- Đưa Dockerfile này vào lệnh
docker image build. - Ngồi chờ Docker build ứng dụng thành 1 Docker image.
Sau khi containerize xong (đã thành 1 Docker image), bạn sẵn sàng ship và chạy nó như 1 container.
2. Lấy code & đọc hiểu Dockerfile
Ví dụ dưới đây containerize 1 web app Node.js đơn giản (quy trình với ứng dụng Windows cũng tương tự).
Lấy code ứng dụng
Giả sử bạn đã có sẵn source code 1 web app Node.js đơn giản trong thư mục dự án hello-web:
$ cd hello-web
$ ls -l
app.js Dockerfile package.json README.md test/ views/
Thư mục này chứa toàn bộ source code ứng dụng, cùng subdirectory cho view và unit test.
Đọc Dockerfile
Thư mục chứa code ứng dụng gọi là build context. Thường nên đặt Dockerfile ngay tại thư mục gốc của build context. Tên file phải viết đúng Dockerfile — chữ D viết hoa, liền 1 từ (dockerfile hay Docker file đều không hợp lệ).
💡 Hình dung: Dockerfile giống 1 công thức nấu ăn — liệt kê rõ nguyên liệu gốc (
FROM), các bước sơ chế/nấu theo đúng thứ tự (RUN,COPY), và ghi chú cách trình bày món ăn khi dọn ra (ENTRYPOINT,EXPOSE). Ai cũng có thể cầm đúng công thức đó lên và ra được đúng 1 món giống hệt, bất kể nấu ở bếp nào.
FROM alpine
LABEL maintainer="[email protected]"
RUN apk add --update nodejs nodejs-npm
COPY . /src
WORKDIR /src
RUN npm install
EXPOSE 8080
ENTRYPOINT ["node", "./app.js"]
Dockerfile có 2 mục đích chính: mô tả ứng dụng, và hướng dẫn Docker cách containerize nó (tạo ra 1 image chứa app bên trong). Đừng đánh giá thấp giá trị tài liệu hóa của Dockerfile — nó bắc cầu giữa dev và ops, và giúp rút ngắn thời gian onboard developer mới, vì nó mô tả chính xác ứng dụng và dependency của nó theo định dạng dễ đọc.
Đọc từng dòng:
FROM alpine— mọi Dockerfile đều bắt đầu bằngFROM, đây sẽ là base layer của image, mọi phần còn lại của app được thêm vào như các layer phía trên. Vì đây là app Linux,FROMphải trỏ tới 1 image gốc Linux (với app Windows, cần chỉ định image gốc Windows tương ứng, ví dụmicrosoft/aspnetcore-build).LABEL maintainer="..."— label là cặp key-value đơn giản, cách tốt để gắn thêm metadata tùy chỉnh vào image. Ghi rõ maintainer là best practice, để người dùng sau này có điểm liên hệ.RUN apk add --update nodejs nodejs-npm— dùng package managerapkcủa Alpine để càinodejs/nodejs-npm, tạo thêm 1 layer mới chồng lên base layer.COPY . /src— copy toàn bộ file app từ build context vào image, tạo thêm 1 layer.WORKDIR /src— đặt working directory cho các instruction phía sau; đây chỉ là metadata gắn vào image config, không tạo layer mới.RUN npm install— cài dependency liệt kê trongpackage.json, chạy trong context củaWORKDIRvừa đặt, tạo thêm 1 layer.EXPOSE 8080— ghi chú (document) rằng app expose service ở cổng TCP 8080 — chỉ là metadata, không tạo layer.ENTRYPOINT ["node", "./app.js"]— đặt chương trình chính chạy khi container khởi động từ image này — cũng chỉ là metadata.
3. Build image, chạy & test
Build
$ docker image build -t web:latest .
Dấu . cuối lệnh báo Docker dùng working directory hiện tại của shell làm build context — nhớ luôn chạy lệnh này từ đúng thư mục chứa Dockerfile và code app.
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
web latest e33cdd8266d0 About a minute ago 55.6MB
Vậy là app đã được containerize! Dùng docker image inspect web:latest để xem lại toàn bộ cấu hình được set từ Dockerfile.
Chạy & test
$ docker container run -d --name c1 -p 80:8080 web:latest
-d chạy nền, -p 80:8080 map cổng 80 của host vào cổng 8080 bên trong container (nếu host đã có service khác dùng cổng 80, đổi sang cổng khác, ví dụ -p 5000:8080).
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
82a4f9c1d388 web:latest "node ./app.js" 2 minutes ago Up 2 minutes 0.0.0.0:80->8080/tcp c1
Mở trình duyệt, trỏ tới DNS name/IP của host — sẽ thấy trang web của app. Nếu không kết nối được: kiểm tra lại container đang chạy đúng port mapping (docker container ls), và kiểm tra firewall/network security có đang chặn cổng 80 trên host không.
4. Nhìn kỹ hơn: instruction nào tạo layer, instruction nào chỉ tạo metadata
Dòng comment trong Dockerfile bắt đầu bằng #. Mọi dòng còn lại là instruction, dạng INSTRUCTION argument — tên instruction không phân biệt hoa/thường nhưng theo quy ước thường viết HOA để dễ đọc. docker image build đọc Dockerfile tuần tự từng dòng từ trên xuống.
Một số instruction tạo layer mới (FROM, RUN, COPY), số khác chỉ thêm metadata (EXPOSE, WORKDIR, ENV, ENTRYPOINT...). Nguyên tắc chung: nếu instruction thêm nội dung (file, chương trình) vào image → tạo layer mới; nếu chỉ là hướng dẫn cách build/chạy → chỉ tạo metadata.
Xem lại các instruction đã dùng để build image bằng docker image history:
$ docker image history web:latest
So khớp với docker image inspect web:latest (phần RootFS.Layers) sẽ thấy: dù output của docker image history trông như mọi dòng đều tạo layer, thực tế chỉ 4 layer có dữ liệu thật (ứng với các instruction FROM, RUN, COPY) — phần còn lại chỉ là metadata.
Vài lưu ý thêm:
- Nên dùng image từ repository chính thức với
FROM, vì chúng thường theo best practice và ít lỗ hổng bảo mật đã biết hơn. - Nên
FROMtừ image càng nhỏ càng tốt — giảm attack surface đã nói ở Phần 5 (image càng nhỏ, càng ít package/service đi kèm, càng ít điểm có thể bị khai thác). - Quy trình build thực chất: dựng 1 container tạm → chạy instruction của Dockerfile bên trong container đó → lưu kết quả thành 1 layer image mới → xóa container tạm. Lặp lại cho từng instruction tạo layer.
5. Multi-stage build — giữ image production nhỏ gọn
Với Docker image, to là xấu: to nghĩa là chậm, khó làm việc cùng, và attack surface lớn. Vì vậy image nên nhỏ gọn — chỉ nên ship lên production đúng những gì cần để chạy app.
Vấn đề là: giữ image nhỏ vốn không dễ. Cách viết Dockerfile ảnh hưởng rất lớn tới kích thước image cuối — ví dụ mỗi RUN đều tạo 1 layer mới, nên thường nên gộp nhiều lệnh vào chung 1 RUN (nối bằng && và xuống dòng bằng \). Vấn đề khác: không dọn dẹp sau khi build — build tool/dependency chỉ cần lúc build lại vô tình bị đóng gói theo cả vào image production.
Trước đây có cách giải quyết gọi là builder pattern: cần ít nhất 2 Dockerfile — 1 bản Dockerfile.dev (base image lớn, kéo theo build tool, build app), build ra image + container tạm, rồi 1 bản Dockerfile.prod (base image nhỏ hơn) copy app đã build từ container tạm đó sang — toàn bộ phải nối lại bằng 1 script riêng. Khả thi, nhưng khá phức tạp.
Multi-stage build giải quyết đúng vấn đề này mà không cần thêm độ phức tạp: chỉ cần 1 Dockerfile duy nhất, chứa nhiều instruction FROM — mỗi FROM là 1 build stage riêng, có thể COPY artifact từ stage trước đó sang.
💡 Hình dung: giống nấu ăn trong 1 bếp nhà hàng bừa bộn — nồi niêu, dao thớt, rau vụn vương vãi khắp nơi trong lúc nấu — nhưng khi bưng ra bàn cho khách, chỉ đúng 1 đĩa thức ăn sạch sẽ được mang ra, không ai bưng theo cả đống nồi niêu bẩn. Stage
builderchính là cái bếp bừa bộn đó; stageproductionchỉ nhận đúng "đĩa thức ăn" (file binary đã build xong), bỏ lại toàn bộ "bếp" phía sau.
Ví dụ
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/api ./cmd/api
FROM alpine:latest AS production
RUN adduser -D appuser
COPY --from=builder /out/api /app/api
USER appuser
EXPOSE 8080
ENTRYPOINT ["/app/api"]
Dockerfile này có 2 instruction FROM — tương ứng 2 build stage, đánh số nội bộ từ 0, nhưng đã được đặt tên gợi nhớ riêng:
- Stage
builder: dùng imagegolang:1.22-alpine(vài trăm MB, kèm sẵn toàn bộ Go toolchain) — tải dependency, rồi compile code Go thành 1 binary tĩnh duy nhất (CGO_ENABLED=0để không phụ thuộc thư viện C động của hệ thống). Kết quả là 1 image rất lớn chứa cả compiler lẫn source code, chỉ dùng để build chứ không phải để chạy production. - Stage
production: dùng imagealpine:latest— chỉ vài MB, nhỏ hơn hẳn stage build.RUN adduser -D appusertạo 1 user thường (không phải root), rồiUSER appuserchuyển toàn bộ instruction phía sau (và cả tiến trình app lúc container chạy) sang chạy dưới quyền user đó — best practice bảo mật: nếu app bị khai thác lỗ hổng, kẻ tấn công cũng chỉ có quyền của user thường, không có quyền root bên trong container. Sau đó dùngCOPY --from=builderđể chỉ lấy đúng file binary đã build xong từ stage trước — không copy theo compiler, source code, hay bất kỳ dependency build-time nào.
Điểm quan trọng: COPY --from chỉ lấy đúng phần liên quan tới production, không kéo theo rác build. Và tất cả chỉ cần 1 Dockerfile duy nhất — không cần thêm tham số gì đặc biệt cho docker image build.
$ docker image build -t api:latest .
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
golang 1.22-alpine 8f3e2a1b9c0d 2 weeks ago 358MB
<none> <none> 6598db3cefaf 3 mins ago 381MB
alpine latest 9e1a4c8d0f2e 1 week ago 7.4MB
api latest 3dc0d5e6223e 1 min ago 14.8MB
Dòng cuối (api:latest) chính là image production cuối cùng — nhỏ hơn hẳn image build phía trên (14.8MB so với hơn 380MB), vì nó dựa trên base nhỏ (alpine:latest) và chỉ mang theo đúng 1 file binary cần thiết từ stage build. Kết quả: 1 image production nhỏ gọn, tạo ra chỉ bằng 1 Dockerfile, 1 lệnh docker image build bình thường, và không cần thêm bất kỳ script phụ nào.
Multi-stage build là tính năng mới từ Docker 17.05 tại thời điểm sách gốc viết — hiện đã là kỹ thuật tiêu chuẩn để build image production nhỏ gọn.
Theo kinh nghiệm thực tế, phần lớn Dockerfile viết ẩu không phải vì thiếu kiến thức mà vì bỏ qua đúng thói quen ở đây: gộp RUN cẩn thận, dùng multi-stage build ngay từ đầu thay vì "để sau tối ưu", và luôn tự hỏi "cái gì trong image này thực sự cần lúc chạy, chứ không chỉ lúc build". Một image nhẹ vài chục MB thay vì vài trăm MB không chỉ đỡ chi phí lưu trữ/băng thông — nó còn kéo theo attack surface nhỏ hơn, pull/deploy nhanh hơn, và ít bất ngờ hơn khi debug production.
6. BuildKit, .dockerignore & HEALTHCHECK — build nhanh hơn, image đáng tin hơn
BuildKit — builder mặc định từ Docker 23.0
Mọi ví dụ docker image build ở trên thực chất đã chạy qua BuildKit — builder engine mới, thay thế hẳn "classic builder" (legacy builder) cũ, và là mặc định kể từ Docker 23.0 (2023). Classic builder giờ gần như đã nghỉ hưu. Khác biệt cốt lõi: classic builder build tuần tự từng instruction, đúng thứ tự viết trong Dockerfile; BuildKit phân tích Dockerfile thành 1 dependency graph, rồi chạy song song những bước không phụ thuộc lẫn nhau — build nhanh hơn đáng kể, nhất là với multi-stage build có nhiều stage độc lập.
💡 Hình dung: classic builder giống 1 đầu bếp duy nhất làm tuần tự từng món trong thực đơn, món sau chỉ bắt đầu khi món trước xong hẳn. BuildKit giống cả 1 dây chuyền bếp — bước nào không phụ thuộc bước khác (sơ chế rau, ướp thịt) được làm song song ngay từ đầu, chỉ những bước thực sự cần chờ nhau (nấu chín rồi mới trình bày) mới xếp hàng tuần tự.
BuildKit còn hỗ trợ cache mount — cache riêng cho package manager (npm, pip, apt...) sống sót qua nhiều lần build, tách biệt hẳn khỏi layer cache thông thường:
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install
COPY . .
Dòng # syntax=docker/dockerfile:1 ở đầu file là cách khai báo tường minh "Dockerfile này cần cú pháp BuildKit" — nên có mặt trong mọi Dockerfile mới, kể cả khi không dùng tính năng nâng cao nào, để luôn nhận được bản parser mới nhất bất kể phiên bản Docker Engine đang chạy là gì. --mount=type=cache,target=/root/.npm giữ lại cache của npm giữa các lần build khác nhau — lần build sau, dependency đã tải trước đó không cần tải lại, dù layer COPY package*.json phía trên đã invalidate cache thông thường.
.dockerignore — đừng gửi thừa vào build context
Phần 6 §2 đã nói: docker image build gửi toàn bộ thư mục (build context) tới daemon, không chỉ đúng Dockerfile. Nếu thư mục dự án có node_modules/, .git/, file .env chứa secret, hay log cũ hàng trăm MB — tất cả đều bị gửi đi dù Dockerfile không hề dùng tới, làm chậm build và có nguy cơ rò rỉ secret vào image nếu vô tình COPY . ..
.dockerignore (đặt cùng cấp Dockerfile, cú pháp giống .gitignore) loại các file/thư mục đó khỏi build context trước khi gửi đi:
node_modules
.git
.env
*.log
Dockerfile.dev
⚠️ Lưu ý:
.dockerignorekhông mã hóa hay bảo vệ gì cả — nó chỉ ngăn file lọt vào build context. Secret thật sự (API key, mật khẩu DB...) không nên nằm trong thư mục project ở dạng plaintext dù có.dockerignorehay không — dùng Docker secret hoặc biến môi trường bơm lúc runtime.
HEALTHCHECK — để Docker tự biết container có "sống" đúng nghĩa không
docker container ls chỉ báo STATUS: Up khi tiến trình PID 1 còn chạy — nhưng "còn chạy" không có nghĩa là còn phục vụ được: 1 web server có thể vẫn chạy (Up) trong khi bị deadlock, hết connection pool, hay treo hoàn toàn. HEALTHCHECK giải quyết đúng khoảng trống này — khai báo 1 lệnh Docker tự chạy định kỳ bên trong container để xác nhận app còn phục vụ được thật sự, không chỉ "chưa chết":
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -q --spider http://localhost:8080/health || exit 1
--interval=30s: 30 giây kiểm tra 1 lần.--timeout=3s: lệnh check phải trả lời trong 3 giây, quá hạn tính là fail.--retries=3: fail liên tiếp 3 lần mới đổi trạng thái sangunhealthy.
$ docker container ls
# Output minh họa
CONTAINER ID IMAGE STATUS PORTS
82a4f9c1d388 web:latest Up 5 minutes (healthy) 0.0.0.0:80->8080/tcp
(Output minh họa — cột STATUS hiện thêm (healthy)/(unhealthy)/(starting) ngay bên cạnh Up; các công cụ orchestration như Swarm/Kubernetes dựa vào đúng trạng thái này để quyết định có nên route traffic vào container hay tự động thay thế nó.)
7. Volume — lưu dữ liệu bền vững đúng cách
Phần 5 đã chứng minh container có thể giữ dữ liệu qua stop/start — nhưng đó không phải cách khuyến nghị để lưu dữ liệu thật sự quan trọng, vì dữ liệu lúc đó vẫn nằm chết dí trong writable layer của đúng 1 container: xóa container (docker container rm) là mất trắng, và không container nào khác chia sẻ được dữ liệu đó. Volume giải quyết đúng vấn đề này — tách hẳn dữ liệu ra khỏi vòng đời container.
💡 Hình dung: writable layer của container giống 1 cuốn sổ tay cá nhân — mất cuốn sổ (xóa container) là mất luôn nội dung, và không ai khác đọc chung được. Volume giống 1 tủ hồ sơ đặt riêng ngoài phòng — nhân viên (container) nào cũng mở tủ ra đọc/ghi được, nhân viên nghỉ việc (container bị xóa) thì tủ hồ sơ vẫn còn nguyên đó cho người tiếp theo.
Named volume vs bind mount
Docker có 2 cách chính để đưa dữ liệu bền vững vào container:
- Named volume: Docker tự tạo và quản lý hẳn 1 vùng lưu trữ riêng (mặc định nằm ở
/var/lib/docker/volumes/trên Linux) — không cần biết chính xác nó nằm ở đâu trên host, chỉ cần gọi đúng tên. - Bind mount: mount thẳng 1 thư mục có sẵn trên host (đường dẫn tuyệt đối do bạn chỉ định) vào bên trong container — thường dùng khi cần chia sẻ code đang phát triển (live-reload) hoặc truy cập file host đã có sẵn từ trước.
# Named volume — Docker tự quản lý nơi lưu trữ
$ docker volume create db-data
$ docker container run -d --name db \
--mount source=db-data,target=/var/lib/mysql \
mysql:8
# Bind mount — chỉ định thẳng thư mục host
$ docker container run -d --name dev \
--mount type=bind,source="$(pwd)"/src,target=/app/src \
myapp:dev
$ docker volume ls
# Output minh họa
DRIVER VOLUME NAME
local db-data
$ docker volume inspect db-data
# Output minh họa (rút gọn)
[
{
"Name": "db-data",
"Driver": "local",
"Mountpoint": "/var/lib/docker/volumes/db-data/_data"
}
]
(Output minh họa — Mountpoint là đường dẫn thật trên host nơi dữ liệu volume nằm; với bind mount thì không có entry tương ứng trong docker volume ls, vì bind mount không phải 1 volume do Docker quản lý.)
Xóa container không tự xóa volume nó dùng (đây chính là điểm mấu chốt của "bền vững") — muốn dọn volume phải chủ động:
$ docker container rm -f db
$ docker volume rm db-data
# Output minh họa
db-data
(Output minh họa — docker volume rm chỉ echo lại đúng tên volume vừa xóa; nếu volume đang được 1 container khác dùng, lệnh sẽ báo lỗi tương tự cách docker image rm chặn xóa image còn container tham chiếu.)
Có thể khai báo volume ngay trong
Dockerfilebằng instructionVOLUME /var/lib/mysql— đây cũng chỉ là metadata (không tạo layer), báo cho người dùng image biết thư mục nào nên được mount volume vào, nhưng không tự tạo volume nào cho tới khi container thực sự chạy.
Theo kinh nghiệm vận hành thực tế, sai lầm phổ biến nhất với volume không phải là quên dùng nó, mà là quên rằng docker system prune -a --volumes hay docker container rm -f $(docker container ls -aq) (đã thấy ở Phần 5) xóa luôn cả volume không còn container nào tham chiếu nếu thêm cờ --volumes — 1 lệnh dọn dẹp tưởng vô hại có thể xóa mất dữ liệu production nếu chạy nhầm môi trường. Luôn kiểm tra kỹ docker volume ls trước khi chạy bất kỳ lệnh dọn dẹp hàng loạt nào.
Phần tiếp theo rời khỏi 1 host đơn lẻ, bước sang quy mô cluster: Swarm mode — cách Docker tự động hóa việc chạy, scale, và tự chữa lành hàng chục container trải trên nhiều máy.
Danh sách lệnh & instruction chính
| Lệnh/Instruction | Công dụng |
|---|---|
docker image build -t <tên>:<tag> . | Đọc Dockerfile và containerize app; -f cho phép chỉ định tên/vị trí Dockerfile khác mặc định |
docker image history <image> | Xem lại các instruction đã dùng để build image |
FROM | Chỉ định base image — thường là instruction đầu tiên |
RUN | Chạy lệnh bên trong image lúc build, mỗi RUN tạo 1 layer mới |
COPY | Copy file vào image dưới dạng 1 layer mới (thường dùng để copy code app) |
EXPOSE | Ghi chú network port ứng dụng dùng (metadata, không tạo layer) |
ENTRYPOINT | Đặt chương trình mặc định chạy khi container khởi động (metadata) |
COPY --from=<stage> | Trong multi-stage build, copy artifact từ 1 stage trước đó sang |
HEALTHCHECK | Khai báo lệnh Docker tự chạy định kỳ để xác nhận app còn phục vụ được, không chỉ "còn chạy" |
docker volume create <tên> / ls / rm | Tạo / liệt kê / xóa named volume |
--mount source=<vol>,target=<path> | Gắn volume (hoặc type=bind,source=<host-path> để bind mount) vào container lúc run |
Các instruction khác còn có LABEL, ENV, ONBUILD, CMD...
Nguồn tham khảo
Nguồn gốc: Bài viết lấy cảm hứng từ sách "Docker Deep Dive" — tác giả Nigel Poulton (bản phát hành version 4, 3/10/2017), Part 2: The Technical Stuff — chapter 8 "Containerizing an App". Nội dung được viết lại theo cách hiểu riêng, ví dụ minh họa (web app Node.js, multi-stage build Go) là ví dụ tự soạn, không sao chép từ sách gốc. Muốn đọc bản đầy đủ hoặc ủng hộ tác giả, có thể mua sách tại leanpub.com/dockerdeepdive.