Go 容器化部署实战——多阶段 Dockerfile 构建、最小化镜像、优雅启停
Go 容器化部署实战——多阶段 Dockerfile 构建、最小化镜像、优雅启停
适读人群:要把 Go 服务容器化的工程师、想优化 Docker 镜像大小和启停流程的开发者 | 阅读时长:约18分钟 | 核心价值:构建出一个生产可用的 Go 容器镜像,镜像体积最小化,启停流程符合 K8s 规范
那个让我丢人现眼的镜像——1.2GB 的 Go 服务
2022年底转 Go 后,我写的第一个 Go 服务需要容器化部署。当时的 Dockerfile 是这样的:
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]构建出来一看:镜像 1.2GB。
运维同学截图发给我,说:"你这个 Go 服务比我们的 Java SpringBoot 镜像还大。"
我非常惭愧。因为 Go 的一个核心优势就是可以编译成静态二进制,镜像理论上可以做到几MB。
我在那个基础镜像上带了整个 Go 工具链(几百MB)、gcc、以及各种系统工具。
花了半天研究,最终把镜像从1.2GB压缩到了18MB。这篇文章把这个过程完整记录下来。
多阶段构建:Go 镜像优化的核心技术
多阶段构建(Multi-stage Build)是 Docker 17.05+ 支持的特性,允许在一个 Dockerfile 里用多个 FROM,每个阶段可以只保留必要的产物。
# 第一阶段:构建阶段(使用完整的 Go 工具链)
FROM golang:1.21-alpine AS builder
WORKDIR /build
# 先只复制 go.mod 和 go.sum,利用 Docker 层缓存
# 只要依赖没变,这一步就不需要重新执行(go mod download 很耗时)
COPY go.mod go.sum ./
RUN go mod download
# 再复制源代码(源代码变动频繁,放后面可以减少无效缓存失效)
COPY . .
# 构建静态二进制
# CGO_ENABLED=0:禁用 CGO,生成完全静态链接的二进制(不依赖任何系统 .so 文件)
# -ldflags="-s -w":去掉 debug symbol 和 DWARF 信息,减小二进制体积约30%
# -ldflags="-s -w" 还去掉了 Go 版本等元数据(生产中推荐)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /build/server ./cmd/server
# 第二阶段:运行阶段(使用最小化基础镜像)
FROM scratch
# 从构建阶段只复制必要的文件
# 二进制文件
COPY --from=builder /build/server /server
# 如果需要 SSL 证书(调用 HTTPS 接口时需要)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# 如果需要时区数据(处理时间相关逻辑时需要)
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# 非 root 用户运行(安全最佳实践)
# scratch 镜像没有 useradd 命令,需要在构建阶段创建用户
# 这里用 numeric UID/GID,避免依赖 /etc/passwd
USER 65534:65534
EXPOSE 8080
EXPOSE 50051
ENTRYPOINT ["/server"]构建结果:
docker build -t myapp:v1.0 .
docker images myapp:v1.0
# REPOSITORY TAG IMAGE ID SIZE
# myapp v1.0 abc123def456 18.2MB比 scratch 更实用:distroless 和 alpine
scratch 是完全空白的镜像,优点是体积最小,缺点是无法 exec 进去调试,也没有 shell。
根据不同场景选择基础镜像:
# 方案1:scratch(最小,适合生产)
FROM scratch
# 方案2:distroless(Google 出品,有最基本的安全工具,适合生产)
FROM gcr.io/distroless/static-debian11
# 大小约 ~20MB,包含 ca-certificates 和时区数据
# 方案3:alpine(有 shell 和包管理器,便于调试,适合开发/测试)
FROM alpine:3.19
# 大小约 ~10MB,可以 apk add 安装工具我的建议: 生产用 distroless/static,开发/测试用 alpine。
优雅启停:符合 Kubernetes 规范
容器化部署后,最重要的不是镜像大小,而是优雅启停——服务必须能干净地处理启动和关闭,不丢请求、不留僵尸连接。
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
)
type Server struct {
grpcServer *grpc.Server
httpServer *http.Server
wg sync.WaitGroup
}
func (s *Server) Start() error {
// 1. 启动 gRPC 服务
lis, err := net.Listen("tcp", ":50051")
if err != nil {
return fmt.Errorf("gRPC 监听失败: %w", err)
}
// 注册健康检查(Kubernetes liveness/readiness probe 需要)
healthSrv := health.NewServer()
grpc_health_v1.RegisterHealthServer(s.grpcServer, healthSrv)
healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
s.wg.Add(1)
go func() {
defer s.wg.Done()
log.Println("gRPC 服务启动: :50051")
if err := s.grpcServer.Serve(lis); err != nil {
log.Printf("gRPC 服务退出: %v", err)
}
}()
// 2. 启动 HTTP 服务(用于健康检查、metrics)
s.httpServer = &http.Server{
Addr: ":8080",
Handler: healthMux(),
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
log.Println("HTTP 服务启动: :8080")
if err := s.httpServer.ListenAndServe(); err != http.ErrServerClosed {
log.Printf("HTTP 服务退出: %v", err)
}
}()
return nil
}
// GracefulStop 优雅停机
func (s *Server) GracefulStop(ctx context.Context) {
log.Println("开始优雅停机...")
// 第一步:标记服务为 NOT SERVING(K8s 会停止向该 Pod 发流量)
// 这一步很重要!先停止接收新请求,再处理完存量请求
if healthSrv, ok := s.grpcServer.(*health.Server); ok {
healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_NOT_SERVING)
}
// 给 K8s 一些时间更新 Endpoints(iptables 规则传播需要时间)
time.Sleep(5 * time.Second)
// 第二步:优雅停止 gRPC(等待所有在途 RPC 完成)
done := make(chan struct{})
go func() {
s.grpcServer.GracefulStop()
close(done)
}()
select {
case <-done:
log.Println("gRPC 服务已优雅停止")
case <-ctx.Done():
log.Println("优雅停机超时,强制停止 gRPC")
s.grpcServer.Stop()
}
// 第三步:停止 HTTP 服务
if err := s.httpServer.Shutdown(ctx); err != nil {
log.Printf("HTTP 服务停止错误: %v", err)
}
log.Println("优雅停机完成")
}
func healthMux() http.Handler {
mux := http.NewServeMux()
// K8s liveness probe
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
// K8s readiness probe
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
// 可以检查数据库连接等依赖是否就绪
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))
})
return mux
}
func main() {
grpcServer := grpc.NewServer()
// 注册业务服务...
srv := &Server{grpcServer: grpcServer}
if err := srv.Start(); err != nil {
log.Fatal(err)
}
// 监听系统信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
log.Printf("收到信号: %v,开始优雅停机", sig)
// 30秒内必须完成停机
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.GracefulStop(ctx)
}Kubernetes 配置最佳实践
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
template:
spec:
# terminationGracePeriodSeconds 要 > 优雅停机超时时间
terminationGracePeriodSeconds: 60
containers:
- name: order-service
image: myapp:v1.0
ports:
- containerPort: 8080
- containerPort: 50051
# 资源限制(必须设置,防止一个 Pod 影响其他)
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
# 存活探针:失败时重启容器
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
# 就绪探针:失败时从 Service 摘除
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
# 停机前执行(给 K8s Endpoints 传播留时间)
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]踩坑实录
坑1:ENTRYPOINT 用 shell 形式,导致 SIGTERM 无法传递给进程
现象: K8s 发送 SIGTERM 信号,容器一直等到 terminationGracePeriodSeconds 超时才被强制终止,业务进程没有执行优雅停机。
原因: 使用了 CMD ./server 这种 shell 形式,会启动 /bin/sh -c ./server,SIGTERM 发给了 sh,sh 没有把信号转发给 server 进程。
解法: 必须用 exec 形式(JSON 数组):
# 错误(shell 形式)
CMD ./server
# 正确(exec 形式,进程直接是 PID 1,能接收信号)
ENTRYPOINT ["/server"]坑2:scratch 镜像里没有时区数据,时间显示错误
现象: 日志时间总是 UTC,数据库里的时间也不对,因为 time.LoadLocation("Asia/Shanghai") 返回 error。
原因: scratch 镜像没有任何文件,包括 /usr/share/zoneinfo。
解法: 在 Dockerfile 里从 builder 阶段复制时区数据(如文章中所示),或者用 time/tzdata 包内嵌时区数据:
import _ "time/tzdata" // 把时区数据编译进二进制,不依赖系统文件坑3:多阶段构建没有使用层缓存,每次依赖下载都重新跑
现象: 每次修改一行代码,CI/CD 构建时都要重新 go mod download,耗时4-5分钟。
原因: COPY . . 放在 go mod download 之前,源代码任何修改都会导致后面的层缓存失效,包括依赖下载。
解法: 分两步复制:先复制 go.mod go.sum,执行 go mod download,再复制源代码(如文章中 Dockerfile 所示)。这样只要依赖没变,下载这步就会命中缓存。
