从零开始的云原生之旅(一):把 Go 应用塞进 Docker
第一次容器化 Go 应用的完整记录 | 包含所有踩过的坑
文章目录
- 前言
- 一、为什么要容器化?
- 1.1 我遇到的痛点
- 1.2 容器化解决了什么
 
- 二、构建第一个 Go 微服务
- 2.1 项目结构设计
- 2.2 实现健康检查(给 K8s 用的)
- 2.3 优雅关闭(避免请求丢失)
- 2.4 Prometheus 监控(留个口子)
 
- 三、编写 Dockerfile
- 3.1 第一版:直接构建(800MB 的怪物)
- 3.2 第二版:多阶段构建(瘦身到 49.1MB)
- 3.3 踩坑记录
 
- 四、构建和测试
- 4.1 本地运行测试
- 4.2 Docker 构建
- 4.3 容器运行验证
 
- 五、优化技巧总结
- 结语
前言
大家好,我是一个正在学习云原生的 Go 开发者。
我的背景:
- 会写 Go 代码(做过几个小项目)
- 知道 Docker 是啥(用过 docker run、docker ps这些命令)
- Kubernetes?听说过,但没用过
最近想系统学习云原生,决定从零开始做一个完整的项目。这篇文章记录我第一次容器化 Go 应用的完整过程,包括:
- 我怎么写的代码
- 我怎么优化镜像的(从 800MB 到 49.1MB)
- 我踩过的所有坑(重点!)
如果你也是 Go 开发者,想学习容器化,这篇文章应该能帮到你。
一、为什么要容器化?
1.1 我遇到的痛点
之前我部署 Go 应用是这样的:
1. 在开发机器上:go build
2. scp 上传到服务器
3. ssh 登录服务器
4. 启动:nohup ./app &
5. 祈祷不要出问题...问题来了:
❌ 开发环境能跑,服务器跑不起来(glibc 版本不一样)
❌ 换台服务器要重新配置环境
❌ 进程挂了,要手动重启
❌ 多个服务端口冲突
❌ 回滚?删除重新上传...简单说:太麻烦了,还容易出错。
1.2 容器化解决了什么
听说 Docker 可以解决这些问题,我决定试试:
容器化后:
✅ 镜像包含所有依赖(环境一致)
✅ 一次构建,到处运行
✅ 容器挂了自动重启(配合 K8s)
✅ 版本管理简单(镜像 tag)
✅ 回滚?切换镜像版本就行好,决定了,开始搞!
二、构建第一个 Go 微服务
2.1 项目结构设计
参考了几个开源项目,我设计了这样的结构:
cloudnative-go-journey/
├── src/
│   ├── main.go              # 入口文件
│   ├── config/              # 配置管理
│   │   └── config.go
│   ├── handler/             # 路由处理
│   │   ├── health.go        # 健康检查(K8s 会用到)
│   │   └── hello.go         # 业务接口
│   ├── middleware/          # 中间件
│   │   ├── logger.go        # 日志
│   │   └── metrics.go       # 监控指标收集
│   └── metrics/
│       └── prometheus.go    # Prometheus 配置
├── Dockerfile               # 重点!
└── go.mod为什么这么设计?
- 按功能分包,后面代码多了好维护
- 把健康检查单独出来,K8s 要用
- Prometheus 监控,提前准备好
2.2 实现健康检查(给 K8s 用的)
这是我第一次听说"健康检查"这个概念。简单理解:
健康检查 = K8s 定期问你:"服务还活着吗?"
你的程序要回答:"活着!"(返回 200 OK)
如果你不回答,或者回答太慢:
→ K8s 认为你挂了
→ 重启你的容器代码实现 (handler/health.go):
            
            
              go
              
              
            
          
          package handler
import (
    "net/http"
    "time"
    "github.com/gin-gonic/gin"
)
var startTime = time.Now()
// HealthCheck - 告诉 K8s:"我还活着"
func HealthCheck(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
        "status": "healthy",
        "uptime": time.Since(startTime).String(),
    })
}
// ReadinessCheck - 告诉 K8s:"我准备好接收流量了"
func ReadinessCheck(c *gin.Context) {
    // 这里可以检查数据库连接、Redis 连接等
    // 如果依赖服务没准备好,返回 503
    c.JSON(http.StatusOK, gin.H{
        "status": "ready",
    })
}注册路由 (main.go):
            
            
              go
              
              
            
          
          router.GET("/health", handler.HealthCheck)
router.GET("/ready", handler.ReadinessCheck)测试一下:
            
            
              bash
              
              
            
          
          go run src/main.go
# 另开终端
curl http://localhost:8080/health
# 输出: {"status":"healthy","uptime":"5s"}✅ 可以!
2.3 优雅关闭(避免请求丢失)
这个概念我一开始也不懂。后来查资料才明白:
没有优雅关闭:
Ctrl+C → 程序立即退出 → 正在处理的请求全部失败 ❌
有优雅关闭:
Ctrl+C → 停止接收新请求 → 等待现有请求完成 → 退出 ✅在 K8s 环境特别重要:
K8s 滚动更新流程:
1. 启动新版本 Pod
2. 给旧版本发 SIGTERM 信号(让它退出)
3. 等待 30 秒
4. 如果还没退,强制 kill(SIGKILL)
如果你没有优雅关闭:
→ 收到 SIGTERM,程序直接退出
→ 正在处理的请求全部失败
→ 用户收到 502 错误 ❌代码实现 (main.go 核心部分):
            
            
              go
              
              
            
          
          func main() {
    // 创建 HTTP 服务器
    srv := &http.Server{
        Addr:    ":8080",
        Handler: router,
    }
    // 用 goroutine 启动服务器(不阻塞)
    go func() {
        log.Println("🚀 Server starting on :8080...")
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("Server failed: %v", err)
        }
    }()
    // 优雅关闭的核心代码
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit  // 阻塞在这里,等待信号
    log.Println("🛑 Shutting down...")
    // 给 5 秒时间处理完现有请求
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("Shutdown failed: %v", err)
    }
    log.Println("✅ Server exited cleanly")
}测试一下:
            
            
              bash
              
              
            
          
          go run src/main.go
# 等一会
# 按 Ctrl+C
# 输出:
# 🛑 Shutting down...
# ✅ Server exited cleanly✅ 优雅退出成功!
2.4 Prometheus 监控(留个口子)
虽然现在不用监控,但提前加上:
            
            
              go
              
              
            
          
          // metrics/prometheus.go
var (
    // 请求计数器
    RequestCounter = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "api_requests_total",
            Help: "Total API requests",
        },
        []string{"method", "endpoint", "status"},
    )
)
// middleware/metrics.go  
func Metrics() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()  // 处理请求
        
        // 记录耗时和状态
        duration := time.Since(start).Seconds()
        status := strconv.Itoa(c.Writer.Status())
        RequestCounter.WithLabelValues(c.Request.Method, c.Request.URL.Path, status).Inc()
    }
}访问 /metrics 可以看到统计数据:
api_requests_total{method="GET",endpoint="/health",status="200"} 5三、编写 Dockerfile
3.1 第一版:直接构建(800MB 的怪物)
我的第一版 Dockerfile(错误示范):
            
            
              dockerfile
              
              
            
          
          FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o api ./src/main.go
EXPOSE 8080
CMD ["./api"]构建并查看大小:
            
            
              bash
              
              
            
          
          docker build -t my-api:v1 .
docker images my-api:v1
# REPOSITORY   TAG    SIZE
# my-api       v1     842MB  ← 卧槽,800 多 MB!问题:
golang:1.21 基础镜像 = 800MB+
你的程序 = 10MB
总计 = 810MB
而且:
- 包含了完整的 Go 编译环境(根本用不到)
- 包含了 git、gcc 等工具(也用不到)这要是部署到生产,网络传输得多久?不行,得优化!
3.2 第二版:多阶段构建(瘦身到 49MB)
查了资料,发现 Docker 有个"多阶段构建"的技巧:
思路:
阶段 1:用完整的 Go 环境编译代码 → 生成二进制文件
阶段 2:用最小的镜像 + 二进制文件 → 最终镜像
就像:
编译 → 工厂(需要各种工具)
运行 → 家里(只需要成品)优化后的 Dockerfile:
            
            
              dockerfile
              
              
            
          
          # ==================== 阶段 1:编译阶段 ====================
FROM golang:1.21-alpine AS builder
# alpine 版本只有 150MB,比标准版小多了
WORKDIR /app
# 先复制依赖文件(技巧:利用 Docker 缓存)
COPY go.mod go.sum ./
RUN go mod download
# 再复制源代码
COPY src/ ./src/
# 编译(重点在这些参数)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -a -installsuffix cgo \
    -ldflags="-w -s" \
    -o /app/api ./src/main.go
# ==================== 阶段 2:运行阶段 ====================
FROM alpine:latest
# alpine 只有 5MB!
# 安装 CA 证书(HTTPS 需要)
RUN apk --no-cache add ca-certificates
# 创建普通用户(安全最佳实践:不用 root)
RUN addgroup -g 1000 appuser && \
    adduser -D -u 1000 -G appuser appuser
WORKDIR /app
# 关键:只复制二进制文件,不要其他东西
COPY --from=builder /app/api .
# 给文件设置所有者
RUN chown -R appuser:appuser /app
# 切换到普通用户运行(不用 root)
USER appuser
EXPOSE 8080
ENTRYPOINT ["./api"]再次构建:
            
            
              bash
              
              
            
          
          docker build -t my-api:v2 .
docker images my-api:v2
# REPOSITORY   TAG    SIZE
# my-api       v2     49.1MB  ← 成功!从 842MB → 49.1MB效果对比:
第一版:842MB
第二版:49.1MB3.3 踩坑记录
坑 1:忘记设置 CGO_ENABLED=0
我的错误:
            
            
              dockerfile
              
              
            
          
          RUN go build -o /app/api ./src/main.go构建成功,但运行时报错:
            
            
              bash
              
              
            
          
          docker run my-api:v2
# 错误:
standard_init_linux.go:228: exec user process caused: no such file or directory懵了:文件明明存在啊,为什么说找不到?
查了半天才知道:
原因:
- 默认 CGO_ENABLED=1,编译出的二进制依赖 glibc(GNU C 库)
- alpine 用的是 musl libc(轻量级 C 库)
- 两个不兼容!
- 虽然二进制文件存在,但依赖库找不到
解决:
CGO_ENABLED=0  ← 完全静态编译,不依赖任何库验证是否静态编译:
            
            
              bash
              
              
            
          
          docker run -it --entrypoint sh my-api:v2
ldd /app/api
# 输出:
# not a dynamic executable  ← 说明是静态编译,OK!教训 :alpine 镜像必须用 CGO_ENABLED=0!
坑 2:端口被占用
现象:
            
            
              bash
              
              
            
          
          go run src/main.go
# 报错:
Failed to start server: listen tcp :8080: bind: Only one usage of each socket address...原因:我之前运行的进程没关,还占着 8080 端口。
解决(Windows):
            
            
              powershell
              
              
            
          
          # 查找谁占用了 8080
netstat -ano | findstr :8080
# 输出:
# TCP    0.0.0.0:8080    0.0.0.0:0    LISTENING    12345
#                                                    ↑ PID
# 杀死进程
taskkill /F /PID 12345教训:跑新程序前,先检查端口!
坑 3:Docker 缓存让我怀疑人生
场景:
1. 我修改了 main.go 的代码
2. docker build -t my-api:v2 .
3. docker run my-api:v2
4. 运行的还是旧代码!!!为什么?Docker 的分层缓存机制:
Dockerfile:
COPY . .           ← 复制所有文件
RUN go build       ← 编译
如果文件内容没变 → 使用缓存层 → 不重新编译 → 运行旧代码解决方法 1:强制重新构建
            
            
              bash
              
              
            
          
          docker build --no-cache -t my-api:v2 .解决方法 2:优化 Dockerfile(推荐)
            
            
              dockerfile
              
              
            
          
          # 先复制依赖文件(很少变)
COPY go.mod go.sum ./
RUN go mod download      ← 这一层会被缓存
# 再复制源代码(经常变)
COPY src/ ./src/
RUN go build             ← 只有这一层重新构建
# 好处:依赖没变时,只重新编译代码,快 10 倍!教训:理解 Docker 缓存机制,合理安排 COPY 顺序!
坑 4:-ldflags="-w -s" 是啥?
查资料时看到这个参数,试了一下:
            
            
              bash
              
              
            
          
          # 不加参数
go build -o api1 ./src/main.go
ls -lh api1
# -rwxr-xr-x  1 user  staff   20M  api1  ← 20MB
# 加参数
go build -ldflags="-w -s" -o api2 ./src/main.go
ls -lh api2什么原理?
-w: 去掉 DWARF 调试信息
-s: 去掉符号表
简单说:去掉调试用的东西,程序小了,但不能 debug 了
生产环境:用 -w -s(不需要 debug)
开发环境:不用(方便 debug)效果:减小 65% 的体积!
四、构建和测试
4.1 本地运行测试
            
            
              bash
              
              
            
          
          # 先确保代码能跑
go run src/main.go
# 测试接口(另开终端)
curl http://localhost:8080/health
curl http://localhost:8080/api/v1/hello?name=Docker
# 看到返回就 OK4.2 Docker 构建
            
            
              bash
              
              
            
          
          # 构建镜像
docker build -t cloudnative-go-api:v0.1 .
# 观察构建过程
[+] Building 45.2s (15/15) FINISHED
 => [builder 1/6] FROM golang:1.21-alpine      # 阶段 1
 => [builder 4/6] RUN go mod download
 => [builder 6/6] RUN CGO_ENABLED=0 go build...
 => [stage-1 2/4] RUN apk add ca-certificates  # 阶段 2  
 => [stage-1 4/4] COPY --from=builder /app/api .
 => exporting to image
 => => writing image sha256:abc123...看到 2 个阶段:
- [builder ...]- 编译阶段(用完就扔)
- [stage-1 ...]- 运行阶段(最终镜像)
4.3 容器运行验证
            
            
              bash
              
              
            
          
          # 运行容器
docker run -d -p 8080:8080 --name api-test cloudnative-go-api:v0.1
# 等 2 秒让容器启动
sleep 2
# 测试
curl http://localhost:8080/health
# 输出: {"status":"healthy","uptime":"2s"}
curl http://localhost:8080/api/v1/hello
# 输出: {"message":"Hello, CloudNative!",...}
# 查看日志
docker logs api-test
# 看到:
# 🚀 Server starting on :8080...
# [GET] /health 172.17.0.1 | Status: 200 | Latency: 500µs
# 停止并删除测试容器
docker stop api-test
docker rm api-test✅ 所有测试通过!
五、优化技巧总结
经过这次实践,我总结了几个关键点:
1. 多阶段构建是必须的
单阶段:842MB  ❌
多阶段:49.1MB   ✅
节省 98% 空间!2. CGO_ENABLED=0 很重要
alpine 镜像必须用静态编译
否则运行时找不到 glibc3. 先复制 go.mod,后复制代码
            
            
              dockerfile
              
              
            
          
          # 好的顺序:
COPY go.mod go.sum ./
RUN go mod download        ← 依赖层(缓存)
COPY src/ ./src/
RUN go build               ← 代码层(经常变)
# 坏的顺序:
COPY . .                   ← 全部复制
RUN go mod download        ← 每次都重新下载依赖
RUN go build
修改代码时:
好的顺序 → 只重新编译,快 10 倍
坏的顺序 → 重新下载依赖 + 编译,很慢4. 用 appuser 而不是 root
root 运行 → 容器被攻破 = 拿到 root 权限 ❌
普通用户 → 容器被攻破 = 只有普通权限 ✅
安全第一!结语
第一次容器化 Go 应用,踩了不少坑,但收获很大:
技术收获:
- ✅ 学会了多阶段构建
- ✅ 理解了静态编译的重要性
- ✅ 掌握了 Docker 缓存优化
- ✅ 实现了优雅关闭
思维转变:
之前:写完代码就完事
现在:要考虑容器化、监控、健康检查...
云原生 = 不只是写代码,还要考虑运维!下一篇我会分享如何把这个容器部署到 Kubernetes,会遇到更多有趣的坑:
- 镜像怎么加载到 Minikube?
- Pod 一直 ImagePullBackOff 怎么办?
- 负载均衡为什么看不到效果?
敬请期待!
本文完整代码 :我的仓库
今天的分享到这里就结束啦!如果觉得文章对你有帮助,欢迎:
- ⭐ 给项目点个 Star
- 💬 评论区聊聊你踩过的坑
- 📤 分享给正在学云原生的朋友
你在容器化时遇到过什么问题?欢迎评论区讨论!
作者 :Mikkeyf
日期 :2025-10-27
系列:CloudNative Go Journey v0.1