16.1 CI/CD 流水线
到第 15 章为止,TaskHub 已经能在 Kubernetes 里滚动更新了——但前提是「有人把镜像构建出来并推到仓库」。如果这一步靠人手工执行,那它迟早会被忘记、被跳过、或在凌晨三点用一台配置不对的笔记本跑出不可复现的结果。发布运维的第一件事,就是把「构建与验证」从人的记忆里搬进一条每次提交都自动跑的流水线。
本节把 TaskHub 推进到「提交即验证、合并即产出可部署镜像」:为仓库补上 lint、build、race 测试、漏洞扫描、镜像构建五个阶段,并在本机把每个阶段的命令真跑一遍。流水线平台本身不在本机,YAML 无法真跑,但每一个步骤对应的命令都是可验证的。
16.1.1 CI 与 CD 的职责边界
很多人把 CI/CD 当成一个词,其实它们是两段目标不同的流水线:
| 阶段 | 触发时机 | 目标 | 失败后果 |
|---|---|---|---|
| CI(持续集成) | 每次 push / PR | 证明这次改动「没把主干弄坏」 | 阻止合并 |
| CD(持续交付/部署) | 合入主干 / 打 tag | 产出一个可部署的制品并送进环境 | 阻止发布 |
关键区别在于失败语义:CI 失败只是拦住一个 PR,作者改完再推即可;CD 失败意味着一个已经合并的改动没能上线,需要回滚或补丁。把两者混在一条流水线里、又都不允许失败,结果是主干长期红着没人管。TaskHub 的做法是两条流水线、共享构建步骤:
ci.yml:跑在 PR 上,只验证,不推镜像。release.yml:跑在v*tag 上,验证通过后构建并推送带版本号的镜像。
16.1.2 五个阶段与「失败即停」
一条健康的 Go 流水线,顺序大致是:
lint -> build -> test(race) -> vuln -> image
顺序不是随意的。便宜且快失败的放前面:go vet 与 gofmt 几秒钟就能跑完,没必要等 3 分钟的测试跑完才发现格式没对齐。而 vuln 扫描放在测试之后、构建镜像之前——漏洞是「依赖问题」,与本次代码是否通过测试无关,但必须在产出制品前拦下。
每一阶段都必须是门禁(gate):命令返回非零就终止整条流水线。一个只打印警告、不阻断的检查,等于没有检查——这是 CI 里最常见的自欺。
16.1.3 在本机把每一步真跑一遍
本机没有 GitHub Actions / GitLab CI 运行环境,所以下面这段 YAML 不会真跑。但流水线里的每一条命令都可以在本地 shell 里执行,我逐条跑了一遍,把真实输出贴在下面。先建一个最小可运行的 TaskHub 骨架:
mkdir -p /tmp/gb16/cmd/taskhub /tmp/gb16/internal/task
cd /tmp/gb16
GOTOOLCHAIN=go1.27.0 go mod init taskhub
internal/task/task.go 放领域模型与校验:
package task
import (
"errors"
"strings"
)
type Task struct {
ID string
TenantID string
Title string
Done bool
}
var ErrEmptyTitle = errors.New("task: title is empty")
func (t Task) Validate() error {
if strings.TrimSpace(t.Title) == "" {
return ErrEmptyTitle
}
if t.TenantID == "" {
return errors.New("task: tenant id is required")
}
return nil
}
func (t Task) Complete() Task {
t.Done = true
return t
}
阶段一:格式与静态检查。 gofmt -l 列出所有未格式化的文件(有输出即失败),go vet 做编译期的可疑代码检查:
GOTOOLCHAIN=go1.27.0 gofmt -l .
GOTOOLCHAIN=go1.27.0 go vet ./...
本机实测两条命令都无输出,退出码为 0。go vet 能抓出的典型问题包括 Printf 动词不匹配、锁被复制、无用赋值等,它比编译器的检查更进一层,但几乎不产生误报,适合当门禁。
阶段二:构建。 确认整个模块能编译:
GOTOOLCHAIN=go1.27.0 go build ./...
无输出即成功。这一步在 CI 里通常还会加 -trimpath,去掉产物里本机绝对路径,让构建可复现。
阶段三:带竞态检测的测试。 普通 go test 抓不到并发 bug,-race 会插桩检测数据竞争,代价是更慢、内存占用更高——但它是 CI 该花的钱:
GOTOOLCHAIN=go1.27.0 go test -race -v ./...
本机真实输出(节选):
=== RUN TestValidate
=== RUN TestValidate/ok
=== RUN TestValidate/empty_title
=== RUN TestValidate/no_tenant
--- PASS: TestValidate (0.00s)
--- PASS: TestValidate/ok (0.00s)
--- PASS: TestValidate/empty_title (0.00s)
--- PASS: TestValidate/no_tenant (0.00s)
=== RUN TestComplete
--- PASS: TestComplete (0.00s)
PASS
ok taskhub/internal/task 1.878s
注意 -race 需要 CGO_ENABLED=1,在 Alpine 基础镜像里要装 gcc 与 musl-dev;如果 CI 的测试跑在 golang:1.27(Debian)镜像里则开箱可用。这是把测试与构建拆成不同 job 的一个现实理由。
16.1.4 供应链门禁:govulncheck 真抓到两个漏洞
govulncheck 与「扫 go.mod 里依赖的版本」的老式工具不同——它做可达性分析:只有当你的代码真的调用到了有漏洞的那个函数,才会报「affected」。这能大幅降低误报。它不在标准工具链里,需要单独安装:
GOTOOLCHAIN=go1.27.0 GOPROXY=https://goproxy.cn,direct \
go install golang.org/x/vuln/cmd/govulncheck@latest
本机装到的是 govulncheck@v1.8.0,漏洞库更新时间 2026-10-08。先在一个干净依赖的 TaskHub 上跑:
govulncheck ./...
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
为了验证这个门禁确实会拦下东西,我临时引入一个已知有漏洞的依赖 github.com/golang-jwt/jwt/v4@v4.5.0,并写一个会调用它的函数:
import "github.com/golang-jwt/jwt/v4"
func ParseToken(s string) (*jwt.Token, error) {
return jwt.Parse(s, func(t *jwt.Token) (any, error) { return []byte("k"), nil })
}
再跑一次,真实命中两个漏洞:
=== Symbol Results ===
Vulnerability #1: GO-2025-3553
Excessive memory allocation during header parsing in
github.com/golang-jwt/jwt
More info: https://pkg.go.dev/vuln/GO-2025-3553
Module: github.com/golang-jwt/jwt/v4
Found in: github.com/golang-jwt/jwt/v4@v4.5.0
Fixed in: github.com/golang-jwt/jwt/v4@v4.5.2
Vulnerability #2: GO-2024-3250
Improper error handling in ParseWithClaims and bad documentation may cause
dangerous situations in github.com/golang-jwt/jwt
More info: https://pkg.go.dev/vuln/GO-2024-3250
Fixed in: github.com/golang-jwt/jwt/v4@v4.5.1
Your code is affected by 2 vulnerabilities from 1 module.
这里最关键的细节是退出码:命中漏洞时 govulncheck 返回 3(本机实测 exit=3),干净时返回 0。也就是说它天然适合当门禁——CI 不需要解析输出,看退出码即可。去掉那个临时依赖后重跑,又回到 No vulnerabilities found.、退出码 0。
一个常见的误判:govulncheck 还会顺带报告「在你 import 的包里、但你的代码没调用」的漏洞。这类不计入 affected,不应当阻断流水线,否则你会被一堆用不到的历史漏洞淹掉。本机这次扫描就报告了「1 vulnerability in packages you import and 12 vulnerabilities in modules you require」,但 affected 为 0。
16.1.5 镜像构建也是门禁
第 14 章已经讲过 Dockerfile 的写法,这里只强调它在流水线里的角色:镜像构建成功本身就是一道验证——它能抓出「本地能跑、容器里缺文件」这类问题。本机的 Dockerfile:
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/taskhub ./cmd/taskhub
FROM alpine:3.21
RUN adduser -D -u 10001 app
COPY --from=build /out/taskhub /usr/local/bin/taskhub
USER 10001
ENTRYPOINT ["/usr/local/bin/taskhub"]
本机真实构建与运行:
docker build -t taskhub:ci .
docker run --rm taskhub:ci
Successfully tagged taskhub:ci
task 1 ready: {ID:1 TenantID:t1 Title:部署 TaskHub Done:false}
镜像大小实测 15.1MB。docker run 退出后容器自动删除(--rm),没有残留。
16.1.6 GitHub Actions 流水线(未在真实 CI 上执行)
以下 YAML 未在真实 CI 上执行(本机无 Actions 运行环境),仅作为设计参考;其中的每一条 run 命令都已在上文于本机实跑。它把上面五个阶段编成一条 ci job,并把 setup-go 的缓存与 docker/build-push-action 的层缓存接上:
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.27'
cache: true
- name: gofmt
run: test -z "$(gofmt -l .)"
- name: vet
run: go vet ./...
- name: build
run: go build -trimpath ./...
- name: test
run: go test -race -covermode=atomic -coverprofile=cover.out ./...
- name: vuln
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
- name: image
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: taskhub:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
几处值得解释的取舍:
gofmt用test -z "$(gofmt -l .)":gofmt -l列出未格式化的文件,输出非空时test -z返回非零,天然成为门禁。go-version: '1.27':与go.mod的go 1.27.0对齐;CI 的 Go 版本低于 go.mod 声明时,GOTOOLCHAIN=auto会去下载,白白浪费几分钟。cache: true:缓存 Go module 与 build cache,是 Go CI 提速最大的一项。push: false:PR 上只构建不推送;推送留给release.yml,避免每个 PR 都在镜像仓库留下垃圾 tag。
16.1.7 制品与版本
镜像 tag 策略直接决定「能不能回滚到某个确定的版本」:
| tag 形式 | 用途 | 可回滚性 |
|---|---|---|
taskhub:<git-sha> | 不可变,唯一的真源 | 最好,永不覆盖 |
taskhub:v1.4.0 | 语义化版本,人读友好 | 好,但需保证不重打 |
taskhub:latest | 仅作提示 | 差,随时被覆盖 |
生产环境的 Deployment 里只能引用不可变 tag(sha 或语义化版本),绝不能用 latest。latest 的问题不是「不好看」,而是「同一个名字指向不同镜像」,回滚时你根本不知道要回滚到哪一份。第 16.2 节讲回滚时会依赖这一点。
16.1.8 流水线自检清单
-
gofmt -l .无输出,go vet ./...无输出 -
go build ./...通过,产物带-trimpath -
go test -race ./...通过(不是普通go test) -
govulncheck ./...退出码为 0(命中即失败) -
docker build成功,镜像以非 root 运行 - 镜像 tag 用 git sha,生产不使用
latest - 每个阶段都是门禁,失败即停
小结
- CI 验证「没弄坏主干」并拦合并,CD 产出「可部署制品」并送环境;两者失败语义不同,应拆成两条流水线共享构建步骤。
- 阶段顺序遵循「便宜且快失败的放前面」:lint → build → test(race) → vuln → image。
go vet、go test -race、govulncheck、docker build的命令本机均实跑通过;-race需要 CGO,注意基础镜像。govulncheck做可达性分析,命中漏洞时退出码为3(本机实测),天然适合当门禁;「import 了但没调用」的漏洞不计入 affected。- 本节的 GitHub Actions YAML 未在真实 CI 上执行(本机无 Actions 环境),但其中每条命令均已在本机验证。
- 镜像 tag 用不可变 git sha,生产禁用
latest,这是后续回滚的前提。
下一节我们让这条流水线产出的镜像只放一部分流量:先 10% 灰度、观测健康指标,再决定放量还是回滚。16.2 灰度/蓝绿与回滚 会用一段可运行的反向代理把「按权重分流」跑出真实数字。
阅读导航:上一节:15.3 优雅停机与滚动更新 · 下一节:16.2 灰度/蓝绿与回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。