Viết Dockerfile hiệu quả: Bí quyết tối ưu image size và build time

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

Hồi mới dùng Docker cho dự án thực tế, mình hay copy Dockerfile từ StackOverflow rồi chạy thôi. Build được, container chạy được — thế là xong. Nhưng sau một thời gian, image nặng 1.2GB, build mất 8 phút mỗi lần push CI, đồng nghiệp trong team bắt đầu phàn nàn. Lúc đó mình mới ngồi lại tìm hiểu đúng nghĩa về Dockerfile.

Nhìn lại thì hồi đó mình mắc đủ loại lỗi cơ bản: copy toàn bộ thư mục node_modules vào image, quên không có .dockerignore nên build context lên tới 800MB, hay cài package trong một RUN rồi xóa cache ở RUN khác mà tưởng image sẽ nhỏ hơn. Bài này mình chia sẻ lại những gì học được sau nhiều lần build thất bại — theo đúng nghĩa đen.

Dockerfile hoạt động như thế nào bên dưới?

Hiểu cơ chế layer thì mọi tối ưu đều tự nhiên theo. Mỗi instruction trong Dockerfile (RUN, COPY, ADD…) tạo ra một layer mới. Docker cache lại các layer này — nếu layer không thay đổi, lần build sau sẽ dùng cache thay vì chạy lại lệnh đó.

Vấn đề là: nếu một layer thay đổi, tất cả các layer phía sau đều bị invalidate. Đây là lý do build chậm dù bạn chỉ sửa 1 dòng code.

Sắp xếp instruction theo đúng thứ tự

Quy tắc mình luôn nhớ: thứ gì ít thay đổi nhất thì đặt lên trên.

Ví dụ sai — đây là kiểu Dockerfile mình hay viết hồi đầu:

# SAI: copy code trước khi install dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

Vấn đề: mỗi lần sửa code — kể cả sửa 1 comment — bước npm install sẽ chạy lại từ đầu vì layer COPY . . thay đổi. Với project có vài trăm dependency, mỗi lần như vậy tốn 2-5 phút.

Cách đúng: tách riêng phần cài dependency trước:

# ĐÚNG: copy package files trước, install, rồi mới copy code
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]

Bây giờ npm ci chỉ chạy lại khi package.json hoặc package-lock.json thay đổi. Mỗi lần sửa code thông thường, bước này được cache — build nhanh hơn rõ rệt.

Multi-stage build — vũ khí giảm image size

Lần đầu thấy kỹ thuật này, mình không tin image có thể giảm nhiều đến vậy. Nguyên tắc đơn giản: dùng một image nặng để build, nhưng chỉ copy artifact cần thiết vào image cuối cùng — bỏ lại toàn bộ compiler, toolchain, và file trung gian.

Ví dụ với ứng dụng Go:

# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .

# Stage 2: Runtime image
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
CMD ["./main"]

Image golang:1.22-alpine nặng khoảng 300MB. Image cuối chỉ chứa binary đã compile + alpine base — tổng khoảng 15-20MB. Giảm hơn 10 lần.

Kỹ thuật tương tự áp dụng được cho Python (build wheel → copy vào slim image), Java (Maven build → copy JAR), React (npm build → copy dist vào nginx).

Chọn base image phù hợp

ubuntu:latest là lựa chọn quen thuộc — nhưng cũng là lựa chọn nặng nhất. Ubuntu base image kéo theo khoảng 70-80MB cùng nhiều tool không cần thiết trong production.

  • Alpine Linux (-alpine): nhẹ nhất (~5MB), dùng musl libc thay vì glibc — đôi khi gây issue với một số thư viện C
  • Debian Slim (-slim): cân bằng giữa size và compatibility (~30-80MB)
  • Distroless (Google): không có shell, package manager — bảo mật cao nhất, nhưng debug khó

Gợi ý: dùng -alpine cho Go/Node, -slim cho Python khi cần thư viện native, distroless cho production nếu team đã quen debug qua kubectl exec hoặc sidecar container.

Đừng bỏ qua .dockerignore

Quên tạo file .dockerignore là lỗi mình hay thấy nhất khi review Dockerfile của người mới. File này loại bỏ thư mục và file không cần thiết trước khi Docker gửi build context lên daemon — nếu không có, mọi thứ trong thư mục project đều được gửi lên, kể cả node_modules hay thư mục .git.

Ví dụ .dockerignore cho project Node.js:

node_modules
npm-debug.log
.git
.gitignore
.env
*.md
dist
.dockerignore
Dockerfile
.DS_Store

Không có file này, Docker sẽ gửi cả node_modules (có thể vài trăm MB) lên daemon mỗi lần build, dù bạn sẽ chạy npm install lại bên trong container. Mình từng mắc lỗi này, build context lên tới 800MB — mỗi lần build phải đợi thêm 30-40 giây chỉ để upload context.

Thực hành: Gộp RUN commands và dọn dẹp cache

Mỗi RUN tạo một layer. Nếu bạn cài package xong rồi xóa cache ở lệnh RUN khác, cache vẫn nằm trong layer trước — image không nhỏ hơn chút nào.

# SAI: xóa cache ở layer khác không có tác dụng
RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*

# ĐÚNG: gộp thành 1 RUN, xóa trong cùng layer
RUN apt-get update && apt-get install -y \
    curl \
    git \
    && rm -rf /var/lib/apt/lists/*

Với Alpine thì dùng:

RUN apk add --no-cache curl git

Flag --no-cache của apk không tạo cache index ngay từ đầu, gọn hơn.

Chạy container với non-root user

Mặc định container chạy với user root. Nếu có lỗ hổng trong app, attacker có quyền root trong container — và tùy cấu hình host, có thể leo thang ra ngoài. Đây không phải lý thuyết: CVE-2019-5736 (runc escape) là ví dụ thực tế khi root trong container dẫn đến root trên host. Thêm vào Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# Tạo user riêng
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

CMD ["node", "server.js"]

Pin version — đừng dùng latest

Tag latest thay đổi theo thời gian. Build của bạn hôm nay có thể khác hoàn toàn build 3 tháng sau nếu base image cập nhật.

# Tránh
FROM python:latest

# Nên dùng: pin version cụ thể
FROM python:3.12.3-slim-bookworm

Sau khi pin version, dùng docker scout hoặc trivy để scan CVE định kỳ và cập nhật có chủ động.

Kết luận

Những thứ mình hay nhớ nhất — không phải vì lý thuyết, mà vì đã bị đau một lần:

  1. Sắp xếp layer theo thứ tự từ ít thay đổi đến nhiều thay đổi
  2. Dùng multi-stage build cho mọi ngôn ngữ compiled
  3. Luôn có .dockerignore trước khi build lần đầu
  4. Gộp RUN commands và dọn cache trong cùng layer
  5. Chạy bằng non-root user trong production
  6. Pin version base image, không dùng latest

Mỗi dự án sẽ có trade-off riêng — đôi khi image nhỏ hơn nhưng debug khó hơn, hoặc build nhanh hơn nhưng cần setup phức tạp hơn. Quan trọng là hiểu tại sao mình làm vậy, thay vì copy template mù quáng như mình từng làm.

Nếu bạn muốn kiểm tra Dockerfile của mình, thử chạy docker history <image_name> để xem từng layer nặng bao nhiêu — thường chỗ nặng nhất sẽ lộ ngay vấn đề cần tối ưu.

Share: