Cách Viết Dockerfile Hiệu Quả: Kinh Nghiệm Từ Thực Tế

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

Dockerfile viết cẩu thả là một trong những thứ đầu tiên tôi kiểm tra mỗi khi CI/CD pipeline chạy ì ạch. Tôi từng thấy những build mất 15 phút trong khi đáng ra chỉ cần dưới 3 phút, và những image nặng 900 MB trong khi thực tế chỉ cần tầm 30 MB là đủ. Đây không phải trường hợp ngoại lệ. Đây là kết quả của việc Dockerfile được viết một lần rồi không ai đụng vào nữa.

Bài viết này tổng hợp những cách tiếp cận tôi đã thấy trong môi trường production — cái nào hiệu quả, cái nào không, và cách tôi viết Dockerfile hiện tại.

So Sánh Hai Cách Tiếp Cận: Dockerfile Đơn Giản vs. Tối Ưu

Hầu hết developer bắt đầu với một Dockerfile kiểu “cứ chạy được là được”. Trông nó sẽ như thế này:

FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3 pip
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["python3", "app.py"]

Nó chạy được. Nhưng vấn đề tích lũy rất nhanh. Image phình to lên 600–900 MB. Mỗi lần build lại là toàn bộ dependencies được cài đặt lại, dù chỉ sửa một dòng code. Và production cuối cùng chứa đầy các công cụ và file không bao giờ được dùng đến.

Cách tiếp cận tối ưu kết hợp một số kỹ thuật:

  • Base image tối giản (Alpine, slim variants, hoặc distroless)
  • Hiểu về cache layer — sắp xếp các instruction từ ít thay đổi nhất đến thường xuyên thay đổi nhất
  • Multi-stage build — tách dependencies dùng khi build khỏi dependencies dùng khi chạy
  • .dockerignore — giữ cho build context gọn nhẹ

Ưu và Nhược Điểm của Từng Cách

Cách Đơn Giản

  • Ưu: Viết nhanh, dễ hiểu
  • Nhược: Kích thước image lớn (thường trên 500 MB)
  • Nhược: Build lại chậm — mỗi thay đổi code đều kéo theo cài đặt lại toàn bộ dependencies
  • Nhược: Các build tool, compiler và dev package đều nằm trong production image
  • Nhược: Bề mặt tấn công lớn hơn cho các lỗ hổng bảo mật

Cách Tối Ưu

  • Ưu: Image chỉ 50–150 MB (đôi khi dưới 20 MB với distroless)
  • Ưu: Build lại nhanh nhờ cache layer hiệu quả
  • Ưu: Production image gọn gàng — chỉ chứa runtime dependencies, không hơn không kém
  • Nhược: Phức tạp hơn một chút khi thiết lập ban đầu
  • Nhược: Image dựa trên Alpine có thể gây ra các lỗi khó phát hiện với binary phụ thuộc vào glibc

Viết Dockerfile tốt là một trong những thói quen có giá trị nhất trong quy trình DevOps. Pipeline CI chạy nhanh hơn, chi phí lưu trữ registry thấp hơn, ít sự cố bất ngờ trong production hơn — tất cả nhờ một file duy nhất.

Thiết Lập Được Khuyến Nghị

Chọn Base Image Phù Hợp

Bỏ qua ubuntu:latest hoặc debian:latest trừ khi bạn thực sự cần môi trường đó. Đây là thứ tự ưu tiên tôi sử dụng:

  • python:3.12-slim — image chính thức đã được tinh gọn, cân bằng tốt giữa khả năng tương thích và kích thước
  • python:3.12-alpine — nhỏ nhất, nhưng cẩn thận với các package cần glibc
  • gcr.io/distroless/python3 — không có shell, không có package manager, bề mặt tấn công tối thiểu (tốt nhất cho production có yêu cầu bảo mật cao)

Với các ngôn ngữ biên dịch như Go hoặc Rust, multi-stage build cho phép bạn biên dịch trên image đầy đủ và chỉ copy binary cuối cùng vào base distroless hoặc scratch.

Cache Layer: Thứ Tự Quan Trọng

Docker cache từng layer. Thay đổi một layer sẽ khiến mọi thứ sau nó phải build lại từ đầu. Nguyên tắc: đặt các instruction ít thay đổi nhất lên trên cùng.

Thứ tự sai (phá vỡ cache mỗi khi thay đổi code):

COPY . /app
RUN pip install -r requirements.txt

Thứ tự đúng (dependencies được cache riêng):

COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY . /app

Bây giờ pip install chỉ chạy lại khi requirements.txt thay đổi — không phải mỗi lần chỉnh sửa code.

Dùng Multi-Stage Build

Multi-stage build là cải tiến lớn nhất cho các ứng dụng được biên dịch hoặc cần nhiều bước build. Đây là ví dụ thực tế về Go service:

# Giai đoạn 1: Build
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 /app/server ./cmd/server

# Giai đoạn 2: Production image
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Image cuối cùng chỉ chứa binary đã được biên dịch — không có Go toolchain, không có source code, không có package manager. Pattern này đã giúp một service giảm từ 900 MB xuống còn dưới 20 MB.

Luôn Dùng .dockerignore

Mặc định, Docker gửi toàn bộ thư mục project đến daemon mỗi khi build. Với project Node.js có node_modules, build context có thể tăng từ vài KB lên đến vài trăm MB. Một file .dockerignore sẽ giải quyết ngay vấn đề này.

Một file .dockerignore tối giản:

.git
.gitignore
*.md
__pycache__
*.pyc
.env
.env.*
tests/
docs/
node_modules/

Hướng Dẫn Triển Khai

Ứng Dụng Python — Ví Dụ Đầy Đủ

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base

# Thiết lập biến môi trường
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /app

# Cài đặt dependencies trước (layer được cache)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy code ứng dụng
COPY src/ ./src/

# Chạy với non-root user
RUN adduser --disabled-password --gecos "" appuser
USER appuser

EXPOSE 8000
CMD ["python", "-m", "src.main"]

Ứng Dụng Node.js — Multi-Stage

# Giai đoạn 1: Cài đặt & Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Giai đoạn 2: Runtime tối giản
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY src/ ./src/

RUN addgroup -S app && adduser -S app -G app
USER app

EXPOSE 3000
CMD ["node", "src/index.js"]

Các Lỗi Thường Gặp Cần Tránh

  • Đừng dùng tag latest — hãy pin phiên bản cụ thể như python:3.12.3-slim. Tag latest phá vỡ tính tái lặp và sớm muộn sẽ gây rắc rối trong CI.
  • Đừng chạy với quyền root — tạo và chuyển sang non-root user trước CMD/ENTRYPOINT.
  • Đừng tách apt-get updateapt-get install thành các lệnh RUN riêng biệt — Docker cache layer update độc lập, dẫn đến danh sách package cũ và quá trình cài đặt bị lỗi:
# Cách sai
RUN apt-get update
RUN apt-get install -y curl

# Cách đúng
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
  • Đừng lưu secrets trong Dockerfile — chỉ dùng build args cho dữ liệu không nhạy cảm. Truyền secrets thực qua biến môi trường lúc runtime, hoặc dùng Docker secrets.
  • Đừng bỏ qua HEALTHCHECK — nếu không có nó, các orchestrator như Kubernetes và Docker Swarm không thể biết container đang chạy hay đã thực sự sẵn sàng phục vụ traffic.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:8000/health || exit 1

Kiểm Tra Kích Thước Image

Sau khi build, hãy kiểm tra kết quả:

docker build -t myapp:latest .
docker images myapp:latest
docker history myapp:latest

Để phân tích chi tiết hơn, dùng dive:

dive myapp:latest

dive phân tích mức sử dụng dung lượng theo từng layer. Một điểm cần chú ý: các file được thêm trong một bước RUN và xóa ở bước sau vẫn chiếm dung lượng trong image cuối cùng. dive sẽ đánh dấu rõ ràng những trường hợp này.

Danh Sách Kiểm Tra Nhanh

  • Dùng base image slim hoặc Alpine
  • Pin phiên bản image cụ thể
  • Copy file dependency trước source code
  • Dùng multi-stage build cho ứng dụng biên dịch hoặc cần nhiều bước build
  • Tạo và sử dụng non-root user
  • Thêm file .dockerignore
  • Dọn sạch cache của package manager trong cùng một layer RUN
  • Thêm HEALTHCHECK
  • Không bao giờ nhúng secrets vào image

Đây không phải những best practice trừu tượng — mỗi cái đều gắn với một vấn đề thực tế tôi đã gặp phải. Làm đúng những điều này và container của bạn sẽ build nhanh hơn, tốn ít chi phí lưu trữ hơn, và ít rủi ro hơn trong production. Xứng đáng với 30 phút bỏ ra để thiết lập đúng cách.

Share: