Dockerfile nâng cao: image dưới 200MB và build lại trong vài giây
Ở bài trước, image của chúng ta chạy được. Nhưng “chạy được” và “tốt” là hai chuyện khác nhau. Một Dockerfile viết cẩu thả tạo ra image nặng cả gigabyte, mỗi lần sửa một dòng code lại build mất vài phút. Một Dockerfile tốt tạo ra image chỉ hơn trăm megabyte và build lại trong vài giây. Đây là bài phân biệt người mới với người có nghề, và tin vui là bạn học được trong một buổi.
Vì sao image bị phình to
Thủ phạm phổ biến nhất: bạn kéo cả bộ đồ nghề xây dựng vào sản phẩm cuối. Trình biên dịch, các gói dùng để build, tập tin tạm, bộ nhớ đệm của trình quản lý gói, tất cả nằm lại trong image dù app chạy xong không cần đến chúng nữa.
Một ví dụ kinh điển: dùng base image cỡ lớn rồi cài thêm bộ công cụ ngôn ngữ, image dễ dàng vượt 900MB, trong khi phần thật sự cần để chạy chỉ vài chục megabyte. Image to kéo theo pull chậm, tốn dung lượng kho, khởi động lâu, và nhiều gói thừa đồng nghĩa nhiều lỗ hổng bảo mật hơn.
Kỹ thuật 1: hiểu layer cache để build nhanh
Docker xây image theo từng lớp (layer), mỗi lệnh trong Dockerfile tạo một lớp. Docker ghi nhớ (cache) các lớp này. Khi build lại, nếu một lệnh và tập tin liên quan không đổi, Docker dùng lại lớp cũ thay vì làm lại. Nhưng có một quy tắc quan trọng: khi một lớp thay đổi, mọi lớp phía sau nó đều bị làm lại.
Đây là lý do thứ tự các lệnh quyết định tốc độ build. Hãy xem đoạn sai lầm hay gặp:
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm install
Vấn đề: mỗi lần bạn sửa một dòng code bất kỳ, lệnh COPY toàn bộ bị coi là thay đổi, kéo theo npm install phải chạy lại từ đầu, dù danh sách thư viện không hề đổi. Chờ dài cổ.
Cách đúng là chép riêng tập tin khai báo thư viện trước, cài thư viện, rồi mới chép mã nguồn:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
Giờ thì thư viện chỉ được cài lại khi tập tin khai báo thay đổi. Sửa code hằng ngày sẽ không đụng tới bước cài thư viện, và build lại chỉ mất vài giây nhờ cache còn ấm.
Kỹ thuật 2: build nhiều tầng (multi-stage)
Đây là kỹ thuật mạnh nhất để làm nhỏ image. Ý tưởng: tách quá trình làm hai giai đoạn. Giai đoạn build có đầy đủ công cụ để tạo ra sản phẩm. Giai đoạn chạy chỉ lấy đúng sản phẩm cuối, bỏ lại toàn bộ công cụ.
Ví dụ với một app cần biên dịch:
# Giai đoạn 1: build, có đủ công cụ
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Giai đoạn 2: chạy, chỉ lấy kết quả
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
Điểm mấu chốt nằm ở dòng COPY –from=builder. Nó chép kết quả từ giai đoạn build sang giai đoạn chạy. Image cuối cùng chỉ chứa nội dung của giai đoạn cuối. Mọi công cụ build, mã nguồn gốc, bộ nhớ đệm đều bị bỏ lại và không lên image. Giống như xây nhà: đội thợ với khoan, búa, giàn giáo là giai đoạn một; còn bạn dọn vào ở chỉ mang theo bàn ghế, là giai đoạn hai.
Kỹ thuật 3: chọn base image nhỏ
Base image bạn chọn ở dòng FROM ảnh hưởng lớn tới kích thước. Ba lựa chọn phổ biến từ to tới nhỏ:
- Bản đầy đủ (ví dụ node:22): tiện, có sẵn nhiều thứ, nhưng nặng.
- Bản slim: gọn hơn, bỏ bớt các gói không cần thiết.
- Bản alpine: rất nhẹ, dựng trên Linux Alpine, chỉ vài megabyte. Lựa chọn quen thuộc cho image production.
Ngoài ra còn có distroless, loại image gần như không có gì ngoài phần chạy app, nhỏ và an toàn nhất, phù hợp khi bạn đã quen tay.
Vài mẹo dọn dẹp thêm
- Gộp các lệnh cài đặt liên quan vào một lệnh RUN và dọn bộ nhớ đệm ngay trong đó, để không để lại rác giữa các lớp.
- Luôn có tập tin .dockerignore để không chép rác vào bối cảnh build.
- Cho app chạy bằng người dùng thường thay vì quyền cao nhất, để an toàn hơn.
- Ghim rõ phiên bản base image thay vì để chung chung, để build hôm nay và tháng sau ra kết quả giống nhau.
Định nghĩa xong
Bạn coi như nắm được bài này khi:
- Image cuối cùng của app xuống dưới 200MB.
- Sau khi sửa một dòng code, build lại chỉ mất dưới 30 giây nhờ cache còn ấm.
- Bạn giải thích được vì sao chép tập tin khai báo thư viện trước khi chép mã nguồn, và vì sao multi-stage giúp image nhỏ đi.
App của chúng ta giờ đã được đóng gói gọn gàng. Nhưng một hệ thống thật thường có nhiều thành phần chạy cùng lúc. Bài tiếp theo sẽ dùng docker compose để dựng cả cụm chỉ bằng một lệnh.