把 Git 分支名写进 K8s namespace,每个 PR 自动拉起一套独立测试环境,用完就删

分支名进 namespace 不是新玩法,但要做到“PR 创建即部署、合并即回收、全程不手抖”,关键在三个环节的咬合:CI 里的命名规则、集群侧的资源配额与清理策略、以及一套能扛住并发 PR 的 Ingress 路由方案。下面按我实际跑通的流程拆开讲。

先定命名契约:namespace 名字必须可逆推

别用随机后缀。随机后缀意味着你没法从 namespace 反推分支,也没法在清理时做精确匹配。我用的是这个规则:

分支名: feat/user-login-optimize
namespace: pr-6f8a2c1d-feat-user-login-optimize

前 8 位是分支名的 SHA-1 前 8 位(小写),后面接 sanitize 过的分支名。这么做的原因:

  1. 分支名最长可以到 250 字符左右,但 K8s namespace 的 DNS label 上限是 63 字符,必须截断;
  2. 两个不同分支 sanitize 后可能撞名(比如 feat/foofeat_foo),加 hash 前缀彻底避免;
  3. 从 namespace 能直接看出是哪个分支,排障时不用查 CI 变量。

sanitize 规则我固定为:[^a-z0-9-] 全部替换为 -,连续 - 合并成一个,首尾 - 去掉,最后整体截断到 54 字符(63 减去 pr- 加 8 位 hash 再加一个连字符的开销)。GitLab CI 里这样写:

sanitize_branch:
  stage: .pre
  script:
    - export BRANCH_SLUG=$(echo "$CI_COMMIT_REF_SLUG" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/-\+/-/g; s/^-//; s/-$//' | cut -c1-54)
    - export BRANCH_HASH=$(echo -n "$CI_COMMIT_REF_NAME" | sha1sum | cut -c1-8)
    - export NS_NAME="pr-${BRANCH_HASH}-${BRANCH_SLUG}"
    - echo "NS_NAME=$NS_NAME" >> build.env
  artifacts:
    reports:
      dotenv: build.env

GitHub Actions 同理,把 CI_COMMIT_REF_SLUG 换成 github.head_ref 自己处理一遍。注意 CI_COMMIT_REF_SLUG 本身已经做了部分 sanitize,但它的规则和 K8s 的 DNS label 规则不完全一致(比如它允许下划线),所以上面又做了一遍 sed

部署侧:一个 Helm chart 搞定整栈

每个 PR 环境不应该只部署一个 Deployment,通常需要完整的应用栈:主服务、可能的 worker、依赖的中间件(Redis/Postgres 测试实例)、ConfigMap、Secret。把这些打包成一个 Helm chart,namespace 作为 release 级别隔离。

我的 chart 结构大致是:

# values-pr.yaml 由 CI 动态生成
namespace: pr-6f8a2c1d-feat-user-login-optimize
image:
  tag: pr-1234
ingress:
  host: pr-6f8a2c1d-feat-user-login-optimize.dev.internal.example.com
resources:
  requests:
    cpu: 100m
    memory: 256Mi
  limits:
    cpu: 500m
    memory: 512Mi

CI 里的部署步骤:

deploy_pr_env:
  stage: deploy
  script:
    - helm upgrade --install "$NS_NAME" ./chart
        --namespace "$NS_NAME"
        --create-namespace
        --set namespace="$NS_NAME"
        --set image.tag="pr-${CI_MERGE_REQUEST_IID}"
        --set ingress.host="${NS_NAME}.dev.internal.example.com"
        --set resources.requests.cpu=100m
        --set resources.requests.memory=256Mi
        --set resources.limits.cpu=500m
        --set resources.limits.memory=512Mi
        --wait
        --timeout 5m
  environment:
    name: pr-$CI_MERGE_REQUEST_IID
    url: https://${NS_NAME}.dev.internal.example.com
    on_stop: cleanup_pr_env

几个要点:

  • --create-namespace 放在 --namespace 之后,Helm 3 会先建 namespace 再装 release;
  • 资源限制必须写死,不能让 chart 里的默认值透传,否则某个 PR 环境可能吃光节点资源;
  • environment.name 关联 GitLab 的 Environments 面板,on_stop 指向清理 job,这样在 UI 上可以直接手动停止环境,不一定要等合并。

Ingress 与证书:别为每个 namespace 单独申请证书

如果每个 PR 环境都单独走一次 Let's Encrypt 的 HTTP-01 验证,几百个 PR 下来不仅慢,还可能撞上速率限制。我的做法是:只申请一张通配符证书 *.dev.internal.example.com,所有 PR 环境的 Ingress 都引用同一个 Secret。

假设集群里已经有一个 cert-manager 管理的 ClusterIssuer,通配符证书用 DNS-01 验证:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-dev-internal
  namespace: ingress-nginx
spec:
  secretName: wildcard-dev-internal-tls
  issuerRef:
    name: letsencrypt-dns
    kind: ClusterIssuer
  dnsNames:
    - "*.dev.internal.example.com"
    - "dev.internal.example.com"

然后 PR 环境的 Ingress 模板里:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ .Release.Name }}-ingress
  namespace: {{ .Values.namespace }}
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - {{ .Values.ingress.host }}
      secretName: wildcard-dev-internal-tls
  rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ .Release.Name }}-svc
                port:
                  number: 8080

注意 secretName 指向的是 ingress-nginx namespace 里的 Secret,但 Ingress 在 PR namespace 里。如果你用的是 NGINX Ingress Controller,它默认只从 Ingress 所在的 namespace 找 Secret——这时有两个选择:

  1. 把通配符证书的 Secret 复制到每个 PR namespace(用 kubectl get secret -n ingress-nginx wildcard-dev-internal-tls -o yaml | sed 's/namespace: .*/namespace: <pr-ns>/' | kubectl apply -f -,或者用 reflector 之类的工具自动同步);
  2. 给 NGINX Ingress Controller 加 --watch-namespace 之外还要配置 --default-ssl-certificate=ingress-nginx/wildcard-dev-internal-tls,让所有 TLS 都回落到这个默认证书。这个方法最简单,但有一个限制:default-ssl-certificate 只对没有显式 tls.secretName 的 Ingress 生效,所以 Ingress 里要么不写 tls 字段,要么写了但 secret 不存在时它才回落。实测中,直接省略 tls 字段、靠 --default-ssl-certificate 挂证书,配合 ssl-redirect 注解,体验最顺。

我选的是方案 2 的变体:Ingress 里不写 tls,由 Controller 的 default-ssl-certificate 统一处理。这样 chart 更简单,也不用维护 Secret 同步。

清理策略:双保险,不能只靠 CI

“用完就删”听起来简单,实际执行时会遇到三类漏网之鱼:

  1. CI job 失败导致 on_stop 没触发;
  2. 开发者直接关掉 MR 没走合并流程,GitLab 的 on_stop 对于 close 事件默认不触发(需要额外配置);
  3. 人为在集群里手动建的 namespace,CI 管不到。

我的做法是分层清理

第一层:CI 的 on_stop job,处理正常合并/手动停止:

cleanup_pr_env:
  stage: deploy
  when: manual
  environment:
    name: pr-$CI_MERGE_REQUEST_IID
    action: stop
  script:
    - helm uninstall "$NS_NAME" --namespace "$NS_NAME"
    - kubectl delete namespace "$NS_NAME" --ignore-not-found=true

helm uninstall 之后再 kubectl delete namespace 是双保险,因为 Helm 有时候会留下 namespace 里的残留资源(比如 PVC 没被 chart 管理到)。

第二层:定时 GC job,每天跑一次,扫掉所有超过 48 小时没有更新的 PR namespace。判定依据是 namespace 上的 annotation,我在创建时写入时间戳和 MR 链接:

# 在部署 job 里额外执行
kubectl annotate namespace "$NS_NAME" \
  "pr.gitlab.com/created-at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  "pr.gitlab.com/mr-url=${CI_MERGE_REQUEST_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}" \
  --overwrite

GC 脚本(放在一个 CronJob 里,或者直接在 CI 的 scheduled pipeline 里跑):

# !/bin/bash
cutoff=$(date -u -d '48 hours ago' +%Y-%m-%dT%H:%M:%SZ)
for ns in $(kubectl get ns -l 'pr.gitlab.com/managed=true' -o jsonpath='{.items[*].metadata.name}'); do
  created=$(kubectl get ns "$ns" -o jsonpath='{.metadata.annotations.pr\.gitlab\.com/created-at}' 2>/dev/null || echo "")
  if [[ -n "$created" && "$created" < "$cutoff" ]]; then
    echo "GC namespace $ns (created $created)"
    helm uninstall "$ns" --namespace "$ns" 2>/dev/null || true
    kubectl delete namespace "$ns" --ignore-not-found=true
  fi
done

这里给 namespace 打了一个 pr.gitlab.com/managed=true 的 label,GC 只扫带这个 label 的 namespace,避免误删。这个 label 必须在创建 namespace 时打上,否则 GC 看不见它:

kubectl create namespace "$NS_NAME" --dry-run=client -o yaml | \
  kubectl label -f - "pr.gitlab.com/managed=true" --dry-run=client -o yaml | \
  kubectl apply -f -

或者让 Helm 在 --create-namespace 时通过 namespaceMetadata 注入(Helm 3.14+ 支持)。我用的是手动创建 namespace 再 helm install 的方式,因为这样能完全控制 namespace 的 label 和 annotation,而且避免 Helm 对 namespace 的“懒删除”行为(Helm 创建的 namespace 在 helm uninstall 后不会自动删除,需要额外处理)。

第三层:ResourceQuota 兜底。即使 GC 偶尔没跑,单个 namespace 的资源也被限制住了,不会拖垮整个集群:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: pr-quota
  namespace: {{ .Values.namespace }}
spec:
  hard:
    requests.cpu: "2"
    requests.memory: 4Gi
    limits.cpu: "4"
    limits.memory: 8Gi
    persistentvolumeclaims: "5"
    pods: "20"

这个 ResourceQuota 是 chart 的一部分,随 PR 环境一起部署。

并发与冲突:同一个分支重复 push

开发者在一个 MR 上连续 push 两次,会触发两个 pipeline 同时跑。如果两个 pipeline 都尝试 helm upgrade 同一个 release,Helm 的锁机制(helm upgrade --atomic 会等待前面的操作完成)一般能处理,但更稳妥的做法是:同一个 MR 的 pipeline 用 interruptible: true 标记,新 push 直接取消旧的 pipeline。

另外,如果两个 MR 分支名恰好 hash 前 8 位相同(概率约 1/4^8,实际项目规模下几乎不会发生),namespace 名会冲突。但为了严谨,我在部署前检查一下:

existing_ns=$(kubectl get ns "$NS_NAME" --ignore-not-found=true -o name)
if [[ -n "$existing_ns" ]]; then
  # 检查 annotation 里的 MR URL 是否匹配当前 MR
  existing_mr=$(kubectl get ns "$NS_NAME" -o jsonpath='{.metadata.annotations.pr\.gitlab\.com/mr-url}' 2>/dev/null || echo "")
  if [[ "$existing_mr" != "${CI_MERGE_REQUEST_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}" ]]; then
    echo "Namespace $NS_NAME already exists for a different MR ($existing_mr), aborting"
    exit 1
  fi
fi

这种情况在 SHA-1 前 8 位碰撞时才会触发,概率极低,但检查一下不费事。

数据库与有状态依赖

每个 PR 环境如果都连同一个开发数据库,数据会互相污染。我的方案是:每个 PR namespace 内起一个独立的 Postgres 实例,用 bitnami/postgresqlzalando/postgres-operator 的轻量模式,storageClass 用集群的临时存储(或直接 emptyDir,因为 PR 环境的数据不需要持久化)。

postgresql:
  enabled: true
  auth:
    database: app_test
    username: app
    password: pr-test-only
  primary:
    persistence:
      enabled: false
    resources:
      requests:
        cpu: 50m
        memory: 128Mi

persistence.enabled: false 意味着 Pod 重启数据就没了,但对 PR 测试环境完全够用。如果 PR 环境需要跑迁移(migration),在部署后的 job 里执行:

kubectl exec -n "$NS_NAME" deploy/app -- ./manage.py migrate

或者直接在应用容器的启动命令里做迁移,看团队习惯。

常见问题

问:为什么不用 vCluster 或者 Namespace-as-a-Service 之类的方案?

vCluster 解决的是“多租户隔离”的问题,它本身也跑在一个 namespace 里,相当于套了一层。如果你的 PR 环境需要更严格的隔离(比如独立的 CRD、独立的 API server 视图),vCluster 有意义。但对大多数应用来说,单 namespace + ResourceQuota + NetworkPolicy 已经足够,引入 vCluster 会增加一层运维复杂度,而且回收时要同时清理 vCluster 和底层 namespace,步骤更多。

问:PR 环境里的应用需要访问集群外的服务(比如公司内部 API),怎么处理?

用 NetworkPolicy 控制 egress,或者在应用配置里注入一个“测试模式”的环境变量,让应用走 mock 服务。更简单的做法是:给 PR namespace 打上 egress: allow-internal 的 label,NetworkPolicy 根据这个 label 放行到特定 CIDR 的流量。不要在 PR 环境里硬编码生产依赖的地址,否则 PR 环境可能意外打到生产服务。

问:如果 PR 环境部署失败,namespace 会残留吗?

会。Helm 用 --wait 时如果超时,release 会处于 failed 状态,但 namespace 和部分资源已经建好了。所以我的部署 job 里有一个 after_script,判断如果部署失败就立即执行清理:

after_script:
  - |
    if [[ "$CI_JOB_STATUS" == "failed" ]]; then
      helm uninstall "$NS_NAME" --namespace "$NS_NAME" 2>/dev/null || true
      kubectl delete namespace "$NS_NAME" --ignore-not-found=true
    fi

这样失败的部署不会留下半成品 namespace。但要注意 after_script 里能用到的变量有限,$NS_NAME 需要提前写入文件再读出来。

问:通配符证书方案在有多集群的情况下怎么管理?

每个集群单独用 cert-manager 申请自己的通配符证书,Secret 名保持一致(比如都叫 wildcard-dev-internal-tls),Ingress 模板里引用同一个名字。这样 chart 在不同集群间可移植,不用改配置。如果集群很多,可以考虑把证书 Secret 放到一个外部 Secret 管理工具里同步,但那是另一个话题了。