把 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 过的分支名。这么做的原因:
- 分支名最长可以到 250 字符左右,但 K8s namespace 的 DNS label 上限是 63 字符,必须截断;
- 两个不同分支 sanitize 后可能撞名(比如
feat/foo和feat_foo),加 hash 前缀彻底避免; - 从 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——这时有两个选择:
- 把通配符证书的 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之类的工具自动同步); - 给 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
“用完就删”听起来简单,实际执行时会遇到三类漏网之鱼:
- CI job 失败导致
on_stop没触发; - 开发者直接关掉 MR 没走合并流程,GitLab 的
on_stop对于 close 事件默认不触发(需要额外配置); - 人为在集群里手动建的 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/postgresql 或 zalando/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 管理工具里同步,但那是另一个话题了。