Build image Docker chỉ 50MB thay vì 800MB (multi-stage)

Tài liệu » Quản trị VPS - Server » Build image Docker chỉ 50MB thay vì 800MB (multi-stage)

Vì sao cần image Docker nhỏ gọn ngay? — pain point + 4-5 bullet emoji ngắn

Image Docker quá lớn là cơn ác mộng cho mọi quy trình CI/CD. Chúng làm chậm quá trình build, push, pull, và deploy, dẫn đến thời gian downtime kéo dài và chi phí lưu trữ tăng vọt.

  • 🚀 Deploy chậm: Tốn thời gian chờ image tải về.
  • 💾 Tốn dung lượng: Chiếm nhiều không gian trên registry và host.
  • 💰 Chi phí cao: Lưu trữ và truyền tải dữ liệu tốn kém.
  • 🐛 Khó debug: Image cồng kềnh chứa nhiều thứ không cần thiết.
  • 🛡️ Rủi ro bảo mật: Nhiều thành phần không cần thiết làm tăng bề mặt tấn công.

Hiểu rõ vấn đề: Image Docker “phì nhiêu” đến từ đâu?

Tóm gọn: Image Docker nặng nề thường do chứa SDK, trình biên dịch, các gói phụ thuộc không cần thiết, và file debug trong môi trường production.

Image Docker mặc định thường được build từ các base image lớn, chứa đầy đủ công cụ cho việc phát triển và debug. Ví dụ, khi bạn build một ứng dụng Go hoặc Node.js, bạn có thể vô tình đưa cả trình biên dịch Go (Golang SDK) hoặc Node.js runtime và các gói npm nặng nề vào image production cuối cùng.

Base image: Gốc rễ của vấn đề

Các base image như ubuntu:latest hay golang:latest có thể lên tới vài trăm MB. Chúng bao gồm hệ điều hành đầy đủ, trình quản lý gói, và các công cụ phát triển.

docker pull ubuntu:latest
docker images | grep ubuntu
ubuntu   latest   92.4MB

Mặc dù ubuntu:latest chỉ 92.4MB, nhưng khi bạn cài thêm các gói phụ thuộc cho ứng dụng, image sẽ phình to nhanh chóng.

Dependencies: Kẻ “ăn gian” dung lượng

Ứng dụng của bạn cần các thư viện và gói phụ thuộc để chạy. Nếu không quản lý cẩn thận, bạn có thể đóng gói tất cả chúng vào image, kể cả những thứ chỉ cần thiết cho quá trình build.

Build tools & Debug symbols: Gánh nặng không cần thiết

Trình biên dịch, các công cụ build (như make, gcc), và các ký hiệu debug (debug symbols) là cần thiết khi phát triển, nhưng lại hoàn toàn vô dụng và chiếm dung lượng quý giá trong môi trường production.

Giải pháp “thần thánh”: Multi-stage builds

Tóm gọn: Multi-stage builds cho phép bạn sử dụng nhiều FROM statement trong một Dockerfile duy nhất, tách biệt môi trường build và môi trường runtime.

Đây là kỹ thuật mạnh mẽ nhất để tạo ra các image Docker siêu nhẹ. Ý tưởng cốt lõi là:

  1. Stage 1 (Build Stage): Sử dụng một image chứa đầy đủ công cụ để build ứng dụng của bạn (ví dụ: golang:latest, node:latest).
  2. Stage 2 (Runtime Stage): Sử dụng một image tối giản, chỉ chứa những gì cần thiết để chạy ứng dụng (ví dụ: alpine, scratch, hoặc một image distroless).
  3. Copy Artifacts: Chỉ copy các file thực thi (binaries) hoặc các tài nguyên cần thiết từ Stage 1 sang Stage 2.

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

Hãy xem xét một ứng dụng Go đơn giản:

main.go:

package main

import "fmt"

func main() {
    fmt.Println("Hello from lightweight Go app!")
}

Dockerfile ban đầu (khá nặng):

# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# Stage 2: Runtime (vẫn còn khá nặng)
FROM golang:1.21-alpine
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

Image build từ Dockerfile trên có thể lên tới ~150-200MB. Giờ hãy tối ưu bằng multi-stage:

Dockerfile với multi-stage build:

# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
# Build static binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o myapp .

# Stage 2: Runtime - Sử dụng Alpine Linux tối giản
FROM alpine:latest
WORKDIR /app
# Copy only the compiled binary from the builder stage
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]

Giải thích các flag -ldflags="-w -s":

  • -w: Tắt thông tin DWARF debug.
  • -s: Tắt bảng ký hiệu (symbol table).

Cả hai flag này giúp giảm kích thước file thực thi, từ đó giảm kích thước image.

Build và kiểm tra kích thước

docker build -t myapp:multi-stage .
docker images | grep myapp

Output mẫu:

myapp   multi-stage   13.5MB

Bạn có thể thấy sự khác biệt đáng kinh ngạc, từ ~150-200MB xuống còn ~13.5MB!

Ví dụ với ứng dụng Node.js

Ứng dụng Node.js thường có nhiều dependencies trong thư mục node_modules.

app.js:

const http = require('http');

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello from lightweight Node.js app!\n');
});

const port = 3000;
server.listen(port, () => {
  console.log(`Server running at http://localhost:${port}/`);
});

Dockerfile ban đầu (nặng):

FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD [ "node", "app.js" ]

Image này có thể lên tới ~300-400MB. Tối ưu với multi-stage:

Dockerfile với multi-stage build (Node.js):

# Stage 1: Build dependencies
FROM node:18-alpine AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Runtime - Copy dependencies and app code
FROM node:18-alpine
WORKDIR /usr/src/app
# Copy only production dependencies
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD [ "node", "app.js" ]

Lưu ý quan trọng: Lệnh npm ci --only=production trong stage builder sẽ cài đặt các dependencies cần thiết cho production. Nếu bạn cần build assets (ví dụ: React, Vue), bạn sẽ cần một stage build riêng biệt sử dụng npm install (không có --only=production) và sau đó copy các file build (thường trong thư mục dist) sang stage runtime.

Build và kiểm tra kích thước

docker build -t myapp-node:multi-stage .
docker images | grep myapp-node

Output mẫu:

myapp-node   multi-stage   120MB

Vẫn còn khá lớn so với Go, nhưng đã giảm đáng kể so với image ban đầu.

Tối ưu hơn nữa với Distroless Images

Tóm gọn: Distroless images là các image cực kỳ tối giản, chỉ chứa ứng dụng của bạn và các runtime dependencies cần thiết, không có shell, trình quản lý gói hay các tiện ích hệ thống khác.

Distroless images, được phát triển bởi Google, mang lại lợi ích bảo mật và kích thước vượt trội. Chúng có sẵn cho Java, Python, Node.js, Go, .NET Core, và các ứng dụng tĩnh.

Ví dụ Go với Distroless

# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o myapp .

# Stage 2: Runtime - Sử dụng Google Distroless Base Image
FROM gcr.io/distroless/static-debian11
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

Lưu ý: gcr.io/distroless/static-debian11 phù hợp cho các ứng dụng tĩnh (như Go binary đã build với CGO_ENABLED=0). Nếu ứng dụng của bạn cần runtime (như Python, Node.js), bạn sẽ dùng các image distroless khác như gcr.io/distroless/nodejs18-debian11.

Build và kiểm tra kích thước

docker build -t myapp-distroless:go .
docker images | grep myapp-distroless

Output mẫu:

myapp-distroless   go   20MB

Kích thước đã giảm xuống còn ~20MB, một con số ấn tượng!

Ví dụ Node.js với Distroless

# Stage 1: Build dependencies
FROM node:18-alpine AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Runtime - Sử dụng Node.js Distroless
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD [ "app.js" ]

Build và kiểm tra kích thước

docker build -t myapp-distroless:node .
docker images | grep myapp-distroless

Output mẫu:

myapp-distroless   node   150MB

Distroless cho Node.js vẫn lớn hơn so với Go do bản chất của runtime Node.js, nhưng vẫn là một cải tiến đáng kể so với image Node.js mặc định.

Sử dụng scratch image: Tối giản tuyệt đối

Tóm gọn: Image scratch là một image rỗng, hoàn toàn không có gì cả. Nó là lựa chọn tối ưu cho các ứng dụng tĩnh (statically linked binaries) như các ứng dụng Go đã build với CGO_ENABLED=0.

Chỉ sử dụng scratch khi bạn chắc chắn rằng binary của bạn không có bất kỳ dependency nào về shared libraries.

Ví dụ Go với scratch

# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
# Build a fully static binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags="-w -s" -o myapp .

# Stage 2: Runtime - Sử dụng scratch image
FROM scratch
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

Build và kiểm tra kích thước

docker build -t myapp-scratch:go .
docker images | grep myapp-scratch

Output mẫu:

myapp-scratch   go   8.9MB

Đây là kích thước image nhỏ nhất có thể đạt được cho ứng dụng Go!

💡 Mẹo: Luôn cố gắng build binary tĩnh (statically linked) khi sử dụng scratch hoặc distroless/static. Điều này đảm bảo ứng dụng của bạn có mọi thứ cần thiết để chạy mà không phụ thuộc vào bất kỳ thư viện hệ thống nào có sẵn trong image.

Pitfalls & lỗi thường gặp

1. Quên copy file cấu hình hoặc static assets

Lỗi: Ứng dụng chạy nhưng không load được config file, template, hoặc static files.
Nguyên nhân: Chỉ copy binary mà quên các tài nguyên khác cần thiết cho ứng dụng.
Cách fix: Thêm các lệnh COPY tương ứng để copy các thư mục hoặc file cần thiết từ stage builder sang stage runtime.

# Ví dụ: Copy thư mục 'configs' và 'static'
COPY --from=builder /app/configs ./configs
COPY --from=builder /app/static ./static

2. Binary không chạy được trên image runtime tối giản

Lỗi: Image build thành công, nhưng khi chạy lại báo lỗi “Exec format error” hoặc tương tự.
Nguyên nhân: Binary được build trên một kiến trúc hoặc hệ điều hành khác với image runtime, hoặc binary không phải là static.
Cách fix:
* Đảm bảo môi trường build và runtime có cùng kiến trúc (ví dụ: cả hai đều là linux/amd64).
* Sử dụng CGO_ENABLED=0 và các flag -a khi build để tạo binary tĩnh.
* Kiểm tra lại base image runtime có phù hợp không (ví dụ: dùng alpine cho binary tĩnh có thể gặp vấn đề nếu không cẩn thận).

3. npm install thay vì npm ci trong stage builder

Lỗi: Image lớn hơn dự kiến, hoặc dependencies bị lỗi.
Nguyên nhân: npm install có thể cài đặt các gói devDependencies không cần thiết và có thể thay đổi version dependencies một cách không mong muốn.
Cách fix: Luôn sử dụng npm ci --only=production (hoặc npm ci nếu cần devDependencies cho build) để đảm bảo cài đặt chính xác và chỉ các gói cần thiết.

4. Không sử dụng alpine hoặc distroless cho image runtime

Lỗi: Image runtime vẫn còn lớn.
Nguyên nhân: Vẫn sử dụng các base image lớn như ubuntu hoặc debian cho stage runtime.
Cách fix: Chuyển sang các base image nhẹ hơn như alpine (cho các ứng dụng cần shell) hoặc distroless (cho bảo mật và tối giản).

⚠️ Cảnh báo: Image alpine sử dụng musl libc thay vì glibc. Một số ứng dụng hoặc thư viện có thể gặp vấn đề tương thích. Nếu gặp lỗi, hãy thử dùng base image debian hoặc ubuntu phiên bản slim.

Key takeaways

  • Multi-stage builds là kỹ thuật cốt lõi để tạo image Docker nhỏ gọn.
  • Tách biệt rõ ràng môi trường build (chứa SDK, compiler) và môi trường runtime (chỉ chứa ứng dụng và dependencies cần thiết).
  • Sử dụng các base image tối giản như alpine, distroless, hoặc scratch cho stage runtime.
  • Đối với ứng dụng tĩnh (Go, Rust), scratch là lựa chọn nhỏ nhất.
  • Luôn copy artifacts (binary, compiled assets) từ stage builder sang stage runtime, thay vì copy toàn bộ source code và dependencies.
  • Tối ưu hóa kích thước file thực thi bằng các flag build phù hợp (ví dụ: -ldflags="-w -s" cho Go).

FAQ

### Multi-stage build có làm chậm quá trình build Docker không?

Không hẳn. Mặc dù có nhiều stage, Docker chỉ build những stage cần thiết và cache từng layer. Quá trình build cuối cùng (stage runtime) sẽ nhanh hơn vì nó dựa trên image nhỏ hơn.

### Có thể dùng bao nhiêu stage trong một Dockerfile?

Bạn có thể định nghĩa bao nhiêu stage tùy ý. Tuy nhiên, nên giữ số lượng vừa phải để dễ quản lý.

### Làm sao để debug một ứng dụng trong image distroless?

Image distroless không có shell. Để debug, bạn có thể:
1. Build image debug riêng biệt với shell.
2. Sử dụng docker exec với một shell tùy chỉnh (như busybox) và copy nó vào image.
3. Tập trung vào logging tốt trong ứng dụng.

### Tôi có thể copy artifacts giữa các stage không cùng base image không?

Có. Lệnh COPY --from=<stage_name> cho phép bạn copy từ bất kỳ stage nào đã được định nghĩa trước đó, bất kể base image của chúng là gì.

### Khi nào nên dùng Alpine so với Distroless?

  • Alpine: Nhỏ gọn, có shell và trình quản lý gói (apk), phù hợp khi bạn cần tương tác với container hoặc cài thêm công cụ. Tuy nhiên, có thể gặp vấn đề tương thích musl libc.
  • Distroless: Nhỏ gọn hơn nữa, bảo mật cao hơn do không có shell hay công cụ hệ thống. Phù hợp cho production khi bạn chỉ cần chạy ứng dụng.

Cần VPS chạy Docker hiệu quả?

Để tận dụng tối đa các kỹ thuật build image Docker nhẹ nhàng, bạn cần một hạ tầng VPS ổn định và hiệu năng cao. VSIS.net cung cấp các gói VPS cấu hình mạnh mẽ, tối ưu cho Docker, giúp quá trình build, deploy và vận hành ứng dụng của bạn diễn ra nhanh chóng và mượt mà.

Hãy trải nghiệm sự khác biệt với VPS tại VSIS.net ngay hôm nay!

vsis.net/vps

Lên đầu trang