Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Bài 16 — Dockerfile — Best Practices

Đã đọc 0/14 mục · còn khoảng 11 phút
Cách chúng tôi ghi nhận

Một mục được tính là đã đọc khi nó nằm giữa màn hình đủ lâu so với độ dài của nó (bảng và sơ đồ tính một nửa), bạn vẫn đang cuộn hoặc bấm phím, và bạn đã thấy tới cuối mục. Lướt nhanh hoặc để tab mở không được tính. Chúng tôi chỉ lưu thời gian đọc từng mục — không lưu gì khác.

Mở đầu — vì sao bài này quan trọng

Ở bài trước bạn đã hiểu vì sao Docker thay đổi cách chúng ta làm DevOps: nó đóng gói ứng dụng cùng toàn bộ môi trường chạy vào một artifact bất biến. Nhưng có một sự thật phũ phàng mà nhiều bạn kỹ sư mới gặp phải: viết được một Dockerfile chạy được thì dễ, còn viết một Dockerfile tốt lại là cả một nghệ thuật.

Sự khác biệt giữa hai thứ đó không phải chuyện lý thuyết. Một Dockerfile tệ khiến image của bạn nặng 1.4 GB thay vì 120 MB, khiến mỗi lần build tốn 8 phút thay vì 40 giây, khiến pipeline CI/CD ì ạch, tốn tiền lưu trữ registry, và tệ nhất là mang theo hàng trăm lỗ hổng bảo mật vì bạn vô tình đóng gói cả compiler, cả git, cả file .env chứa mật khẩu vào production image.

Tôi từng review một dự án của một startup fintech ở TP.HCM, nơi mỗi image Node.js đều tầm 1.2 GB. Mỗi lần deploy, cluster Kubernetes phải kéo image mất gần 3 phút cho mỗi node, nhân với 12 node là gần 40 phút chỉ để "rải" image ra. Sau khi tối ưu Dockerfile theo đúng những nguyên tắc trong bài này, image xuống còn 140 MB và thời gian rollout giảm từ 40 phút xuống dưới 5 phút. Không đổi một dòng code ứng dụng nào cả — chỉ đổi cách đóng gói.

Bài này sẽ dạy bạn cách viết Dockerfile chuẩn production: nhẹ, nhanh, an toàn, và tận dụng tối đa layer cache. Đây là kỹ năng nền tảng mà mọi DevOps Engineer đều bị hỏi trong phỏng vấn và dùng hằng ngày trong công việc.

Khái niệm cốt lõi

Image được xây bằng các layer

Mỗi lệnh FROM, RUN, COPY, ADD trong Dockerfile tạo ra một layer — một lớp filesystem chồng lên nhau. Docker cache từng layer. Khi build lại, nếu một layer và tất cả layer trước nó không đổi, Docker dùng lại cache thay vì chạy lại. Đây là ý tưởng chi phối gần như mọi best practice: sắp xếp các lệnh sao cho thứ ít thay đổi nằm trên, thứ hay thay đổi nằm dưới.

Ví dụ kinh điển: mã nguồn ứng dụng thay đổi liên tục, còn danh sách dependency (package.json, requirements.txt, go.mod) thì ít khi đổi. Vì vậy ta copy file khai báo dependency và cài đặt trước, rồi mới copy source code. Nhờ đó mỗi lần sửa code, layer cài dependency vẫn được cache, build cực nhanh.

Multi-stage build — vũ khí quan trọng nhất

Đây là kỹ thuật thay đổi cuộc chơi. Ý tưởng: bạn dùng một image "nặng" đầy đủ công cụ (compiler, build tools) để build ứng dụng, rồi chỉ copy sản phẩm cuối cùng sang một image "nhẹ" để chạy. Compiler, cache npm, source code trung gian — tất cả bị bỏ lại ở stage build, không lọt vào image production.

# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: runtime — chỉ chứa những gì cần để chạy
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

Điểm mấu chốt là dòng COPY --from=builder — nó chỉ lấy thư mục dist đã build xong, bỏ lại toàn bộ devDependencies, source TypeScript, test files. Image cuối chỉ chứa runtime code và production dependencies.

Base image — chọn nền cho đúng

Base image quyết định phần lớn kích thước và bề mặt tấn công (attack surface). Vài lựa chọn phổ biến:

  • alpine: siêu nhẹ (~5 MB), dựa trên musl libc. Rất phổ biến nhưng đôi khi gây lỗi tương thích với thư viện native (ví dụ một số package Python cần glibc).
  • slim (như python:3.12-slim, debian:slim): nhẹ vừa phải, dùng glibc chuẩn nên ít rắc rối hơn alpine.
  • distroless (của Google): chỉ chứa runtime và ứng dụng, không có shell, không có package manager. An toàn nhất cho production nhưng khó debug vì không exec vào được bằng bash.
Nguyên tắc: luôn pin phiên bản cụ thể, đừng bao giờ dùng :latest. node:20.11.1-alpine cho bạn build có thể tái lập (reproducible), còn node:latest là quả bom hẹn giờ vì hôm nay khác hôm qua.

.dockerignore — người bạn bị lãng quên

Trước khi build, Docker gửi toàn bộ "build context" (thư mục hiện tại) cho daemon. Nếu bạn không có file .dockerignore, nó sẽ gửi cả node_modules, .git, log files, file .env bí mật — làm build chậm và có nguy cơ lộ secret. Một .dockerignore tối thiểu:

node_modules
.git
.env
*.log
dist
Dockerfile
.dockerignore

Tình huống thực tế

Tình huống 1: Startup thương mại điện tử và image 1.2 GB

Một công ty e-commerce quy mô vừa ở Hà Nội (khoảng 50 kỹ sư) chạy backend Node.js trên Kubernetes. Ban đầu Dockerfile của họ chỉ đơn giản: FROM node:20, COPY . ., RUN npm install, CMD npm start. Image ra 1.2 GB.

Vấn đề bùng lên vào dịp sale 12/12: họ cần scale nhanh từ 12 lên 40 pod. Nhưng mỗi node kéo image mất 3 phút, và autoscaler không kịp đáp ứng burst traffic, dẫn đến 502 error trong 15 phút đầu khi lượng khách đổ vào.

Khi xử lý, đội DevOps làm ba việc: đổi sang node:20-alpine, áp dụng multi-stage build tách devDependencies, và thêm .dockerignore loại bỏ node_modules cục bộ. Image xuống 140 MB. Trong đợt sale sau, thời gian pull image xuống dưới 20 giây/node, autoscaler phản ứng kịp.

Bài học: kích thước image không chỉ là chuyện thẩm mỹ — nó ảnh hưởng trực tiếp tới tốc độ scale và trải nghiệm khách hàng vào đúng lúc quan trọng nhất.

Tình huống 2: Layer cache đặt sai chỗ khiến CI tốn tiền

Một team fintech ở Singapore có pipeline CI trên GitHub Actions build image Python mỗi lần push. Dockerfile của họ đặt COPY . . trước RUN pip install -r requirements.txt. Kết quả: mỗi lần sửa một dòng code, layer copy đổi, nên layer pip install phía sau luôn phải chạy lại, tải lại toàn bộ package từ đầu — mất 6 phút mỗi build.

Với khoảng 60 lần build mỗi ngày, đội ngũ chờ tổng cộng 6 giờ mỗi ngày. Trên runner tính phí theo phút, chi phí CI tăng đáng kể.

Sửa lại rất đơn giản — copy requirements.txt trước, cài đặt, rồi mới copy code:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

Sau khi sửa, các build mà không đổi requirements.txt giảm từ 6 phút xuống 45 giây vì layer pip install được cache. Chi phí CI giảm hơn 80%.

Bài học: thứ tự các lệnh trong Dockerfile là một quyết định kiến trúc, không phải chuyện tùy tiện. Đặt dependency install trước source code là quy tắc vàng.

Tình huống 3: Chạy container bằng root và cái giá của nó

Một agency phần mềm outsource ở Đà Nẵng giao sản phẩm cho khách hàng châu Âu. Trong đợt security audit trước khi go-live, auditor phát hiện mọi container đều chạy bằng user root. Nếu attacker khai thác được lỗ hổng trong ứng dụng, họ có quyền root trong container — và nếu container escape, có thể ảnh hưởng cả host.

Fix: thêm một non-root user và dùng USER trước CMD.

RUN addgroup --system app && adduser --system --ingroup app app
USER app

Ngoài ra auditor còn chỉ ra image chứa curl, git, và cả một file .env staging bị copy nhầm. Sau khi dùng multi-stage build và siết .dockerignore, số CVE do scanner báo giảm từ 240 xuống còn 12, và khách hàng chấp nhận bàn giao.

Bài học: bảo mật container bắt đầu từ Dockerfile. Non-root user, image tối giản, và không đóng gói secret là ba việc bắt buộc.

Hướng dẫn từng bước

Hãy cùng viết một Dockerfile production-grade cho một ứng dụng Node.js, giải thích từng quyết định.

Bước 1 — Chọn base image có pin phiên bản. Dùng node:20.11.1-alpine thay vì node:latest. Bạn muốn build tái lập được và nhẹ.

Bước 2 — Tạo multi-stage. Đặt tên stage build là builder bằng AS builder. Ở stage này bạn có đầy đủ công cụ để npm ci và npm run build.

Bước 3 — Tối ưu layer cache. Copy package*.json trước, chạy npm ci, rồi mới COPY . .. Như vậy layer cài dependency chỉ chạy lại khi manifest đổi.

Bước 4 — Tạo stage runtime tối giản. Từ một node:20.11.1-alpine mới, chỉ cài production dependency (npm ci --omit=dev) và copy artifact đã build từ builder bằng COPY --from=builder.

Bước 5 — Thêm non-root user. Alpine đã có sẵn user node từ image Node chính thức; chỉ cần USER node. Với base khác, tự tạo user như tình huống 3.

Bước 6 — Khai báo metadata runtime. Dùng EXPOSE 3000 để tài liệu hóa cổng, ENV NODE_ENV=production, và ưu tiên CMD ["node", "dist/main.js"] dạng exec form (mảng JSON) thay vì shell form, để tiến trình nhận đúng tín hiệu SIGTERM khi Kubernetes muốn dừng pod.

Bước 7 — Thêm HEALTHCHECK (tùy chọn). Với môi trường không phải Kubernetes, HEALTHCHECK giúp Docker biết container còn khỏe hay không.

Bước 8 — Build và kiểm tra kích thước. Chạy docker build -t myapp:1.0 . rồi docker images myapp để xem size. Dùng docker history myapp:1.0 để soi từng layer nặng bao nhiêu — công cụ tuyệt vời để tìm layer phình to.

Bước 9 — Scan bảo mật. Chạy docker scout cves myapp:1.0 hoặc trivy image myapp:1.0 để phát hiện lỗ hổng trước khi push.

Lỗi thường gặp & mẹo

Gộp lệnh RUN để giảm số layer, nhưng đúng cách. Mỗi RUN apt-get install riêng lẻ tạo một layer và có thể để lại cache thừa. Hãy gộp và dọn trong cùng một RUN:

RUN apt-get update && \
    apt-get install -y --no-install-recommends curl ca-certificates && \
    rm -rf /var/lib/apt/lists/*

Nếu bạn xóa cache ở một RUN sau, dung lượng vẫn còn nằm trong layer trước — không giảm được gì. Phải dọn trong cùng layer tạo ra nó.

Đừng dùng ADD khi chỉ cần COPY. ADD có phép màu tự giải nén tar và tải URL, dễ gây bất ngờ. Nguyên tắc: dùng COPY cho mọi thứ, chỉ dùng ADD khi thực sự cần giải nén tar cục bộ.

Cẩn thận với secret khi build. Đừng bao giờ COPY .env hay truyền mật khẩu qua ARG rồi hardcode — mọi giá trị ARG/ENV đều nằm lộ liễu trong docker history. Dùng BuildKit secret mount (RUN --mount=type=secret,...) khi cần token lúc build.

Bật BuildKit. Đặt DOCKER_BUILDKIT=1 (mặc định trên Docker mới). BuildKit build song song các stage độc lập, có cache mount cho npm/pip, nhanh hơn hẳn builder cũ.

Đừng cài công cụ debug vào production image. vim, curl, netstat nghe tiện nhưng chúng là bề mặt tấn công. Nếu cần debug, dùng ephemeral debug container (kubectl debug) thay vì nhồi công cụ vào image.

Pin cả phiên bản dependency hệ thống khi có thể, để build tháng sau giống hệt tháng trước — tránh cảnh "máy tôi build được mà CI thì không".

Đặt CMD ở exec form. Viết CMD npm start (shell form) khiến process thật chạy dưới /bin/sh -c, và tín hiệu SIGTERM không tới được app, làm container mất 10 giây mới bị kill. Dùng CMD ["node", "dist/main.js"].

Bài tập thực hành

  • Tối ưu một image có sẵn. Lấy một ứng dụng Node.js hoặc Python bất kỳ của bạn (hoặc một demo trên GitHub) với Dockerfile đơn giản. Ghi lại kích thước image ban đầu bằng docker images. Sau đó áp dụng: base image -alpine/-slim, multi-stage build, .dockerignore, và sắp lại thứ tự layer. Ghi lại kích thước mới và tính phần trăm giảm được. Mục tiêu: giảm ít nhất 60%.
  • Điều tra layer. Dùng docker history <image> trên image bạn vừa tối ưu, xác định layer nào nặng nhất và giải thích vì sao. Thử di chuyển một lệnh COPY lên trên rồi build lại, quan sát cache bị vô hiệu ra sao.
  • Non-root và scan. Thêm một non-root user vào Dockerfile, build lại, rồi chạy docker run --rm <image> whoami để xác nhận không phải root. Cuối cùng chạy trivy image <image> (hoặc docker scout cves) và ghi lại số lỗ hổng trước/sau khi tối ưu.
  • Thử BuildKit secret. Viết một Dockerfile cần một token lúc build (ví dụ đọc từ private npm registry), triển khai bằng RUN --mount=type=secret thay vì ARG, và kiểm tra docker history để xác nhận token không bị lộ.

Tóm tắt

Một Dockerfile tốt không phải chuyện "chạy được là xong" — nó là quyết định kiến trúc ảnh hưởng tới tốc độ build, chi phí CI, tốc độ scale, và bảo mật. Ba trụ cột bạn cần khắc cốt ghi tâm:

  • Nhẹ: dùng base image tối giản (alpine/slim/distroless), multi-stage build để bỏ lại compiler và devDependencies, và .dockerignore để không gửi rác vào build context.
  • Nhanh: sắp xếp layer từ ít-thay-đổi đến hay-thay-đổi, copy manifest dependency và cài đặt trước source code, dọn cache trong cùng RUN, và bật BuildKit.
  • An toàn: pin phiên bản base image, chạy bằng non-root user, không đóng gói secret, dùng exec form cho CMD, và scan lỗ hổng trước khi push.
Khi bạn nắm chắc những nguyên tắc này, image của bạn sẽ gọn gàng và sẵn sàng cho môi trường production thực sự. Ở bài tiếp theo, chúng ta sẽ bước sang Docker Compose để dàn dựng nhiều service cùng lúc cho môi trường phát triển cục bộ.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi