从零开始的云原生之旅(一):把 Go 应用塞进 Docker

从零开始的云原生之旅(一):把 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 rundocker 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.1MB

3.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

# 看到返回就 OK

4.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 镜像必须用静态编译
否则运行时找不到 glibc

3. 先复制 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

下一篇:《从零开始的云原生之旅(二):第一次部署到 K8s》

相关推荐
不爱笑的良田7 小时前
从零开始的云原生之旅(四):K8s 工作负载完全指南
云原生·容器·kubernetes
数据知道9 小时前
Go语言设计模式:桥接模式详解
设计模式·golang·桥接模式
java_logo9 小时前
Docker 部署 Rocky Linux 全流程教程
linux·运维·服务器·docker·容器·1024程序员节
2501_938791839 小时前
服务器镜像安全:Docker 镜像漏洞扫描(Trivy)与基础镜像优化
服务器·安全·docker
Wenhao.10 小时前
LeetCode LRU缓存
算法·leetcode·缓存·golang
老司机张师傅10 小时前
【微服务实战之Docker容器】第十章-compose容器编排
docker·微服务·架构
ghie909010 小时前
利用 Docker 和 Kubernetes 实现微服务部署
docker·微服务·kubernetes
奔跑吧 android11 小时前
【Docker】【03.使用docker搭建ubuntu20.04 Qt5.12 开发环境】
qt·docker·ubuntu20.04·qt5.12
阿里云云原生11 小时前
为什么别人用 DevPod 秒启 DeepSeek-OCR,你还在装环境?
云原生