Ba cách tiếp cận khi Dockerize ứng dụng Go gRPC
Lần đầu đưa gRPC lên Docker, mình cứ đinh ninh nó chỉ là HTTP/2 nên cứ đóng gói như REST API thông thường. Kết quả là hệ thống chạy ì ạch, container nặng cả GB và load balancing thì hoạt động theo kiểu… hên xui. Qua các dự án thực tế, mình rút ra 3 hướng đi phổ biến sau:
- Single-stage Build (Kiểu “mì ăn liền”): Bạn copy toàn bộ source code vào image
golang:latestrồi chạygo run. Image này thường nặng trên 800MB. Nó chứa quá nhiều công cụ thừa thãi, vừa tốn dung lượng vừa dễ bị khai thác lỗ hổng bảo mật. - Multi-stage Build với Alpine: Bạn build file binary ở stage 1, sau đó copy sang stage 2 chạy trên nền
alpine. Dung lượng giảm xuống còn khoảng 50MB. Đây là lựa chọn cân bằng, được nhiều anh em sử dụng nhất. - Multi-stage Build với Distroless: Đây là “đỉnh cao” cho môi trường production. Image chỉ chứa duy nhất file binary và các thư viện runtime tối thiểu. Nó cực nhẹ (khoảng 20MB) và cực an toàn vì không có shell để hacker có thể xâm nhập.
Tại sao mình luôn ưu tiên Distroless cho Production?
Trong một dự án xử lý 5.000 requests/giây, mình từng đau đầu vì lỗi memory leak. Sau 2 ngày debug, mình nhận ra nguyên nhân đến từ các tiến trình chạy ngầm không cần thiết trong image OS truyền thống. Khi chuyển sang Distroless, mọi thứ gọn nhẹ hẳn, container khởi động nhanh hơn 30% và bề mặt tấn công được thu hẹp tối đa.
Dưới đây là bảng so sánh thực tế mình đã đo đạc:
| Tiêu chí | Single-stage | Alpine-based | Distroless (Khuyên dùng) |
|---|---|---|---|
| Dung lượng thực tế | ~850MB | ~48MB | ~19MB |
| Khả năng bảo mật | Rất thấp | Trung bình | Rất cao |
| Tốc độ Pull/Push | Rất chậm | Nhanh | Cực nhanh |
Triển khai thực tế: Từ Code đến Container
1. Viết Dockerfile tối ưu cho gRPC Go
Bí kíp ở đây là chia Dockerfile thành 2 giai đoạn: Build và Run. Đừng quên copy file go.mod trước khi copy toàn bộ source. Cách làm này giúp Docker tận dụng layer cache, tiết kiệm đáng kể thời gian chờ đợi khi bạn chỉ sửa vài dòng code logic.
# Stage 1: Build binary
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Tối ưu cache cho dependencies
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Flag -s -w giúp loại bỏ debug thông tin, giảm thêm ~20% size binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o grpc-app ./cmd/server/main.go
# Stage 2: Runtime cực gọn
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /app/grpc-app .
COPY --from=builder /app/certs ./certs
USER nonroot:nonroot
EXPOSE 50051
ENTRYPOINT ["./grpc-app"]
2. Cấu hình TLS nội bộ (Internal TLS)
Giao tiếp giữa các microservices trong mạng nội bộ nếu không mã hóa thì rất rủi ro. Với Docker, cách nhanh nhất là dùng chứng chỉ tự ký (self-signed). Trong code Go, bạn cần nạp credentials để thiết lập kênh truyền an toàn:
// Phía Server
creds, _ := credentials.NewServerTLSFromFile("certs/server.crt", "certs/server.key")
s := grpc.NewServer(grpc.Creds(creds))
// Phía Client
creds, _ := credentials.NewClientTLSFromFile("certs/ca.crt", "")
conn, _ := grpc.Dial("server-service:50051", grpc.WithTransportCredentials(creds))
3. Load Balancing gRPC: Đừng để Docker Compose đánh lừa
Đây là cái bẫy mà nhiều người mắc phải. gRPC duy trì kết nối lâu dài (long-lived connections) trên HTTP/2. Nếu bạn scale server lên 3 replicas trong Docker Compose, mặc định nó chỉ load balance ở Layer 4. Điều này dẫn đến việc client chỉ bám lấy 1 container duy nhất, trong khi 2 cái còn lại hoàn toàn rảnh rỗi.
Giải pháp là sử dụng Client-side Load Balancing. Bạn hãy dùng scheme dns:/// trực tiếp trong code client để gRPC tự biết cách phân phối request:
// Giải pháp: Sử dụng DNS resolver của gRPC
conn, err := grpc.Dial(
"dns:///server-service:50051",
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
Xử lý lỗi kết nối “chết yểu” trên Production
Mình từng gặp trường hợp hệ thống cứ chạy được 15 phút là gRPC tự ngắt kết nối. Client báo lỗi Unavailable dù server vẫn sống nhăn. Vấn đề nằm ở chỗ các Cloud Load Balancer thường tự ngắt các kết nối nhàn rỗi (idle connections).
Lời khuyên: Hãy luôn cấu hình KeepaliveParams. Nó giống như việc client thỉnh thoảng lại “vỗ vai” server một cái để báo hiệu kết nối vẫn cần duy trì.
var kasp = keepalive.ServerParameters{
MaxConnectionIdle: 15 * time.Second,
Time: 20 * time.Second, // Gửi ping mỗi 20s
Timeout: 5 * time.Second,
}
s := grpc.NewServer(grpc.KeepaliveParams(kasp))
Dockerize gRPC không đơn thuần là nhét code vào container. Nó là nghệ thuật tối ưu hóa từ dung lượng image đến cách các service “nói chuyện” với nhau. Hy vọng những kinh nghiệm thực chiến này giúp bạn tránh được những đêm thức trắng debug không đáng có!

