Tối ưu Docker Image với Multi-stage Build: Hướng dẫn giảm kích thước và tăng cường bảo mật

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

Giới thiệu: Khi image Docker quá ‘phì nhiêu’

Hồi mới làm quen với Docker, mình cứ nghĩ cứ cho hết mọi thứ vào một cái Dockerfile là xong. Cài đặt đủ thứ compiler, thư viện phát triển, công cụ debug… vào chung một image. Đến khi đẩy image lên registry xong nhìn cái size mấy trăm MB, thậm chí cả GB mà toát mồ hôi hột. Lúc đó mới bắt đầu tìm hiểu cách tối ưu. Multi-stage build chính là “vị cứu tinh” mà mình đã tìm thấy, và giờ sau hơn 6 tháng triển khai trên production, mình thấy nó cực kỳ hữu ích.

Multi-stage build không chỉ giúp giảm đáng kể kích thước image, mà còn tăng cường bảo mật cho ứng dụng. Kỹ thuật này cho phép bạn tách biệt môi trường build (nơi cần nhiều công cụ) và môi trường runtime (nơi chỉ cần ứng dụng chạy). Kết quả là một image cuối cùng gọn nhẹ, chỉ chứa những gì thực sự cần thiết để chạy ứng dụng.

Tại sao cần Multi-stage Build?

  • Giảm kích thước image: Đây là lợi ích nổi bật nhất. Image nhỏ hơn giúp quá trình đẩy/kéo nhanh hơn, tiết kiệm đáng kể dung lượng lưu trữ và băng thông.
  • Tăng cường bảo mật: Khi loại bỏ các công cụ build, thư viện không cần thiết và cả mã nguồn khỏi image cuối cùng, bạn sẽ giảm đáng kể bề mặt tấn công tiềm năng.
  • Tối ưu cache: Các stage riêng biệt giúp Docker tận dụng cache hiệu quả hơn, từ đó tăng tốc độ build cho những lần sau.
  • Dockerfile gọn gàng hơn: Việc tách bạch các bước rõ ràng giúp Dockerfile dễ đọc và dễ quản lý hơn.

Không để bạn đợi lâu, giờ mình sẽ hướng dẫn bạn cách áp dụng Multi-stage Build ngay lập tức.

Quick Start: Tối ưu image trong 5 phút

Để bắt đầu, hãy cùng mình đi qua một ví dụ thực tế. Mình sẽ sử dụng một ứng dụng Go đơn giản, bởi vì Go biên dịch ra một binary tĩnh, rất phù hợp để minh họa rõ ràng sự khác biệt về kích thước image.

1. Chuẩn bị ứng dụng Go

Tạo một thư mục mới, ví dụ my_go_app, và tạo file main.go với nội dung sau:

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello from Multi-stage Docker Build!")
	})

	fmt.Println("Server starting on port 8080...")
	http.ListenAndServe(":8080", nil)
}

2. Dockerfile truyền thống (Single-stage)

Tạo file Dockerfile.single trong cùng thư mục:

# Sử dụng image golang đầy đủ để build và chạy
FROM golang:1.22

WORKDIR /app

# Copy toàn bộ mã nguồn vào image
COPY . .

# Khởi tạo go mod (nếu chưa có) và tải dependencies
RUN go mod init example.com/myapp || true
RUN go mod tidy

# Biên dịch ứng dụng
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/myapp .

# Mở port
EXPOSE 8080

# Chạy ứng dụng
CMD ["/app/myapp"]

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

docker build -t my-go-app-single -f Dockerfile.single .
docker images | grep my-go-app-single

Bạn sẽ thấy kích thước image khá lớn, có thể lên tới vài trăm MB.

3. Dockerfile với Multi-stage Build

Tạo file Dockerfile.multi trong cùng thư mục:

# Stage 1: Môi trường build
FROM golang:1.22 AS builder

WORKDIR /app

COPY . .

RUN go mod init example.com/myapp || true
RUN go mod tidy

# Biên dịch ứng dụng
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/myapp .

# Stage 2: Môi trường runtime gọn nhẹ
FROM alpine:latest

WORKDIR /app

# Chỉ copy binary đã biên dịch từ stage 'builder'
COPY --from=builder /app/myapp .

EXPOSE 8080

CMD ["./myapp"]

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

docker build -t my-go-app-multi -f Dockerfile.multi .
docker images | grep my-go-app-multi

Bạn sẽ thấy kích thước image my-go-app-multi nhỏ hơn rất nhiều, chỉ vài MB! Đó chính là sức mạnh của Multi-stage Build.

Để chạy ứng dụng:

docker run -p 8080:8080 my-go-app-multi

Mở trình duyệt và truy cập http://localhost:8080 để kiểm tra.

Giải thích chi tiết: Multi-stage Build hoạt động thế nào?

Multi-stage Build hoạt động bằng cách định nghĩa nhiều giai đoạn (stage) ngay trong một Dockerfile. Mỗi giai đoạn bắt đầu với lệnh FROM và có thể được đặt tên bằng AS <tên_stage>. Điểm mấu chốt là bạn có thể sao chép các artifact (ví dụ: file biên dịch, cấu hình) từ một stage trước đó sang một stage sau. Để làm điều này, chúng ta sử dụng lệnh COPY --from=<tên_stage>.

Điều quan trọng là chỉ stage cuối cùng mới là stage tạo ra image Docker hoàn chỉnh. Tất cả các stage trung gian khác chỉ tồn tại trong quá trình build và không được lưu trữ trong image cuối cùng. Nhờ vậy, chúng ta có thể loại bỏ mọi thứ không cần thiết cho runtime, như compiler, SDK, thư viện phát triển hay cache.

Cấu trúc cơ bản của Multi-stage Build

# Stage 1: Build
FROM some_build_image AS builder
WORKDIR /app
COPY . .
RUN build_command

# Stage 2: Test (tùy chọn)
FROM some_test_image AS tester
WORKDIR /app
COPY --from=builder /app/build_output .
RUN test_command

# Stage 3: Final runtime
FROM some_runtime_image
WORKDIR /app
COPY --from=builder /app/build_output .
EXPOSE port
CMD ["./app"]

Như bạn thấy, chúng ta có thể định nghĩa nhiều stage khác nhau. Stage tester có thể lấy output từ builder để chạy các bài kiểm tra. Sau đó, stage cuối cùng sẽ chỉ lấy những gì cần thiết từ builder (hoặc tester nếu phù hợp) để tạo ra image runtime.

Lợi ích cụ thể

  • Kích thước image siêu nhỏ: Multi-stage Build giúp giảm thiểu đáng kể dung lượng lưu trữ trên registry và rút ngắn thời gian kéo/đẩy image. Đặc biệt với các ứng dụng Go, Rust, hoặc C/C++, kích thước image có thể giảm từ hàng trăm MB xuống chỉ còn vài MB.
  • Tăng cường bảo mật: Image cuối cùng chỉ chứa ứng dụng và các thư viện runtime tối thiểu. Không có compiler, công cụ phát triển hay mã nguồn gốc. Điều này giúp giảm bề mặt tấn công tiềm năng, khiến kẻ xấu khó khăn hơn trong việc khai thác lỗ hổng hoặc trích xuất mã nguồn.
  • Đơn giản hóa Dockerfile: Dù có vẻ dài hơn, mỗi stage lại có một mục đích rõ ràng. Điều này giúp Dockerfile dễ đọc và bảo trì hơn rất nhiều.
  • Tận dụng caching hiệu quả: Nếu chỉ mã nguồn thay đổi, Docker có thể tái sử dụng cache từ các bước cài đặt dependency ở stage build, nhờ đó quá trình build sẽ nhanh hơn.

Nâng cao: Tối ưu hơn với Multi-stage Build

Sau khi đã nắm vững các kiến thức cơ bản, giờ là lúc chúng ta cùng khám phá một số kỹ thuật nâng cao để tối ưu Multi-stage Build hiệu quả hơn nữa.

1. Sử dụng nhiều stage build

Đôi khi, ứng dụng của bạn có thể có nhiều loại dependencies hoặc các bước build phức tạp. Trong trường hợp này, bạn có thể tạo nhiều stage build để tách biệt chúng. Ví dụ: một stage chuyên cài đặt NPM dependencies, một stage khác để build frontend, và một stage thứ ba để build backend.

# Stage 1: Cài đặt Node.js dependencies
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# Stage 2: Build frontend (ví dụ với React/Angular/Vue)
FROM node:18-alpine AS frontend_builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build:frontend

# Stage 3: Build backend (ví dụ với NestJS/Express)
FROM node:18-alpine AS backend_builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build:backend

# Stage 4: Final runtime image
FROM node:18-alpine
WORKDIR /app

# Copy frontend và backend đã build
COPY --from=frontend_builder /app/dist/frontend ./dist/frontend
COPY --from=backend_builder /app/dist/backend ./dist/backend

# Cài đặt chỉ các production dependencies cho backend
COPY package.json package-lock.json ./
RUN npm ci --only=production

EXPOSE 3000
CMD ["node", "./dist/backend/main.js"]

2. Tận dụng Build Arguments (ARG)

Bạn có thể truyền các biến vào quá trình build bằng ARG. Điều này đặc biệt hữu ích khi bạn muốn thay đổi phiên bản của một công cụ hoặc thư viện trong quá trình build mà không cần sửa Dockerfile.

ARG NODE_VERSION=18-alpine

# Stage 1: Dependencies
FROM node:${NODE_VERSION} AS deps
# ... (các bước cài đặt dependency)

# Stage 2: Final runtime
FROM node:${NODE_VERSION}
# ... (các bước copy và chạy ứng dụng)

Khi build, bạn có thể override giá trị mặc định:

docker build --build-arg NODE_VERSION=20-alpine -t my-app .

3. Tối ưu caching

Docker cache các layer dựa trên thứ tự các lệnh trong Dockerfile. Để tối ưu Multi-stage Build, hãy đặt các lệnh ít thay đổi lên trước:

  • Dependencies trước mã nguồn: Luôn copy package.json/go.mod và chạy lệnh cài đặt dependency trước khi copy toàn bộ mã nguồn. Nếu chỉ mã nguồn thay đổi, Docker sẽ tái sử dụng layer dependency.
  • Tách biệt các bước cài đặt: Nếu Dockerfile của bạn có nhiều lệnh RUN, hãy cân nhắc gộp chúng lại. Điều này áp dụng khi các lệnh có liên quan chặt chẽ và thường xuyên thay đổi cùng nhau, nhằm giảm số lượng layer không cần thiết.

Tips thực tế & Kinh nghiệm cá nhân

Sau nhiều lần “đau đầu” triển khai Docker trên môi trường production, mình đã đúc kết được một vài kinh nghiệm “xương máu” liên quan đến Multi-stage Build mà muốn chia sẻ cùng bạn:

  • Chọn Base Image phù hợp:
    • Cho stage build: Nên dùng image đầy đủ hơn (ví dụ: golang:1.22, node:18) vì chúng thường có sẵn các công cụ cần thiết để biên dịch.
    • Cho stage runtime: Luôn ưu tiên các image siêu nhỏ như alpine (ví dụ: alpine:latest, node:18-alpine, scratch). scratch là image trống rỗng, chỉ dùng được cho các binary tĩnh hoàn toàn như Go. Nếu ứng dụng của bạn cần glibc hoặc các thư viện khác, alpine là lựa chọn tuyệt vời.
  • Luôn dọn dẹp ở các stage trung gian: Trước khi COPY --from sang stage tiếp theo, hãy đảm bảo rằng bạn đã xóa mọi file tạm, cache, hoặc công cụ không cần thiết trong stage hiện tại. Việc này giúp giảm kích thước của “artifact” mà bạn copy đi. Mặc dù Docker sẽ tự động loại bỏ các layer không được sử dụng ở stage cuối cùng, việc dọn dẹp chủ động vẫn là một thói quen tốt.
  • Kiểm tra file permissions: Khi COPY --from, đôi khi quyền của file có thể không như mong muốn. Hãy nhớ đặt lại quyền nếu cần (ví dụ: RUN chmod +x /app/myapp).
  • Sử dụng .dockerignore: Đây là một file cực kỳ quan trọng mà nhiều người mới thường bỏ qua. .dockerignore giúp loại trừ các file và thư mục không cần thiết – ví dụ như node_modules cục bộ, .git, .env, dist – khỏi context của Docker build. Điều này không chỉ làm cho quá trình build nhanh hơn mà còn tránh việc copy những thứ không liên quan vào image cuối cùng.
  • Đừng quên EXPOSECMD/ENTRYPOINT: Đảm bảo rằng stage cuối cùng của bạn đã khai báo đúng port ứng dụng sẽ lắng nghe (EXPOSE) và lệnh để khởi chạy ứng dụng (CMD hoặc ENTRYPOINT).
  • Debug Multi-stage Build: Nếu gặp lỗi trong một stage trung gian, bạn có thể build đến stage đó và kiểm tra. Ví dụ, để debug stage builder:
    docker build --target builder -t my-app-builder-debug .
    docker run -it my-app-builder-debug bash
    

    Điều này giúp bạn vào bên trong container của stage đó để kiểm tra file, chạy lệnh và tìm hiểu nguyên nhân lỗi.

Tóm lại, Multi-stage Build là một kỹ thuật cực kỳ mạnh mẽ và là một “best practice” không thể thiếu khi bạn làm việc với Docker ở quy mô production. Kỹ thuật này không chỉ giúp bạn tạo ra những image gọn gàng, nhanh nhẹn mà còn tăng cường đáng kể tính bảo mật cho ứng dụng. Đừng chần chừ, hãy thử áp dụng nó vào các dự án của bạn ngay hôm nay!

Share: