Kubernetes CI/CD:GitHub Actions 构建并发布镜像

用 GitHub Actions 搭发布流水线:OIDC 短期凭据、不可变镜像、环境审批与 rollout 验证。

技术文章约 19 分钟270 次阅读

#这篇文章解决什么问题

这篇文章解决一个很具体的问题:如何把一次源码提交,安全、可追溯地变成 Kubernetes 集群里一次可验证的发布。很多人写完第一版流水线时能"发布",但发布失败后要靠人工看半天日志才能发现;真正的问题不在"构建镜像"这一步,而在三处:部署凭据怎么给、发布后怎么确认、环境之间怎么隔离。

要回答这些问题,得先看清它背后最基础的事实:发布不是"把文件拷到服务器上",而是沿一条因果链逐级传递证据。这条链是:源码提交产生事件 → 事件在隔离的 runner 上执行 job/step → 构建产物被固化成不可变的容器镜像并推送到 registry → 部署凭据把"更新 Deployment"的请求送进集群 API Server → 控制器把期望状态调和成现实。任何一个环节只是"看起来成功",整条链就不可信。下面按这条因果链,从底层机制推到完整可复现的操作,再给出验证方法和常见坑。

#原理层:一条发布流水线背后的底层机制

#事件如何触发 workflow:一切从"提交"开始

GitHub Actions 的本质是一个事件总线加执行器。仓库里发生的每一件大事小情——push、pull request、issue 创建、定时器到点、手动点击——都会生成一条事件(Event)。.github/workflows/*.yml 里的 on: 字段就是事件过滤器:只有匹配的事件才会创建一次 workflow run。

name: release
on:
  push:
    branches: [main]
  workflow_dispatch:

这段配置的含义是:只有 main 分支的 push、或者有人手动触发 workflow_dispatch 时,这个 workflow 才会跑。这个"过滤"为什么重要?因为事件是流水线的唯一输入。github.sha、github.ref、github.event_name 这些上下文变量全部来自触发事件,后续每一步用的"这一版代码是谁"都从事件里读。事件正确,整条链的起点才正确;事件被错误触发(比如把 branches 写漏,任何分支 push 都触发),流水线就会在没有预期的地方开始发布。

一次事件对应一个 workflow run,run 有全局唯一的 run ID。镜像 tag、部署记录、日志、审批记录全都挂在这个 ID 上——这就是"可追溯"的第一个锚点:任何时候都能从线上运行的镜像反查到是哪一次 run、哪一次提交构建的。

#隔离的 runner:job 与 step 的执行模型

事件被接收后,GitHub 会为这个 run 分配执行环境,叫 runner。runs-on: ubuntu-latest 表示使用 GitHub 托管的临时虚拟机:每次运行都是全新环境,任务结束即销毁。

这个"临时"的事实决定了 CI/CD 的很多设计:

  • runner 上没有持久状态。上一次运行装好的依赖、构建出来的二进制,这一次统统没有。所以每次运行都从 checkout(拉源码)开始。
  • 构建产物不能留在 runner 上——run 结束就没了。要让产物跨过"这一次运行"存活,必须把它固化成 registry 里的不可变对象(镜像、包),这正是"推送镜像"这一步存在的原因。
  • 同一个 workflow 里的不同 job 默认跑在不同的 runner上,互相看不到对方的文件系统。job 之间要传东西,要么通过 needs 声明依赖(其实只是保证顺序),要么通过 artifact/镜像等外部存储传递产物。

再看 job 内部的结构:一个 job 是一组顺序执行的 step。前一个 step 的退出码决定后一个 step 是否执行(默认失败即停)。这就是"构建失败不会触发部署"的机制来源——它们是同一个 job 里的先后 step,或者不同 job 间的 needs 依赖,前一步非零退出,后一步根本不会开始。

每个 step 里可以通过 ${{ github.* }}、${{ secrets.* }}、${{ vars.* }} 引用上下文:事件数据、加密的密钥、仓库变量。这些值在 runner 上以环境变量或内联替换的形式注入。secrets 是加密存储、运行时解密,日志里会被打码——这是为什么凭据能安全地出现在流水线里,而不是写死在仓库文件里。

#镜像:构建产物如何跨环境传递

容器镜像不是"一个打包的文件夹",它是一组只读分层文件系统 + manifest(清单)+ 配置的组合。docker build 把 Dockerfile 的每一层指令变成一层文件系统增量,最终产出一个不可变对象,用 tag(标签)来引用:

docker build -t ghcr.io/acme/demo-api:v1.0.0 .
docker push ghcr.io/acme/demo-api:v1.0.0

这里有两个概念必须分清:

  • 镜像内容(layers + manifest)是不可变的。同一 digest(sha256:...)永远指向同一份内容,改不了。
  • tag 是可变的引用。latest 可以今天指向 v1、明天指向 v2;v1.0.0 原则上也不该被覆盖,但谁都能 push 同名 tag。

所以"不可变镜像"的真正含义是:用一个不变的、与源码一一对应的引用方式。项目里最可靠的做法是镜像 tag 直接用 commit SHA:

ghcr.io/acme/demo-api:9f26f0a1c2d3...

${{ github.sha }} 就是这次触发事件的 commit 完整 SHA。这样 tag 与源码一一对应:看到 pod 里跑的镜像 tag,就能直接定位到那次提交;同一个 commit 重新构建,如果内容一致,digest 一致,tag 也一致,不会出现"同名 tag 内容漂移"。latest 是可变 tag,出问题时你根本不知道线上跑的是哪一版——这就是"别用 latest"的底层原因。更严格的方案是直接记录 digest(ghcr.io/acme/demo-api@sha256:...),但 SHA tag 对日常已经足够可追溯。

#registry 认证:谁允许把镜像写进仓库

推送镜像之前必须先"登录 registry"。登录的底层机制是:docker login <registry> 会把凭据写进 runner 上的 ~/.docker/config.json(auths 字段),后续 docker push 会自动携带这段凭据,向 registry 证明"我有权限往这个仓库写"。

对 GitHub Container Registry(GHCR,域名 ghcr.io)来说,有两类凭据可用:

  • 内置的 GITHUB_TOKEN:GitHub 自动为每次 run 生成的临时令牌,权限由 permissions 块声明。这是首选,因为它天然短期、自动轮换、无需人工保管。往 GHCR 推镜像需要 packages: write。
  • 个人访问令牌(PAT,典型占位名 GHCR_TOKEN):当需要跨仓库推送、或指定更细粒度权限时才用,存在仓库的 Secret 里。长期 PAT 泄露面大,能不用就不用。

对应地,workflow 顶部需要声明权限:

permissions:
  id-token: write          # 允许换取 OIDC 短期令牌
  contents: read
  packages: write          # 允许往 GHCR 推送镜像

注意:不声明 packages: write 时,docker push ghcr.io/... 会以 403 失败。这是"构建成功但推送失败"最常见的首因。

#kubeconfig 与集群凭据:部署请求如何到达 API Server

镜像推上 registry 之后,剩下的问题是:怎么让集群把新镜像跑起来。这一步的请求是 kubectl 发出的,底层机制值得说清楚:

  1. kubectl 读取 kubeconfig(默认 ~/.kube/config)找到三样东西:clusters(API Server 地址)、users(客户端凭据)、contexts(前两者的组合)。
  2. kubectl 用凭据向 API Server 发起认证(Token、证书,或 OIDC 换来的短期令牌)。
  3. API Server 校验通过后,把"期望状态"(比如 Deployment 的 image 字段)写入 etcd。
  4. 集群里的 controller(控制平面组件,如 Deployment controller)持续执行调和循环:比较期望状态与当前状态,有差异就采取动作——创建新的 ReplicaSet、滚动更新 Pod、拉取新镜像。

所以"部署"的本质是修改集群里存储的期望状态,而不是直接操作 Pod。这也解释了为什么部署命令看起来"只是改了一行配置"——真正的生效由控制器异步完成,这就是为什么部署之后必须显式等待并验证(后面验证层细说)。

凭据怎么给,是两条路线:

  • 长期 kubeconfig / Token(最不安全的做法):把 KUBE_CONFIG、长期 Token 直接写进 GitHub Secrets 甚至仓库文件。一旦泄露,等于把集群管理权限拱手送人,而且很难知道泄露发生在什么时候。不要这样做。
  • OIDC 短期令牌(推荐做法):GitHub Actions 通过 OIDC 协议向云厂商/集群声明"我是某仓库某分支的这次 run",换取几分钟内有效的临时凭据,用完即失效,没有长期泄露面。GitHub 侧只需要在 workflow 里给 id-token: write 权限,并且在仓库 Settings → Actions 里开启 OIDC 支持;云厂商/集群侧配置一条 federation 信任规则(把"GitHub 的 issuer + 仓库 subject"映射到某个服务身份)。

federation 配置好之后,流水线里不需要任何明文密钥,凭据通过 azure/k8s-set-context 之类的 action 注入到 runner 的 kubeconfig 位置。这一步把"凭据泄露"这个 CI/CD 最大的风险直接消掉,值得花半天时间配好。

#为什么要把"构建镜像"与"更新 Deployment"分成两步

新手最容易犯的错是把整个发布写成一个"大 push"脚本:构建完直接把文件塞进集群。从底层机制看,这两件事属于不同的抽象层次,必须拆开:

  • 构建镜像是"生产不可变产物",产物内容只由源码决定,与环境无关。构建一次,可以推到任何环境(dev / staging / prod)。
  • 更新 Deployment是"改变集群中的运行声明",它不产生新内容,只是把不可变产物的引用(tag / digest)写进 Deployment 的 Pod 模板。同一个镜像,可以被 dev 引用、也可以被 prod 引用;引用变了,控制器才去滚动更新。

拆开之后得到四个直接收益:

  1. 可追溯:pod 里跑的镜像 tag = commit SHA,任何时刻都能说清线上是哪次提交。
  2. 失败域隔离:构建失败不会进入部署;部署失败回滚时只改引用,不用重新构建(kubectl rollout undo 直接把 Pod 模板指回上一个 ReplicaSet 的镜像)。
  3. 可审批:在"构建完成、部署之前"插入环境审批,代码合并可以自动发布测试环境,生产发布必须人点确认。
  4. 可复用:一份镜像可以喂给多条部署流水线(不同环境、不同集群),部署逻辑与构建逻辑解耦。

至于"更新 Deployment"用哪种命令,两种主流方式底层也不同:

  • kubectl set image deployment/demo-api api=ghcr.io/acme/demo-api:<tag>:只改 Pod 模板里的 image 字段,触发滚动更新。改动最小、最直接,适合"只换镜像版本"的发布。
  • kubectl apply -f manifest.yaml:把整个期望状态声明提交给 API Server,kubectl 用三路合并算出 diff。适合配置文件里同时改了副本数、资源限制、标签等多项内容。

两个命令背后是同一个控制器机制:Deployment 会以"先起新的 ReplicaSet、逐步扩容新的缩容旧的(受 maxUnavailable / maxSurge 控制)、探针失败则中断"的方式滚动更新。所以不管用哪个命令,最终都要验证 rollout 是否完成。

#操作层:一条完整可复现的发布流水线

#前置条件

这条流水线按下面环境编写,运行前请确认:

  • GitHub 仓库:以 acme/demo-api 为例(仓库和镜像名请换成自己的),源码含 Dockerfile,应用以 Go 编写(go test ./... 可跑通;其他语言替换成对应测试命令)。
  • Actions 版本:actions/checkout@v4、docker/login-action@v3、docker/build-push-action@v6、azure/login@v2、azure/k8s-set-context@v4,这些是 2026 年当前可用的稳定版本;升级大版本时以各自官方 README 为准。
  • 镜像仓库:GitHub Container Registry(ghcr.io),目标包名 ghcr.io/acme/demo-api。
  • 集群:以 Azure AKS 为例走 OIDC 路线;自建集群或其它云厂商可改用 kubeconfig 兜底路线(见下文)。kubectl 以 1.30+ 版本为例,下列命令在该版本区间均可用。
  • 仓库 Secrets(占位名,自己填值):
    • OIDC 路线:AZURE_CLIENT_ID、AZURE_TENANT_ID、AZURE_SUBSCRIPTION_ID;
    • 兜底路线:KUBE_CONFIG(kubeconfig 文件 base64 编码后的内容);
    • 可选:GHCR_TOKEN(只有使用 PAT 登录 GHCR 时才需要,优先用内置 GITHUB_TOKEN)。
  • 仓库 Variables(占位名):CLUSTER_NAME、RESOURCE_GROUP、NAMESPACE。

OIDC federation 的前置配置(以 Azure AKS 为例,一次性配置):

  1. 在 Azure 里创建应用注册,授予目标 AKS 集群的访问角色(如 Azure Kubernetes Service Cluster User)。
  2. 配置 Federated identity credential:Issuer 填 https://token.actions.githubusercontent.com,Subject 填 repo:acme/demo-api:ref:refs/heads/main(发布不同分支/环境按实际 ref 或 environment 配置)。
  3. 在 GitHub 仓库 Settings → Actions → General → Workflow permissions 里确认允许 OIDC。

如果暂时没有 OIDC,或集群不接入云厂商,用 KUBE_CONFIG 兜底:把 kubeconfig 内容 base64 后存入 Secret,用 kubectl 的标准行为加载到默认位置(下面示例会给出)。兜底方案仍然比把 kubeconfig 明文提交进仓库安全,但要明白它的泄露面比 OIDC 大,只作为过渡。

#完整 workflow 示例

下面是一份可直接落到 .github/workflows/release.yml 的完整流水线(OIDC 路线),覆盖 checkout → 测试 → 登录 registry → 构建推送 → 配置 kubeconfig → 部署 → 验证:

name: release

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  id-token: write          # 换取 OIDC 短期令牌
  contents: read
  packages: write          # 推送到 GHCR

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Run tests
        run: go test ./...

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/acme/demo-api:${{ github.sha }}

      - name: Azure login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Configure kubeconfig
        uses: azure/k8s-set-context@v4
        with:
          method: oidc
          cluster-type: aks
          cluster-name: ${{ vars.CLUSTER_NAME }}
          resource-group: ${{ vars.RESOURCE_GROUP }}

      - name: Deploy
        run: |
          kubectl set image deployment/demo-api \
            api=ghcr.io/acme/demo-api:${{ github.sha }}

      - name: Verify rollout
        run: kubectl rollout status deployment/demo-api --timeout=180s

如果走 kubeconfig 兜底路线,把"Azure login(OIDC)"和"Configure kubeconfig"两个 step 替换为:

      - name: Configure kubeconfig (fallback)
        shell: bash
        run: |
          mkdir -p "$HOME/.kube"
          echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$HOME/.kube/config"
          chmod 600 "$HOME/.kube/config"

如果你不想用 build-push-action,等价的 Docker CLI 写法(同样真实可用,只是少了 buildx 缓存等便利):

docker build -t ghcr.io/acme/demo-api:${{ github.sha }} .
docker push ghcr.io/acme/demo-api:${{ github.sha }}

#逐段拆解:每一步为什么这么写

Checkout(actions/checkout@v4):把触发事件的提交拉进 runner。默认捡出 github.sha 对应的代码,保证后面构建的就是"这次提交"本身,而不是分支最新头(head)。这是可追溯性的第一步。

Run tests(go test ./...):在构建之前先做质量门禁。测试失败 → step 退出码非零 → 后续步骤全部不执行。语言不同就换成对应的测试命令(npm test、pytest 等),作用相同:让坏代码在进入镜像之前被拦住。

Log in to GHCR(docker/login-action@v3):登录 registry。用内置 GITHUB_TOKEN 时不需要任何 Secret 值,username 填 github.actor 即可;如果组织要求用 PAT,把 password 换成 ${{ secrets.GHCR_TOKEN }}。前面说过,这一步的结果写入 ~/.docker/config.json,后面的 push 才能通过认证。

Build and push image(docker/build-push-action@v6):在 runner 上构建并把镜像推到 GHCR。push: true 才是"推送",tags 用 ${{ github.sha }} 保证不可变、可追溯。镜像推上去之后,GHCR 会自动对镜像做漏洞扫描(在包页面可以看到扫描结果),也可以把扫描结果作为流水线门禁。

Configure kubeconfig(OIDC / kubeconfig 兜底):把访问集群的凭据落到 runner 的 kubeconfig 位置。OIDC 路线换到的是几分钟有效的短期令牌;kubeconfig 兜底路线加载的是 Secret 里存的配置。两条路线的后续 kubectl 命令一模一样。

Deploy(kubectl set image):把 Deployment 的 Pod 模板里 api 这个容器的镜像引用,指向本次构建的 SHA tag。这里没有"上传文件",只是修改期望状态——控制器随后会滚动更新。如果发布同时改了 manifest 里的其它内容,改用 kubectl apply -f manifest.yaml,机制见原理层。

Verify rollout(kubectl rollout status ... --timeout=180s):阻塞等待滚动更新完成。成功时输出 deployment "demo-api" successfully rolled out;超时则返回非零退出码,CI 直接标红失败。这一步必须存在——很多人部署完就结束,发布失败全靠人工发现,等于没有验证环。

#环境隔离与生产审批:别一套脚本打天下

on: push: branches: [main] 直接推生产是最危险的做法。生产发布至少要加一道"人"的环节:GitHub Environments(环境)。

在仓库 Settings → Environments 里创建 production 环境,打开"Required reviewers"(需要审批);然后 workflow 里把生产部署放到一个带 environment: production 的 job 中。效果是:代码合并自动发测试,生产发布必须点一下确认,approval 记录还会留在 workflow run 页面上。

deploy-prod:
  needs: [build-and-deploy]
  environment: production          # 审批 + 保护规则
  runs-on: ubuntu-latest
  steps:
    - run: |
        kubectl -n production set image deployment/demo-api \
          api=ghcr.io/acme/demo-api:${{ github.sha }}
        kubectl -n production rollout status deployment/demo-api --timeout=180s

同时,不同环境用不同的 vars(镜像仓库、namespace、values 文件),把环境差异从流水线逻辑里抽出来,而不是把配置写死在 YAML 里。这样"同一个 workflow 模板 + 不同环境变量"就能覆盖 dev / staging / prod,而不是维护三份几乎一样的 YAML。

#把"部署完就结束"改成"部署完验证"

rollout status 只能证明"控制器按期望状态完成了滚动",不能证明"新版本真的能服务流量"。更值钱的是加一条冒烟测试:部署完打一个真实请求,确认服务可用。这一步能拦住一半的"测试过了但线上挂"事故。

kubectl -n production port-forward svc/demo-api 8080:80 &
sleep 3
curl -fsS -m 10 http://127.0.0.1:8080/healthz

端口、路径按你的 Service 和健康检查端点调整。冒烟失败则返回非零退出码,在 rollout status 之后追加这一步,整条流水线才算真正闭环。

#验证层:怎么确认发布确实成功

按"镜像 → 期望状态 → 实际运行 → 真实流量"四个层次逐级确认:

1. 镜像真的推上去了:打开 GHCR 包页面,确认 ghcr.io/acme/demo-api 存在 ${{ github.sha }} 那个 tag,并查看 digest 与漏洞扫描结果。也可以在本地核对:

docker manifest inspect ghcr.io/acme/demo-api:${{ github.sha }}

2. 期望状态被正确修改:

kubectl -n production get deployment demo-api -o jsonpath='{.spec.template.spec.containers[0].image}'

输出应该正好是 ghcr.io/acme/demo-api:<sha>。如果这里不对,后面跑起来也是旧版本。

3. 控制器完成了滚动:rollout status 已经给了结论;再确认 Pod 层面:

kubectl -n production get pods -l app=demo-api
kubectl -n production get rs -l app=demo-api

期望看到:所有 Pod Running 且 READY 1/1;旧 ReplicaSet 缩到 0,新 ReplicaSet 的副本数等于期望值。

4. 真实流量可用:执行上面冒烟测试里的 curl(或你的健康检查请求),返回 200 即通过。这一步过了,才能对用户说"发布成功"。

流水线本身可以从 Actions 页面看到整体状态:绿色 = 全部 step 通过;红色 = 有 step 失败,失败 step 高亮,点开看日志即可定位。判定标准只有一条:四个层次全部确认,才算发布成功。 只过了第 1 层、后面没看,都不算。

#边界与坑

镜像 tag 拼错 → ImagePullBackOff。构建用 SHA、部署也用 SHA,一旦两边对不上(比如手写 tag、复制粘贴时截断),集群拉不到新镜像,Pod 会进入 ImagePullBackOff / ErrImagePull,而且不会自己好——控制器会一直重试拉取。排查时在 Events 里一眼就能看到拉取失败原因。所以:

kubectl -n production get events --sort-by=.lastTimestamp | tail -30

用了 latest 当 tag。latest 是可变 tag:第二次推送会覆盖同名引用,线上 Pod 一旦重启就可能拉到完全不同的内容,你无法从 tag 反推版本。坚持用 commit SHA(或 digest)。

packages: write 没给 → push 403。permissions 块缺了 packages: write,docker push ghcr.io/... 必然失败。这是"构建成功但推送失败"最常见的首因,先查权限再查网络。

OIDC 配置错 → 换不到令牌。常见失败点:仓库没有开启 Actions 的 OIDC(Settings → Actions → General → Workflow permissions);云厂商 federation 的 subject 与仓库不匹配(比如用 repo:acme/demo-api:* 配了,但实际事件带的是 environment subject);id-token: write 没声明。这类错误在"Azure login"或"Configure kubeconfig"步骤就会报 401/403,出错时先核对 issuer 和 subject。

集群上下文/namespace 用错。kubectl set image 默认操作当前 context 的 default namespace。多环境共用一条流水线时,必须显式 -n(如 -n production),否则可能把测试配置推到生产或反之。不同环境不同 vars 的 NAMESPACE 就是要解决这个问题。

部署完不验证。rollout status 不是可选项。没有它,发布失败要到用户反馈才被发现;有了它,超时即 CI 标红。同理冒烟测试建议加上。

把 kubeconfig / 长期 Token 提交进仓库或 Secrets 长期保管。这是泄露面最大的做法:仓库文件会随着 fork、下载、历史记录扩散。短期令牌 + OIDC 没有这个问题。兜底用 KUBE_CONFIG Secret 时也要定期轮换。

push main 直接触发生产发布。没有环境审批时,任何能 push main 的人都能发布生产。用 environment: production + Required reviewers,把"能发布"和"有权发布"分开。

发布失败后的排查顺序,按依赖方向从外到内:

kubectl -n production get events --sort-by=.lastTimestamp | tail -30
kubectl -n production get pods -l app=demo-api
kubectl -n production describe pod <pod-name>
kubectl -n production logs <pod-name> --previous

先看 Events(调度失败、镜像拉取失败、探针失败都会出现在这里),再 describe 看具体状态,最后看日志(--previous 看崩溃前的容器输出)。镜像 tag 拼错是最常见的坑,Events 里一眼就能看到 ImagePullBackOff 和拉取错误信息。

#收尾

一条稳的发布流水线可以压缩成一句话:短期凭据 + 不可变镜像 + 环境审批 + rollout 验证。底层机制上,它无非是把"源码提交"变成事件,在隔离 runner 上把构建产物固化成不可变镜像,再通过短期凭据把"更新期望状态"的请求送进集群,最后用控制器完成滚动并用验证步骤确认闭环。发布不是终点,验证才是。

下一步可以按需加强:用 digest 取代 tag 做引用(更严格);把镜像扫描结果作为发布门禁;把冒烟测试做成多环境复用的 job matrix;在生产发布前加人工审批。同时也要知道什么时候不该用它——如果你的集群只是本地单点验证、没有多人协作与追溯需求,GitHub Actions + GHCR + OIDC 这套设施是过重的,直接 docker build && kubectl apply 更省事;这套流水线的价值恰恰来自多环境、多协作者、需要审计的规模化场景。

相关阅读

Related

留言讨论

0 条

还没有留言,来说点什么吧。

Kubernetes CI/CD:GitHub Actions 构建并发布镜像 · LXH·BLOG