Skip to content

从零搭建 GitLab CI/CD:部署、发布与排错记录 ​

自建 GitLab 只是代码托管的起点。真正可交付的 CI/CD,需要把代码审核、构建算力、制品、部署权限、运行验收和回滚连接起来:维护者合并代码后,系统能够发布确定的版本;失败时能够定位停在哪一层;发布异常时能够恢复发布前的状态。

这里以一个包含订单 API 和管理前端的项目为例,按“部署 GitLab → 接入 Runner → 跑通验证 → 构建制品 → 发布测试环境 → 完善通知与回滚 → 推广到其他仓库”的顺序说明。容器服务发布到 Kubernetes,静态前端发布到对象存储。配置以 2026 年 9 月的 GitLab Runner 认证令牌工作流、Docker Compose v2 和 Kubernetes executor 为边界;具体镜像、Helm Chart 和工具版本应固定到实际验收过的版本。

示例统一使用 git.example.com、team/order-api、app-test 等演示名称。命令中的版本、镜像仓库和环境变量需要按部署环境填写。GitLab 主机和已有 Kubernetes 集群的安装权限是前提;云账号、网络与集群本身的创建不在本文展开,相关模型可先阅读 Kubernetes 总览。

先确定发布链路和边界 ​

对订单 API,期望链路是:开发者提交 MR,维护者合并到受保护的 test 分支,流水线测试代码、构建镜像、推送镜像仓库、更新测试工作负载,最后执行接口验收并通知。测试通过后,生产发布再使用独立的身份和环境门禁。

GitLab CI/CD 组件与权限关系

组件职责不能据此推断的事情
GitLab仓库、MR、流水线编排、变量和作业记录GitLab 启动成功不代表能执行构建
Runner manager向 GitLab 请求任务,创建和清理执行环境管理 Pod 正常不代表某个项目能调度作业
CI 作业 Pod拉代码、测试、编译、推送或部署Pod 执行完毕不代表业务发布成功
镜像仓库/对象存储保存可追溯的发布产物上传成功不代表运行环境已切换版本
Kubernetes/静态站点运行或分发指定版本滚动更新完成、首页返回 200 都不是完整业务验收

Kubernetes executor 中,Runner manager 通常作为 Deployment 常驻,每个 GitLab CI Job 对应一个临时 Pod。它不是每次启动一个 Runner,也不是默认创建 Kubernetes Job。数据库迁移、批处理和集群内冒烟测试,才可能由部署脚本另外创建 Kubernetes Job。

国内与海外环境可以共用 GitLab 控制面,但执行算力应尽量靠近依赖源、镜像仓库和部署目标。海外 Runner 能访问 GitLab,不代表国内 GitLab 主机能访问海外代码源。先分别验证网络路径,再决定镜像导入、缓存和发布的位置。

第一步:把 GitLab 部署成可维护的服务 ​

数据先于容器 ​

GitLab 的状态不只包括 Git 仓库,还有数据库、附件、LFS 对象、配置和加密所需文件。先准备固定的宿主机挂载目录,再启动容器:

text
/srv/gitlab/
├── compose/       # Compose 配置和版本记录
├── config/        # /etc/gitlab
├── data/          # /var/opt/gitlab
└── logs/          # /var/log/gitlab

/srv/gitlab/compose/compose.yaml:

yaml
services:
  gitlab:
    image: gitlab/gitlab-ce:${GITLAB_VERSION:?请设置固定版本}
    container_name: gitlab
    hostname: git.example.com
    restart: unless-stopped
    shm_size: 256m
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'https://git.example.com'
        nginx['listen_port'] = 80
        nginx['listen_https'] = false
        letsencrypt['enable'] = false
        gitlab_rails['gitlab_shell_ssh_port'] = 2222
    ports:
      - '127.0.0.1:8929:80'
      - '2222:22'
    volumes:
      - /srv/gitlab/config:/etc/gitlab
      - /srv/gitlab/data:/var/opt/gitlab
      - /srv/gitlab/logs:/var/log/gitlab

在该目录的本地 .env 中设置已选定的完整镜像标签,例如 GITLAB_VERSION=<版本号>-ce.0,不要使用浮动的 latest。使用 Compose v2 启动和检查:

bash
docker compose config --quiet
docker compose up -d
docker compose ps
docker exec gitlab gitlab-ctl status

宿主机 Nginx 终止 HTTPS,再代理到本机 8929。这样公网只开放 HTTPS、证书验证所需的 HTTP,以及确实需要的 Git SSH 端口;不用把 GitLab 的内部 HTTP 端口暴露出去。Git SSH 的 2222 与主机管理 SSH 的 22 是两条不同通道。

独立目录便于容器重建和数据迁移,但独立目录不等于独立磁盘,更不等于备份。用 findmnt -T /srv/gitlab/data 和 df -h /srv/gitlab/data 确认实际挂载;构建缓存也不应无上限地挤占 GitLab 数据盘。

域名、反向代理和证书必须一致 ​

先确认 DNS 指向正确入口,并完成证书签发。以下片段位于 Nginx 的 http 上下文,证书文件需要事先安装:

nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}

server {
    listen 80;
    server_name git.example.com;
    location /.well-known/acme-challenge/ {
        root /var/www/acme;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    server_name git.example.com;
    ssl_certificate     /etc/nginx/ssl/git.example.com/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/git.example.com/privkey.pem;
    client_max_body_size 512m;

    location / {
        proxy_pass http://127.0.0.1:8929;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600;
    }
}

签发第一张证书时,可以先启用 HTTP 校验站点,证书安装后再启用 HTTPS 配置。自动续期需要包含“重新签发 → 安装到 Nginx 使用的路径 → nginx -t → reload”整条链路,只有定时执行签发命令还不够。下载得到的一份证书文件本身不会自动续期。

更换域名时,要同步检查 DNS、证书、external_url、Runner URL、Git remote、Webhook,以及使用该域名作为 issuer 的身份信任配置。旧域名的错误不能作为新入口是否正常的证据。

验收应从 GitLab 主机和 Runner 所在网络各做一次:

bash
curl --fail --silent --show-error https://git.example.com/users/sign_in -o /dev/null
git ls-remote https://git.example.com/team/demo.git
# SSH 克隆需要提前配置用户公钥,并核对服务端主机密钥指纹
ssh -T -p 2222 git@git.example.com

私有项目的 HTTPS 认证应交给凭据管理工具,不把令牌拼在 URL 中。若主机内服务正常、公网出现拦截页或 TLS 被重置,应检查云侧入口、安全规则及域名接入状态。SSH 能登录主机,只证明管理通道可用。

账号策略和恢复能力 ​

在管理后台明确注册策略:内部实例可以关闭自助注册,或采用邮箱域名限制加管理员审批。域名限制不应被当成清理历史账号的工具;已有成员需要单独审查。SMTP 也要实际验证,否则注册确认、密码重置和告警邮件无法形成闭环。

备份至少覆盖应用数据、gitlab.rb 和 gitlab-secrets.json。应用备份命令是:

bash
docker exec -t gitlab gitlab-backup create

这条命令不等于完整灾备:配置与加密文件需要单独加密保管,外部对象存储和镜像仓库按其存储方式另做备份。备份应离开原数据盘,并演练恢复到匹配的 GitLab 版本与版本类型。容器重建前后可核对用户、项目、SSH 公钥数量以及仓库可读写性,不应把“容器启动了”当作数据完整的证明。

第二步:安装 Runner,先跑一个无部署权限的作业 ​

先分清三种镜像 ​

Runner 管理镜像负责接收任务和管理 Pod;Helper 镜像负责克隆代码、缓存和制品传输;作业镜像才运行 npm、Go、BuildKit、AWS CLI 或 kubectl。

因此,把 Node 或 Go 装进 Runner manager 镜像,并不会让业务作业自动获得这些工具。公共工具可以做成固定版本的作业镜像,但业务编译工具链仍应跟随项目需要选择。管理镜像、Helper 与运行节点架构也需要匹配。

使用认证令牌创建 Runner ​

在 GitLab 的项目或群组 Settings → CI/CD → Runners 中创建 Runner。普通验证 Runner 使用标签 k8s-verify,关闭接收无标签作业;发布 Runner 后续单独创建并设为 Protected。采用以 glrt- 为前缀的 Runner authentication token 工作流,不沿用旧的 registration token 教程。

如果在主机直接执行 gitlab-runner register 却提示 command not found,说明该主机没有安装 Runner 程序。GitLab 服务镜像不会自动在宿主机安装 Runner。这里选择 Helm 安装,因此无需再在同一位置执行主机版注册命令;采用 Docker executor 的独立构建机才需要另行安装对应 Runner 软件并注册。

对于 Kubernetes,先由管理员创建 ci-verify namespace 和无业务权限的 ci-job ServiceAccount。Runner manager 的 Role/RoleBinding 由固定版本的 Helm Chart 创建。安装前用 kubectl auth can-i 检查权限;收到 Forbidden 表示当前身份无权观察或操作,不能推断 namespace 不存在。

bash
# 在管理员已确认的目标集群上下文中执行;已存在的资源无需重复创建
kubectl create namespace ci-verify
kubectl -n ci-verify create serviceaccount ci-job
kubectl auth can-i create deployments -n ci-verify
kubectl auth can-i create roles -n ci-verify

将认证令牌保存到本地受限文件,通过 Secret 注入,避免把值写进命令历史或 Git:

bash
# 本地文件只包含认证令牌,不带末尾换行;文件权限应为 600
kubectl -n ci-verify create secret generic runner-auth \
  --from-file=runner-token=/secure/runner-token \
  --from-literal=runner-registration-token=''

values-verify.yaml 的起步配置:

yaml
gitlabUrl: https://git.example.com
concurrent: 2
rbac:
  create: true
runners:
  secret: runner-auth
  config: |
    [[runners]]
      executor = "kubernetes"
      [runners.kubernetes]
        namespace = "ci-verify"
        image = "alpine:3.22"
        service_account = "ci-job"
        automount_service_account_token = false
        service_account_overwrite_allowed = ""
        privileged = false
        cpu_request = "500m"
        cpu_limit = "2"
        memory_request = "512Mi"
        memory_limit = "2Gi"
        helper_cpu_request = "100m"
        helper_memory_request = "128Mi"
        helper_memory_limit = "512Mi"
        ephemeral_storage_request = "1Gi"
        ephemeral_storage_limit = "4Gi"
        [runners.kubernetes.node_selector]
          "kubernetes.io/arch" = "arm64"

这里选择 arm64 是示例,不代表集群一定是 ARM。先核对节点标签,再选择架构;tags 决定哪个 Runner 接任务,node_selector 决定作业 Pod 到哪类节点,二者不能相互替代。

bash
helm repo add gitlab https://charts.gitlab.io
helm repo update
helm search repo gitlab/gitlab-runner --versions
# CHART_VERSION 填写已选定的 Chart 版本;Chart 版本不等于 Runner 版本
helm upgrade --install ci-verify gitlab/gitlab-runner \
  --namespace ci-verify --version "$CHART_VERSION" \
  --values values-verify.yaml
kubectl -n ci-verify get pods

认证令牌模式下,标签、Protected、是否接收无标签任务等属性应在创建 Runner 时或 GitLab 管理界面设置,不能假定旧注册参数仍会覆盖这些属性。Runner 主动连接 GitLab 请求任务,通常无需为 Runner 新开公网入站端口。

第一条流水线只验证执行链路 ​

在示例仓库根目录提交 .gitlab-ci.yml:

yaml
stages: [verify]

runner-smoke:
  stage: verify
  tags: [k8s-verify]
  image: alpine:3.22
  script:
    - uname -m
    - test -d .git
    - printf '%s\n' "$CI_COMMIT_SHA" > build-info.txt
  artifacts:
    paths: [build-info.txt]
    expire_in: 1 day

确认作业从 pending 进入 running,能克隆仓库、运行脚本、上传并下载制品,结束后临时 Pod 被清理。此时才证明 Runner 执行链路已经打通;还没有业务构建、云权限或部署能力。

第三步:把人员、构建和发布权限拆开 ​

希望“维护者合并后自动发布”,不需要共享一个管理员账号。人员用自己的 GitLab 身份做审查,流水线使用专用机器身份。test 和 main 都设为受保护分支,禁止直接 push,只允许约定的维护者合并;CI 配置和部署脚本也在审核范围内。

身份所需权限不应默认拥有
安装与维护人员安装 Runner、配置 IAM 和 namespace RBAC不把个人登录会话作为流水线运行前提
Runner manager在指定 CI namespace 创建、观察、清理作业 Pod 等资源业务 namespace 的发布权限
验证作业拉代码、下载依赖、测试和上传制品云发布身份、生产变量
镜像发布作业登录并推送指定镜像仓库修改 IAM、删除镜像仓库
测试/生产部署作业各自环境指定资源的更新与验收跨环境发布、任意 ServiceAccount 切换

至少把普通验证、测试发布、生产发布拆开。不同信任级别宜使用不同 Runner 和 CI namespace;普通分支不能通过 KUBERNETES_SERVICE_ACCOUNT_OVERWRITE 选到高权限账号。Runner 标签只是调度条件,真正的权限边界来自 Protected Runner、项目范围、分支保护、变量规则和运行身份。

在 AWS EKS 中,可以使用 IRSA 或 Pod Identity,让长期存在的 IAM Role 每次签发短期凭证。若用 GitLab ID token 直接联合 AWS,则需要独立配置 OIDC provider,并约束 issuer、audience、项目和受保护分支;它与 EKS ServiceAccount 的 OIDC 信任不是同一套配置。

采用 IRSA 时,管理员先关联集群 OIDC provider,创建限定到指定 namespace 和 ServiceAccount 的角色信任,再给 ServiceAccount 添加角色注解。只添加注解不会自动创建 IAM Role 或授权。部署作业内部用以下命令核对实际身份,输出不得包含令牌:

bash
aws sts get-caller-identity
kubectl auth can-i patch deployment/order-api -n app-test
kubectl auth can-i list deployments -n app-test
kubectl auth can-i watch deployments -n app-test

云权限与 Kubernetes RBAC 要分别验收。能向 ECR 推送镜像,不代表能更新 Deployment;eks:DescribeCluster 只允许获取集群信息,也不代表已经获得 Kubernetes 对象权限。同集群发布可以使用 Pod 的 ServiceAccount;跨集群发布还要建立目标集群的身份映射或访问条目与 RBAC,不能照搬源集群令牌。

对一个已有的 order-api Deployment,最小更新示例是由管理员在目标 namespace 创建:

yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: order-api-publisher
  namespace: app-test
rules:
  - apiGroups: [apps]
    resources: [deployments]
    resourceNames: [order-api]
    verbs: [get, patch]
  - apiGroups: [apps]
    resources: [deployments]
    verbs: [list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: order-api-publisher
  namespace: app-test
subjects:
  - kind: ServiceAccount
    name: order-publisher
    namespace: ci-release-test
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: order-api-publisher

这允许读取该 namespace 的 Deployment 列表和变化,但只允许修改指定 Deployment。若进一步限制 list/watch 的 resourceNames,客户端必须携带匹配的名称字段选择器,需要针对所用客户端验证。只给 get/patch 时,更新可能成功,而 kubectl rollout status 的监听持续报 Forbidden。

这个 Role 不能完成首次创建应用、读取 Secret、创建迁移 Job,也不覆盖 Argo Rollouts。首次基础设施初始化由管理员或独立基础设施流程完成;实际需要哪些附加动作,再按资源种类和 namespace 授权。使用 Rollout CRD 的服务要配置 rollouts.argoproj.io 权限及对应验收工具。

ECR 权限同样拆分:ecr:GetAuthorizationToken 的资源范围必须为 *,镜像层上传、PutImage、镜像读取等动作则限制到明确的 repository ARN。运行节点或运行 Pod 的镜像拉取身份还需要单独配置,CI 能推送不代表业务节点能拉取。

第四步:定义仓库怎样构建,而不是套一个万能 Dockerfile ​

接入前先列清楚“仓库 → 制品 → 运行对象”的映射。一个仓库中有多个目录、多个二进制程序,甚至多个 Deployment,都不能直接推断为多个镜像。

仓库类型构建产物发布目标回滚记录
静态管理前端dist/ 等静态文件对象存储和 CDN发布前静态版本或快照
单镜像 API一个镜像 digest一个或多个明确工作负载各目标原镜像 digest
单镜像加前端一个后端镜像和一份静态制品Kubernetes 加对象存储后端与前端版本组合
多镜像仓库API、Worker 等各自的镜像各自声明的工作负载所有组件的发布清单
仅构建项目SDK、压缩包或基础镜像制品库不提供虚假的业务回滚入口

订单项目可以采用以下目录,公共模板不负责决定业务如何编译:

text
order-api/
├── Dockerfile
├── .gitlab-ci.yml
├── web/
└── ci/
    ├── verify.sh
    ├── build.sh
    ├── deploy.sh
    ├── rollback.sh
    └── notify.py

镜像架构按目标工作负载确定 ​

先看目标工作负载的节点约束、实际运行节点和依赖库,再决定 linux/arm64 或 linux/amd64。工具镜像提供两种架构,不意味着每次业务发布也要构建两种架构。镜像标签里出现 amd64 更不是架构证明:

bash
kubectl get nodes -L kubernetes.io/arch
kubectl -n app-test get deployment order-api -o yaml
docker buildx imagetools inspect "$IMAGE_REF"

纯 Go 且不依赖 CGO 时可以交叉编译;涉及 C/C++、CGO 或厂商 SDK 时,应优先使用目标架构节点。QEMU 模拟需要提前配置,性能和兼容性也必须验证。典型错配表现是 exec format error 或 Pod 无法就绪,不能靠重试流水线修复。

构建工具链应固定版本。Dockerfile 保留业务依赖和构建逻辑,作业镜像提供 CI 所需的工具;两层文件系统彼此独立,作业镜像预装了依赖,不代表 Dockerfile 内的安装步骤就能命中缓存。

构建一次,保存不可变产物 ​

用提交 SHA 加流水线编号标记镜像,并在部署前解析为 digest。不要只部署 latest,也不要在 deploy 作业中重新编译一次。下面是已配置 Buildx、构建引擎、镜像认证和目标仓库后的命令片段:

bash
set -euo pipefail
IMAGE_TAG="${IMAGE_REPOSITORY}:ci-${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
docker buildx build \
  --platform "linux/${TARGET_ARCH}" \
  --file Dockerfile \
  --tag "$IMAGE_TAG" \
  --metadata-file build-metadata.json \
  --push .
DIGEST=$(jq -er '."containerimage.digest"' build-metadata.json)
printf 'IMAGE_REF=%s@%s\n' "$IMAGE_REPOSITORY" "$DIGEST" > image.env

这不是仅安装 Docker CLI 就能运行的命令:Buildx 还需要可访问的 BuildKit 后端。镜像构建可采用独立的 rootless BuildKit;其 user namespace、seccomp 和 AppArmor 需求要在所用节点上验证。特权 Docker-in-Docker 和挂载宿主 Docker socket 都会扩大权限边界,不能为了跑通构建而给普通验证 Runner 全局启用。

image.env 可以作为 artifacts:reports:dotenv 传给部署作业;完整镜像 digest、提交、构建参数和流水线编号应写入发布记录。私钥、访问令牌、运行环境 Secret 不进入镜像层、构建参数、缓存或制品。

第五步:把触发规则、构建和发布连接起来 ​

MR 流水线负责验证,合并后 test 的 push 流水线负责部署。不能把“源分支提交”“MR 更新”“合并后目标分支更新”视为同一种事件。

下面是容器仓库的接入骨架,展示作业之间的契约;ci/*.sh、ci/notify.py 和 $CI_TOOLS_IMAGE 需要先实现或由固定版本的公共工具包提供。它不是一个复制到任意仓库即可发布的完整产品模板。

yaml
workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "test" && $CI_COMMIT_REF_PROTECTED == "true"'
    - when: never

stages: [verify, build, deploy, notify, operations]

verify:
  stage: verify
  tags: [k8s-verify]
  image: $CI_TOOLS_IMAGE
  script: ["bash ci/verify.sh"]

.test-release:
  tags: [k8s-release-test]
  image: $CI_TOOLS_IMAGE
  rules:
    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "test" && $CI_COMMIT_REF_PROTECTED == "true"'

build:
  extends: .test-release
  stage: build
  script: ["bash ci/build.sh"]
  artifacts:
    paths: [build-metadata.json]
    reports:
      dotenv: image.env
    expire_in: 30 days

deploy-test:
  extends: .test-release
  stage: deploy
  environment:
    name: test
  resource_group: order-api-test
  needs:
    - job: build
      artifacts: true
  script: ["bash ci/deploy.sh"]
  artifacts:
    when: always
    paths: [release-status.json]
    expire_in: 30 days

notify-success:
  extends: .test-release
  stage: notify
  tags: [k8s-notify]
  dependencies: []
  when: on_success
  allow_failure: true
  script: ["python3 ci/notify.py success"]

notify-failure:
  extends: .test-release
  stage: notify
  tags: [k8s-notify]
  dependencies: []
  when: on_failure
  allow_failure: true
  script: ["python3 ci/notify.py failed"]

rollback-test:
  extends: .test-release
  stage: operations
  environment:
    name: test
  resource_group: order-api-test
  dependencies: []
  when: manual
  allow_failure: true
  script: ["bash ci/rollback.sh"]

redeploy-test:
  extends: .test-release
  stage: operations
  environment:
    name: test
  resource_group: order-api-test
  dependencies: []
  when: manual
  allow_failure: true
  script: ["bash ci/deploy.sh --reuse-release"]

CI_TOOLS_IMAGE 是非敏感的固定镜像引用,需要对 MR 验证作业可见,不能与发布凭据一起限制成仅受保护分支可读;若镜像为私有镜像,验证 Runner 还要具备只读拉取能力。部署凭据和通知 Webhook 则只提供给对应的受保护作业,并按实际 GitLab 支持设置 Masked、Hidden 等属性。

这里用 dependencies: [] 让通知与手动作业不被自动下载上游制品阻塞;通知脚本从 GitLab API 和持久发布记录获取结果,回滚脚本按当前流水线编号读取原始发布记录。没有给通知加 needs: [],因为那会让通知脱离前序阶段、过早开始。也没有让失败通知强依赖 deploy-test,因此验证或构建失败时仍有通知路径。

k8s-notify 需要实际创建对应的受保护 Runner,只持有通知和必要只读查询权限,不持有部署权限。通知作业也可以与受信任的执行池共享容量,但要通过身份和 namespace 控制权限,并避免构建排队耗尽通知容量。

allow_failure: true 使可选的手动入口不阻塞主流水线,但也意味着其失败不会把整条流水线判红。因此回滚和再次发布必须记录独立的操作结果并通知,不能只看原流水线状态。若组织要求这些操作严格计入独立流水线状态,应使用单独的操作流水线。

部署脚本要有可验收的契约 ​

ci/deploy.sh 至少完成以下步骤,且所有环境修改都放在同一个 resource_group 锁内:

  1. 确认目标环境、工作负载、容器名称和待发布 digest;拒绝不完整参数。
  2. 读取当前版本,持久保存“本次发布前版本”和“本次待发布版本”。
  3. 更新指定容器镜像,等待滚动更新完成。
  4. 验证服务内接口,再验证网关或外部域名的业务路径。
  5. 更新发布记录;失败时保留失败阶段、已修改对象与诊断结果。

已有 Deployment 的更新命令可以很短:

bash
kubectl -n "$APP_NAMESPACE" set image \
  "deployment/$WORKLOAD" "$CONTAINER=$IMAGE_REF"
kubectl -n "$APP_NAMESPACE" rollout status \
  "deployment/$WORKLOAD" --timeout=180s
curl --fail --silent --show-error \
  --retry 3 --retry-delay 3 --max-time 15 \
  "$SMOKE_URL"

但 SMOKE_URL 不能只是一个始终返回 200 的首页。订单服务可以使用测试租户查询一笔已知订单,校验状态码、响应字段和依赖可用性;验证账户使用最小权限,响应也不输出客户数据。测试必须有超时和退出条件,不能无限重试掩盖业务失败。

若验收由 Kubernetes Job 执行,不能只等 Complete=True。轮询时同时判断 Failed=True,并检查 backoffLimit、activeDeadlineSeconds,失败后立即采集 Job 状态和脱敏日志。否则一个已经返回 404 的测试会被拖到几分钟后,最终错误却显示成“等待超时”。

rollout status 成功只证明工作负载满足滚动更新条件。网关仍可能返回 404、504,依赖也可能异常。流水线后半段失败不会自动撤销已经更新的镜像,因此必须明确自动回滚还是保留现场、由维护者执行回滚。

静态前端如何接入同一条发布流程 ​

静态前端不必先构造成 Docker 镜像。构建作业执行锁文件对应的依赖安装和构建,将 dist/ 作为 artifact 交给发布作业。VitePress 常见输出是 .vitepress/dist/,应按项目实际构建目录配置,不能统一猜成 dist/。

例如普通 Vite 项目的构建作业:

yaml
build-web:
  stage: build
  tags: [k8s-verify]
  image: node:22-bookworm-slim
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run build
  cache:
    key:
      files: [package-lock.json]
      prefix: node22
    paths: [.npm/]
  artifacts:
    paths: [dist/]
    expire_in: 30 days

发布前先保存当前完整静态版本或建立可恢复的版本清单。上传采用“哈希资源先上传、入口文件最后切换”的顺序,以下只是发布脚本的上传部分:

bash
set -euo pipefail
: "${WEB_BUCKET:?}" "${WEB_PREFIX:?}" "${CF_DISTRIBUTION_ID:?}"
destination="s3://${WEB_BUCKET}/${WEB_PREFIX%/}"
aws s3 sync dist/assets/ "${destination}/assets/" \
  --cache-control 'public,max-age=31536000,immutable'
aws s3 sync dist/ "${destination}/" \
  --exclude 'assets/*' --exclude 'index.html' \
  --cache-control 'public,max-age=300'
aws s3 cp dist/index.html "${destination}/index.html" \
  --content-type text/html \
  --cache-control 'no-cache,max-age=0,must-revalidate'
INVALIDATION_ID=$(aws cloudfront create-invalidation \
  --distribution-id "$CF_DISTRIBUTION_ID" \
  --paths '/*' --query 'Invalidation.Id' --output text)
aws cloudfront wait invalidation-completed \
  --distribution-id "$CF_DISTRIBUTION_ID" --id "$INVALIDATION_ID"

这里假设 WEB_PREFIX 是无前导斜杠的非空目录,例如 apps/admin/test。若对象存储目录是这个前缀、网站却从域名根路径访问,CDN Origin Path 应与此前缀匹配;若网站通过 /admin/ 访问,还需校对构建工具的 base 和 CDN 路由。缓存刷新路径是浏览器访问路径,不是 S3 内部对象前缀。

这个顺序降低新 HTML 引用不存在资源的概率,但多个非哈希页面的覆盖不是原子操作。多页站点或强一致发布可以使用版本目录加入口切换;CDN 刷新后仍要检查页面及其关键资源。不要在上传新版本时立刻删除旧哈希文件,仍持有旧 HTML 的浏览器需要它们。垃圾清理按保留期独立执行,并考虑最长页面缓存时间。

如果发布包含一个后端镜像和一份静态前端,发布记录要保存这两个版本及其兼容关系。前端目录多不等于镜像多,当前只运行一个工作负载也不代表仓库永远只有一个运行单元。

通知、回滚和再次发布应作为基础能力 ​

通知要覆盖失败,并保留准确的业务结果 ​

只在部署脚本最后发送成功通知,会漏掉测试失败、构建失败和部署中途退出。用独立成功/失败通知作业承接结果,通知失败不改变业务发布的事实;再用平台级 Pipeline Webhook 或外部监控兜住 YAML 无效、Runner 全部离线、流水线取消等无法保证执行通知作业的情况。

通知保留一份清楚的结构即可:

text
发布失败
服务:订单 API
环境:测试
合并:feature/order-timeout → test
合并人:实际 MR 合并人
版本:提交 SHA/镜像 digest
失败位置:deploy-test
链接:流水线、失败作业、MR

合并后的分支流水线通常没有完整的 MR 变量。可用项目 ID 和提交 SHA 查询关联 MR,再筛选已合并、目标分支匹配且与该提交对应的记录。合并人取 MR 的实际字段;GITLAB_USER_NAME 是流水线触发者,不能无条件当作合并人。没有可靠匹配时明确显示“直接提交或未识别 MR”,不从提交标题猜测。

通知里的镜像完整引用用于定位仓库,digest 用独立代码块展示以便复制。负责人可以从服务目录或外部表格查询,失败时显示“未配置”,不阻塞发布,也不默认冒用某个人。Webhook 和查询凭据放受保护变量或 Secret;截图、错误日志和导出的静态页面也不应包含它们。

回滚恢复本次发布前的版本 ​

发布、回滚与再次发布的状态关系

“回滚上一个提交”“重跑上一条流水线”和“恢复这次发布前的版本”并不等价。一次流水线可能只部署了一半,也可能在后面已有另一次成功发布。

发布记录需要持久保存以下信息,且只能包含非敏感元数据:

json
{
  "service": "order-api",
  "environment": "test",
  "pipeline_id": "示例流水线编号",
  "commit": "完整提交 SHA",
  "before": { "backend": "发布前镜像 digest", "web": "发布前静态版本" },
  "after": { "backend": "本次镜像 digest", "web": "本次静态版本" },
  "phase": "accepted"
}

第一次修改环境前就写入 before,不要在失败后才尝试补记;重试同一流水线也不能覆盖原始 before。记录可以存放在启用版本和访问控制的对象存储中,GitLab artifact 作为便于排查的副本。只有短期 artifact 时,过期后回滚按钮即使还在,也无法恢复现场。

回滚读取 before,再次发布读取 after,两者都复用已经构建的不可变产物,不重新构建。两种操作和正常部署使用相同的环境锁,并分别完成验收和通知。

执行前还应比较线上当前版本与记录里的预期状态。假设发布 A 后又发布了 B,再点击 A 的回滚,不能直接把 B 覆盖掉;应拒绝过期操作,或经过明确的版本选择流程。resource_group 能解决同一项目内的并发互斥,不能独自解决跨项目写同一环境、旧流水线晚于新流水线部署的问题,还需要统一发布入口或外部锁,以及版本新旧检查。

数据库迁移不随镜像自动回退。涉及删字段、改数据格式等不可逆操作时,要使用向前兼容迁移、分阶段切换和独立恢复方案;“镜像已回滚”不能被记录为“整个系统已恢复”。

缓存与并发:先定位时间花在哪里 ​

临时 Pod 每次重建,不意味着每次都要重新下载和编译。缓存、制品和运行环境承担不同职责:缓存丢失只应变慢;制品缺失则应停止部署;运行环境必须从声明的配置重建。

手段适合保存的内容常见边界
固定版本工具镜像Node、Go、编译器、AWS CLI 等工具不包含凭据;升级需要重新验收
GitLab cachenpm 下载缓存、Go module/编译缓存key 包含项目、工具链、架构和锁文件;隔离不同信任级别
BuildKit cacheDockerfile 构建层单独配置远程缓存;不等同于 GitLab cache
artifacts本条流水线构建出的静态文件、测试报告、元数据明确保留期和下游下载关系
镜像仓库/版本化对象存储可发布和可回滚的制品保留窗口覆盖回滚需求,避免被清理策略误删

常驻一个构建 Pod 可以复用本地目录,但随之增加环境漂移、并发冲突、跨仓库污染和持久卷维护。对起步阶段,固定工具镜像、临时作业 Pod 和远程缓存通常更容易维护。是否引入常驻 BuildKit 或持久卷,应基于构建频率、缓存传输量和测量结果决定。

记录排队、拉镜像、安装依赖、编译、镜像推送、滚动更新和验收分别用了多久。先避免无必要的双架构构建,再调整 Dockerfile 层顺序和缓存;验收耗时不能通过删除验收步骤来“优化”。

作业一直 pending 时,先检查 Runner 标签、Protected、项目授权和在线状态。只有作业 Pod 已创建但无法调度时,才转向 CPU、内存、临时存储、节点架构、taint 和配额。扩大 Runner 并发不能修复权限或标签不匹配。

每个 manager 的全局 concurrent、单 Runner 的 limit、manager 副本数和节点可调度资源都会影响容量;request_concurrency 控制向 GitLab 请求任务的并发,不等于可同时运行的作业数。扩容时同时限制作业资源,避免 CI 抢占业务节点。单独的 namespace 不会隔离底层 CPU 和磁盘竞争,必要时使用专用节点池。

从一个仓库推广到多个仓库 ​

先在一个服务完成真实的发布、失败通知、回滚和再次发布,再抽取共享模板。模板统一的是门禁、变量约定、缓存策略、发布记录、通知和操作入口;Dockerfile、依赖版本、构建上下文、迁移逻辑和业务验收留在业务仓库。

yaml
include:
  - project: platform/ci-templates
    ref: v1.0.0
    file: /templates/container-test.yml

variables:
  SERVICE_NAME: order-api
  TARGET_ARCH: arm64
  DOCKERFILE: Dockerfile
  BUILD_CONTEXT: .
  APP_NAMESPACE: app-test
  WORKLOAD: order-api
  CONTAINER: api

这里的项目、标签和模板文件需要先发布到自己的 GitLab。v1.0.0 是示例接口版本,不是 GitLab 自带模板。模板只能提供已实现的能力;没有完整部署契约的项目先接成“仅构建”,不要展示虚假的发布成功和回滚入口。

共享模板发布顺序应是:合并并验证模板 → 创建不可变标签或引用固定 commit → 验证使用方身份能读取 → 再合并业务仓库引用。仅在作者工作区里有文件,或标签还未创建,都会导致业务流水线在执行任何作业前就配置失败。

私有 include:project 的解析要求触发流水线的用户具有相应项目访问权限;给项目加 CI Job Token allowlist 并不能自动解决这个权限。作业运行期间再通过 CI_JOB_TOKEN 下载模板脚本,是另一阶段的认证,应另外校对 allowlist、用户权限和接口支持。应使用普通维护者身份验收,不能只验证管理员账号。

还要注意:YAML 的 include 不会把模板仓库的脚本复制到业务仓库。公共脚本应打包进固定版本工具镜像,或者由作业从同一固定 ref 下载并校验,避免 YAML 固定版本而脚本却追随 main。

新增服务只需先交付五项信息:仓库和审核人、制品类型及构建入口、目标架构、部署对象和环境、验收与回滚规则。平台据此配置 Runner 范围、云权限、RBAC 和变量,再提交接入 MR。真正的多镜像项目可以用矩阵或子流水线,但每个镜像都要声明自己的 Dockerfile、上下文和部署映射。

生产接入要单独验收 ​

测试发布成功后,再给生产分支和 Runner 配置独立权限、变量、资源锁和发布策略。是否合并 main 后自动部署,还是保留手动门禁,由团队发布制度决定;Protected Environment 等细粒度能力还要核对所用 GitLab 版本与许可,不能假定所有实例都有相同功能。

优先晋级已经验收的制品 digest,而不是合并后随意重建。若前端将环境参数编译进产物,测试产物不能直接当作生产产物使用,需要固定源码和构建输入重新构建并验收,或改为运行时配置。跨区域仓库复制后也应核对 digest、架构和依赖可达性。

首次接入历史服务时,先确认线上镜像对应的源码提交能够找回。线上正常运行、仓库 test 可以构建,并不证明两者是同一版本;无法定位源码基线时直接启用自动部署,可能把现场业务回退到旧实现。

排错顺序和完成标准 ​

现象先看哪一层闭环方式
域名无法访问,但主机 SSH 正常DNS、TLS、反代与公网入口从 Runner 网络验证 HTTPS 和克隆,不关闭证书校验绕过
合并后没有流水线配置解析、workflow:rules、受保护分支状态、跳过 CI 标记用 CI Lint 检查合并配置,再验证目标分支 push 事件
模板 project/ref 不存在模板是否已发布、用户访问权限核对标签与文件,再用实际合并人的权限检查
作业 pending,没有 PodRunner 匹配、Protected、项目范围、并发修正匹配或容量,避免让 MR 去等待只能执行发布的 Runner
Pod Pending节点资源、架构、污点、存储和拉取镜像看调度事件,不盲目增加 manager 副本
ECR/S3 AccessDenied作业实际 AWS 身份与资源范围在作业内查身份,按被拒动作补精确权限
更新成功,rollout 持续 ForbiddenKubernetes RBAC 的 list/watch分开验证修改权限和观察权限
镜像推送成功,应用不能启动架构、入口命令、运行配置、拉取身份核对 manifest、节点和容器事件
冒烟只显示超时Job 是否已失败、接口是否返回 4xx/5xx同时观察 Complete/Failed,保留实际失败响应与日志
MR 合并接口返回 405MR 的 detailed_merge_status、冲突、分支是否落后解决具体合并条件,不把所有 405 都归因于权限
流水线绿了但环境不是预期版本运行版本、发布记录、并发顺序对比 digest 与业务验收结果,检查是否有其他发布通道覆盖

仓库从其他托管平台迁入时,还要处理 LFS、子模块和同步方向。镜像迁移可先传 LFS 对象,再推分支、标签等业务 refs;不要把平台内部 refs 当作普通分支推送。切换主仓库后停止旧平台向新主分支写入,避免同步覆盖新提交或触发重复发布。Git 镜像也不会自动迁移 Issue、MR、权限和 CI 变量。

一项能力应区分“方案已设计、配置已提交、流水线已通过、真实环境已验收”。每个服务、每个环境分别记录以下达成标志:

  • 普通成员能够提交 MR,维护者合并后触发正确环境,普通分支拿不到发布身份。
  • 测试通过才构建,构建失败不部署;运行版本能追溯到提交、流水线和不可变产物。
  • 验收能发现接口和依赖异常,而不只检查 Pod 或首页。
  • 测试、构建和部署失败都有结果通知,通知系统故障不篡改业务结果。
  • 回滚实际恢复发布前版本,再次发布实际复用本次产物;过期操作被拒绝。
  • 换一个具有正常权限的维护者仍能完成流程,流水线不依赖某个人本地登录。
  • 备份恢复、制品保留、缓存清理与资源容量有明确维护方式。

GitLab CI/CD 的完成点不是 YAML 能解析,也不是出现一个绿色作业,而是代码、制品、运行状态和发布记录能够相互核对。更多 Git 协作背景见 协作流程,静态站点的路径、缓存和路由问题见 前端部署。

别急,先让缓存热一下。