知序
返回
保存草稿
发布
H1
H2
H3
1
2
3
Excalidraw 图
AI 写作
AI 优化
目录
输入内容后显示目录
Markdown
# 04 - CI/CD:从提交代码到镜像进仓库 > 到上一篇为止,镜像仍然是"你笔记本上手工敲出来的":本地 `docker build`、本地 `docker push`,然后靠记忆走一遍步骤。 > 本篇把它变成"提交代码后自动产生",并让每次产出的镜像都能被**唯一定位、可回滚** —— 出问题时,能说出线上跑的是哪个提交构建的。 > 关键认知:流水线里跑的命令,和你本机手敲的**完全一样**,区别只是环境固定、步骤固化、结果可追溯。 --- ## 1. 先看手工发布有多脆 一开始,几乎所有人都是这么发的:  *图:手工发布的六步流程与四类痛点:靠人记住、环境不固定、tag 随手打、说不清版本* ```text 手工发布流程(每一步都靠人记得住) ┌────────────────────────────────────────────────┐ │ ① 本地改完代码 │ │ ↓ │ │ ② 本地 mvn clean package │ │ ↓ │ │ ③ 本地 docker build -t order-service:latest . │ │ ↓ │ │ ④ docker push order-service:latest │ │ ↓ │ │ ⑤ scp / ssh 把产物搬上服务器 │ │ ↓ │ │ ⑥ 改服务器上的配置(端口、数据库地址) │ │ ↓ │ │ ⑦ systemctl restart 重启 │ └────────────────────────────────────────────────┘ ``` 它看上去"能跑",但四个痛点会在最要命的时候同时冒出来: | 痛点 | 具体表现 | 后果 | |---|---|---| | 靠人记得住步骤 | 第 ⑤ 步忘了同步配置、第 ⑦ 步忘了改端口 | 出来的东西和预期不符,且没人说得清是谁漏了哪步 | | 构建环境污染 | 本地是 JDK 21、服务器是 JDK 17;本地 `~/.m2` 有旧缓存 | 本地能跑、线上编译不过;依赖版本悄悄漂移 | | tag 随手打 | 每次都是 `latest`,或 `test`、`test2`、`final` | 线上跑的到底是哪个版本,谁也说不清 | | 说不清是哪个提交 | commit 与镜像之间没有对应关系 | 出了 bug 无法定位,也无法干净回滚 | 本地构建还有个隐性坑:**机器换了,构建结果可能不一样**。同一份源码,在你的 Mac 和同事的 Windows 上,因为 JDK 小版本、时区、依赖下载顺序不同,产出的 jar 可能就是两样。你甚至无法证明线上那个镜像,到底是用哪份代码构建的。 > 一句话:**手工发布的本质问题不是"慢",而是"不可复现、不可追溯"。** 修这两点,就是 CI/CD 的全部意义。 --- ## 2. CI/CD 到底在做什么 流水线的主干就几个环节。它把你手工敲的命令,搬到一台固定机器上、按固定顺序执行:  *图:CI/CD 流水线主干:从 git push 到通知 / 部署的七个环节* ```text ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │git push│─>│ 触发 │─>│拉取代码 │─>│编译测试 │─>│构建镜像 │─>│推送仓库 │─>│通知/部署│ │ 提交 │ │webhook │ │checkout│ │mvn test│ │ docker │ │ push │ │ (可选) │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ ``` 这六个环节**没有一个是你本机没做过的**。CI 做的不是"新事情",而是把"你的机器 + 你的记忆"换成"固定机器 + 固定脚本",差别只有三点:环境固定(JDK/Maven 版本钉死)、步骤固化(顺序写在 YAML 里,谁都不能跳步)、结果可追溯(日志、产物、对应 commit 都被记录)。 CI 与 CD 常被混着说,但不是一回事: | 维度 | CI(持续集成) | CD - 持续交付(Continuous Delivery) | CD - 持续部署(Continuous Deployment) | |---|---|---|---| | 目标 | 频繁把代码合进主干并自动验证 | 保证任意通过 CI 的版本都可随时发布 | 通过后自动发布到生产,无人干预 | | 人工环节 | 无 | 最后"点一下按钮"由人决定 | 无 | | 产物 | 测试报告、构建产物 | 可发布的镜像 + 部署清单 | 直接生效的线上变更 | | 风险 | 低 | 低(有闸门兜底) | 高,需完善测试与回滚兜底 | 大部分团队停在"持续交付":流水线一路跑到镜像进仓库,**最后一步上线由人点一下**。这已能解决本文开头那四个痛点。 > 一句话:**CI 是"每次提交都验证",CD 是"验证通过即随时可发"。** 两者共享同一条流水线,只是终点不同。 --- ## 3. 用 GitHub Actions 写第一条流水线 一个能跑的流水线就是一个 YAML 文件。先看全貌,再逐段解释: ```yaml # .github/workflows/build.yml name: build-and-push on: push: branches: [main] # 只在推送到 main 时触发 env: IMAGE: registry.example.com/prod/order-service jobs: build: runs-on: ubuntu-latest steps: - name: 拉取代码 uses: actions/checkout@v4 - name: 装 JDK 17 并开启 Maven 缓存 uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" cache: maven # 缓存 ~/.m2,下次不再重下依赖 - name: 跑单测(质量闸门) run: mvn -B test - name: 打包(跳过测试,上一步已跑过) run: mvn -B clean package -DskipTests - name: 计算短哈希 tag id: meta run: echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - name: 登录镜像仓库 uses: docker/login-action@v3 with: registry: registry.example.com username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_PASSWORD }} - name: 构建并推送镜像 uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ env.IMAGE }}:sha-${{ steps.meta.outputs.short_sha }} ``` 逐段说明这些字段**为什么**这么写: | 片段 | 为什么这么写 | |---|---| | `on.push.branches: [main]` | 只对主干触发。每个分支都跑会浪费额度,也让"哪个镜像能上线"变含糊 | | `actions/checkout@v4` | 流水线从零开始,先得把代码拉下来 | | `setup-java@v4` + `temurin` + `17` | 把 JDK 版本钉死,"环境固定"的落地,杜绝"本地能跑线上不能跑" | | `cache: maven` | 缓存 `~/.m2`,否则每次都要把依赖从公网重下一遍 | | `mvn -B test` | `-B` 是批处理模式;这一步是**质量闸门**,测试不过就别想进仓库 | | `mvn ... -DskipTests` | 打包跳过测试(上一步已跑过)。注意是 `-DskipTests`(只跳过执行),不是 `-Dmaven.test.skip` | | `docker/login-action@v3` | 私有仓库必须先登录;凭据从 Secrets 读,绝不写在 YAML 里 | | `build-push-action@v5` | 构建并推送一步到位,`push: true` 才真的推 | | `tags: ...sha-${{ ... }}` | tag 用短哈希,每次提交唯一 —— 这是"可回滚"的前提 | 关于 `-DskipTests` 的争议说清楚:**测试不是不跑,而是换个地方跑**。流水线刻意把"验证"与"打包"拆两步,因为它们目标不同 —— 测试要的是"尽早失败、失败醒目",打包要的是"快点出产物"。混在一起,测试一挂连产物都拿不到,反而更难排查。 > 一句话:**流水线的价值不在"自动",而在"每次都用同一套命令、同一个环境,产出可追溯的产物"。** --- ## 4. GitLab CI 版本 GitLab 用一份 `.gitlab-ci.yml` 表达同样的主干,逻辑与 Actions 一一对应: ```yaml # .gitlab-ci.yml stages: - test - build variables: IMAGE: registry.example.com/prod/order-service test: stage: test image: maven:3.9-eclipse-temurin-17 script: - mvn -B test rules: - if: $CI_COMMIT_BRANCH == "main" build: stage: build image: docker:27 services: - docker:27-dind # docker-in-docker:job 里能跑 docker script: - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin registry.example.com - docker build -t "$IMAGE:sha-$CI_COMMIT_SHORT_SHA" . - docker push "$IMAGE:sha-$CI_COMMIT_SHORT_SHA" rules: - if: $CI_COMMIT_BRANCH == "main" ``` 两个概念对照着看,换工具时心里就有数: | 概念 | GitHub Actions | GitLab CI | |---|---|---| | 整套配置 | `.github/workflows/*.yml` | `.gitlab-ci.yml` | | 执行单元 | job(`jobs:` 下) | job(顶层键) | | 阶段划分 | `needs:` 声明依赖 | `stages:` 显式声明顺序 | | 触发条件 | `on:` | `rules:` / `only:` | | 执行机 | runner(`runs-on:`) | runner(`tags:` 选) | | 密钥管理 | Repository Secrets | CI/CD Variables(masked / protected) | | 内置变量 | `$GITHUB_SHA`、`$GITHUB_REF_NAME` | `$CI_COMMIT_SHA`、`$CI_COMMIT_SHORT_SHA` | | 复用现成能力 | `uses: owner/action@v4` | `include:`(组件、模板) | `docker:dind` 跑的是特权容器,很多共享 runner 默认不给用。被禁用时用 **kaniko** 更稳:它不需要 Docker daemon,直接以当前用户态把镜像构建出来并推送。 > 一句话:**不同 CI 工具只是"方言"不同,说的事情完全一样:拉代码、跑测试、构建镜像、推仓库。** 学会一条,换工具只是查语法。 --- ## 5. 自动打 tag 的策略(本篇重点) 流水线跑通后,最容易被糊弄、又最要命的就是 tag。四种常见生成方式: | 生成方式 | 例子 | 唯一性 | 可读性 | 典型用途 | |---|---|---|---|---| | commit 短哈希 | `sha-9f3a1c2` | 每次提交唯一 | 只能定位提交 | 回滚定位的锚点,最稳 | | 分支名 + 短哈希 | `main-sha-9f3a1c2` | 同提交唯一 | 能看出分支 | 多分支 / 多环境并行构建 | | 语义化版本 | `1.4.2` | 同版本唯一(依赖 `git tag`) | 最能读懂 | 正式对外发布 | | 时间戳 | `20261015-1042` | 秒级唯一 | 能看出构建时间 | 临时快照、内部测试 | 选择原则只有一条:**生产环境引用的一定是不可变 tag**。`sha-<短哈希>` 天然不可变;语义化版本只要不重复发布也不可变;而 `latest` 是**可变**的 —— 它随时指向新镜像,让"线上跑的是哪个版本"重新变成谜题。 生产推荐"**一次构建、双 tag 推送**":同一次构建的镜像挂两个 tag,各司其职: ```yaml tags: | ${{ env.IMAGE }}:sha-${{ steps.meta.outputs.short_sha }} # 回滚锚点 ${{ env.IMAGE }}:1.4.2 # 给人看的 ``` 两者指向**同一个 image digest**(镜像内容的哈希),不存在"两个 tag 内容不一致"。版本号则来自 git tag: ```bash # 有 tag 就用 tag(v1.4.2 → 1.4.2),没有就用短哈希兜底 VERSION=$(git describe --tags --always --dirty | sed 's/^v//') # 要求"必须是干净发版"时,打不到精确 tag 就让流水线失败,拒绝发布: git describe --tags --exact-match 2>/dev/null \ || { echo "当前提交没有打 tag,拒绝发布"; exit 1; } ``` **为什么不往生产推 `latest`?** 一是它可变,今天的 `latest` 和明天不是同一个;二是回滚时无法表达"回到上一个版本",因为上一个 `latest` 已经不存在了;三是排查时 `kubectl describe` 看到的 image 写着 `:latest`,等于什么都没看到。 > 一句话:**回滚靠的是不可变 tag。** 只要还有 `latest` 在生产里跑,所谓"可回滚"就是一句空话。 --- ## 6. 凭据管理 流水线一定会碰到密码:登录仓库、拉私有依赖、连数据库。放哪里是一道必须一次做对的选择题: | 存放位置 | 该放吗 | 说明 | |---|---|---| | GitHub Secrets / GitLab CI Variables(masked + protected) | 可以 | 加密存储,日志默认打码,当前最常规 | | 云厂商 OIDC 临时凭证 | 最推荐 | 用工作负载身份换短期凭证,仓库里没有长期密钥 | | Dockerfile 里(`ARG` / `RUN` 写死) | 绝对不要 | 留在镜像分层里,`docker history` 一览无余 | | 镜像的 `ENV` | 绝对不要 | `docker inspect <image>` 就是明文明可读 | | 仓库里的 `application.yml` | 绝对不要 | 进了 git 历史就删不干净,fork 一份泄露一份 | **三条红线**,写下来贴墙上: ```text 红线一:凭据不要写进 Dockerfile —— 分层里擦不掉,docker history 就能看到 红线二:凭据不要写进镜像的 ENV —— docker inspect 直接明文 红线三:凭据不要写进仓库里的 application.yml —— 进了 git 历史,删不干净 ``` 凭据只通过 `secrets` 上下文引用,永远不落在 YAML 文本里: ```yaml - name: 登录镜像仓库 uses: docker/login-action@v3 with: registry: registry.example.com username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_PASSWORD }} # 值存在仓库设置里,不在这里 ``` 日志防泄露的几条经验:GitHub 会把 Secrets 的值在日志里自动换成 `***`,但用 base64 或拼接后再打印就打不了码;GitLab 把变量标成 `Masked` 同样会打码,再加 `Protected` 限定只有受保护分支能读;`set -x`、`env`、`docker inspect` 这类会哗啦啦打出环境变量的操作要慎用;长期密钥要定期轮换,而 OIDC 最优是因为它根本不留长期密钥。 > 一句话:**凭据的正确位置只有一个 —— 密钥管理系统。** 其它任何地方出现它,都是事故的开始。 --- ## 7. 流水线最后一步:部署怎么触发 镜像进仓库后,怎么让 K8s 用上新版本?主流两条路:  *图:部署触发方式对比:CI 直接改集群 vs GitOps 由 Argo CD 同步* ```text 方式一:CI 里直接改 K8s(命令式) ┌──────────┐ build & push ┌──────────┐ kubectl set image ┌────────────┐ │ CI │───────────────>│ 镜像仓库 │────────────────────>│ K8s 集群 │ └──────────┘ └──────────┘ (需要 kubeconfig) └────────────┘ 特点:简单直接;但 CI 持集群凭证,权限大、无审计、不留痕 方式二:GitOps(声明式) ┌──────────┐ build & push ┌──────────┐ │ CI │───────────────>│ 镜像仓库 │ └──────────┘ └──────────┘ │ 只推镜像,不碰集群 v ┌──────────────────┐ 监听&同步 ┌────────────┐ │ 部署清单 Git 仓库 │──────────────>│ K8s 集群 │ │ (YAML 里改镜像 tag)│ Argo CD │ (集群内拉) │ └──────────────────┘ └────────────┘ 特点:CI 无集群凭证;变更 = 一次 commit,有审计、可回滚 ``` | 维度 | CI 里直接 `kubectl set image` | GitOps(Argo CD / Flux) | |---|---|---| | 复杂度 | 低,几行命令 | 需额外部署控制器 | | CI 权限 | 需 kubeconfig,往往权限很大 | CI 只推镜像,不需要集群凭证 | | 审计 | 无(命令执行完就没了) | 每次变更都是一次 commit,天然留痕 | | 回滚 | `kubectl rollout undo` | 回退那个 commit | | 适合 | 起步阶段、单环境、小团队 | 多环境、多团队、需合规审计 | 先走简单路线的话,命令就这三条: ```bash # 1. 换掉 Deployment 里容器用的镜像(触发滚动更新) kubectl set image deployment/order-service \ order-service=registry.example.com/prod/order-service:sha-9f3a1c2 # 2. 盯住滚动更新,卡住能立刻发现 kubectl rollout status deployment/order-service # 3. 出问题一键回退到上一个 ReplicaSet kubectl rollout undo deployment/order-service ``` 这里特意用 `sha-9f3a1c2` 而不是 `1.0.1`:命令式部署下,引用不可变 tag 能确保"这次部署的就是这次构建的产物",不会因版本号被覆盖而部署到意外内容。这与「云原生」课程 04 篇"用明确 tag 部署、用 `rollout` 管理版本"的结论一致。 > 一句话:**起步用命令式最快,规模上来后上 GitOps。** 但无论哪条路,部署的都是**镜像 tag**,不是"某次构建"。 --- ## 8. 流水线优化:别让构建等十分钟 第一条流水线跑通后,速度就是下一个问题。一个 Spring Boot 项目什么都不优化,Maven 每次重下依赖约 3 分钟、Docker 每次全量重建约 5 分钟,一次提交等 8 分钟起步。四个优化点: | 优化项 | 做法 | 效果 | |---|---|---| | Maven 依赖缓存 | `setup-java` 的 `cache: maven`,或 `actions/cache` 缓存 `~/.m2` | 依赖下载从分钟级降到秒级 | | Docker 层缓存 | `build-push-action` 里开 `cache-from` / `cache-to: type=gha` | 只重建变更的层,跳过大半构建 | | 并行 job | 测试、静态检查、镜像构建拆成互不依赖的 job | 总时长按最慢的 job 走 | | 只构建改动模块 | 多模块用 `mvn -pl <module> -am`,或按路径过滤触发 | 改一个模块不用全量打包 | 优化前后的对比: | 阶段 | 优化前 | 优化后 | |---|---|---| | 拉代码 | ~5s | ~5s | | Maven 依赖下载 | ~3min(每次重下) | ~10s(缓存命中) | | 编译打包 | ~40s | ~40s | | Docker 构建 | ~5min(全量重建) | ~40s(层缓存命中) | | 推送镜像 | ~30s | ~30s | | **合计** | **约 8~9 分钟** | **约 2 分钟** | Docker 层缓存命中的前提是**前面几层的输入没变**。这就是下一篇 Dockerfile 会"先 COPY `pom.xml` 预热依赖、再 COPY 源码"的原因 —— 只要依赖没变,改业务代码就不会让依赖层失效。 > 一句话:**流水线优化的本质是"让不变的东西只算一次"。** 依赖、基础层、未改动的模块,都不该被重复计算。 --- ## 9. 踩过的坑 ```text 现象:本地能 build,CI 里 mvn 报「不支持发行版本 17」 原因:CI 用的 JDK 版本不是 17 对策:显式 setup-java 指定 temurin 17;GitLab 用 maven:3.9-eclipse-temurin-17 现象:docker push 报「denied: requested access to the resource is denied」 原因:没登录,或 tag 前缀(仓库命名空间)不对 对策:先 docker login;tag 必须是 完整地址/命名空间/镜像名 现象:CI 日志里密码是明文 原因:把 secret 当普通变量 echo,或 set -x 打了出来 对策:用 masked variable;不要 echo 凭据;关掉 set -x 现象:每次提交都推 latest,线上说不清跑的是哪个版本 原因:tag 用了 latest 对策:推 sha-<短哈希> 并留版本 tag,生产只引用不可变 tag 现象:一次提交要等 8 分钟 原因:没缓存 Maven 依赖和 Docker 层 对策:开 Maven 缓存 + buildx 的 gha 缓存 ``` --- ## 10. 本篇小结  *图:本篇小结:从 git push 到通知 / 部署的完整流水线,环境固定、步骤固化、凭据走 Secrets* ```text git push │ 触发(webhook) v 拉取代码(checkout) │ v setup-java + 缓存 —— 环境固定 │ v mvn test → mvn package —— 步骤固化,测试是闸门 │ v docker login —— 凭据来自 Secrets,不落 YAML │ v docker build + push —— tag = sha-<短哈希> + 版本号(双 tag) │ v 镜像进仓库 —— 可唯一定位、可回滚 下一环:CI 交付的是"一个不可变 tag 的镜像 + 一条部署命令" ``` 下一篇:[05 - 实战:源码 → 镜像 → K8s 上线]
预览
发布文章
添加摘要让读者更快了解文章内容
取消
确认发布
AI 写作助手
输入一个主题,AI 将为你生成一篇 Markdown 博客文章
AI 正在写作中...
取消
开始生成
AI 优化助手
描述你的修改需求,AI 将在原文基础上进行补充或微调
AI 正在优化中...
取消
开始优化
画布
从已有图里选
✕
图名
保存后生成 /images/excalidraw/<图名>.svg(只能用字母、数字、短横线、下划线、点)
取消
保存并插入