“K8s 上的证书自动续期”看起来早已被 cert-manager 解决。但在一个真实的 GKE 生产项目里,我们最终没有用它,而是用一个 CronJob 加一个 Shell 脚本(acme.sh + DNS-01)完成。本文拆解这套方案的设计,并重点讲其中最容易踩坑、资料又散的一点:当被签发域名的 DNS 不在你手里时,如何用 DNS alias mode 完成 DNS-01 验证。
文中项目 ID、集群名、域名、邮箱均已脱敏为占位符,代码为生产脚本的节选。
背景与约束
选择自研而非 cert-manager,源于四个约束:
- 签发对象分散在多个命名空间:业务按环境和租户拆成十几个 namespace,每个都需要自己的
kubernetes.io/tlsSecret。 - 入口是 Kong:证书更新后需要一次 rollout,才能确保新证书被稳定加载。
- 部分域名的 DNS 在别的 GCP 项目或别的团队手里,拿不到该 zone 的写权限。
- 不想为每天跑一次的任务常驻 controller 和一批 CRD(Certificate、Issuer、Order、Challenge 等)。
最终形态:一个 CronJob,一个脚本,把结果直接写进各 namespace 的 Secret。
整体架构
flowchart TD
A[CronJob 每日 02:00] --> B[acme-renew.sh]
B --> C[读取 domains.list]
C --> D{Secret 证书<br/>剩余 ≤ 15 天?}
D -- 否 --> E[跳过]
D -- 是 --> F[acme.sh DNS-01 签发]
F --> G[Cloud DNS<br/>写 TXT 记录]
F --> H[kubectl 写 TLS Secret]
H --> I[有更新才 rollout<br/>ingress-kong]
几个关键决策:
| 决策 | 理由 |
|---|---|
| 以 Secret 里的证书判定到期 | Job 的 Pod 是一次性的,本地状态不可靠;Secret 才是网关真正消费的东西 |
| 不挂 PVC,完全无状态 | 判定依据在集群里,Pod 本地目录只是签发的临时空间 |
| 一个 CronJob 处理多个域名 | 配置放 ConfigMap,加域名只改一行 |
| 有更新才重启网关 | 避免每天无意义地滚动线上入口 |
| 用 DNS-01 而非 HTTP-01 | 支持泛域名,不依赖域名当下是否可达,入口不用为 /.well-known 开特例 |
配置格式
domains.list 以 ConfigMap 挂载,一行一个域名,第五列可选:
# 格式:domain email namespace secret_name [alias_domain]
api.example.com ops@example.com prod-a api-tls
console.example.com ops@example.com prod-b console-tls
portal.partner.com ops@example.com prod-c portal-tls portal-validation.example.com
解析只需一个 read:
while IFS= read -r line; do
[[ -z "$line" || "$line" =~ ^# ]] && continue # 跳过空行和注释
local domain email namespace secret_name alias_domain
read -r domain email namespace secret_name alias_domain <<< "$line"
if renew_certificate "$domain" "$email" "$namespace" "$secret_name" "$alias_domain"; then
log "domain $domain process success"
else
log "domain $domain process failed" # 单个域名失败不中断循环
fi
done < "$domains_file"
脚本开了 set -u,可选参数缺省时必须保证变量已定义:这里由 read 保证,其他地方要写成 ${alias_domain:-}。
到期判定
最直觉的做法是让 acme.sh 自己续期(acme.sh --cron),但它的状态存放在 ~/.acme.sh,而 Job 的 Pod 每次都是全新的。要么挂 PVC 引入有状态依赖,要么每次强制签发撞上速率限制。
我们选第三条路:把集群里的 Secret 当作唯一事实来源。
if kubectl get secret "$secret_name" -n "$namespace" >/dev/null 2>&1; then
cert_data=$(kubectl get secret "$secret_name" -n "$namespace" \
-o jsonpath='{.data.tls\.crt}' | base64 -d)
if [[ -n "$cert_data" ]]; then
tmp=$(mktemp); echo "$cert_data" > "$tmp"
expiry=$(openssl x509 -in "$tmp" -noout -enddate | cut -d= -f2)
rm -f "$tmp"
days=$(( ($(date -d "$expiry" +%s) - $(date +%s)) / 86400 ))
if [[ $days -gt 15 ]]; then
log "valid for $days days, skip"
return 0
fi
fi
fi
Alpine 的 date 陷阱
BusyBox 的 date -d 解析不了 openssl 输出的 Mar 12 08:23:41 2026 GMT。Dockerfile 里需要显式安装 coreutils:
RUN apk add --no-cache bash curl openssl jq coreutils python3 py3-pip
漏装的后果很隐蔽:脚本不报错,只算出空值,走进“读不到过期时间,重新申请”的分支,每天强制签发一次,很快触发 Let’s Encrypt 的速率限制。
阈值与 force
阈值取 15 天:证书有效期 90 天,官方建议 30 天左右续期;我们上游另有 7 天告警,15 天留出 8 天容错窗口,同时缩小判定出 bug 时每日强制签发的影响面。
判定与签发是分离的,所以签发时带 --force,不再让 acme.sh 用它那份(容器里不存在的)状态判断一次。代价是:速率限制的防线完全交给了前面的到期判定。同一组域名的重复证书限制是每周 5 张,判定一旦失效,几天就能耗光配额,所以判定逻辑的每个失败分支都要按高优先级对待。
DNS alias 原理
这是本文最值得写的部分。
无权限的场景
DNS-01 要求在 _acme-challenge.<域名> 上写一条 TXT 记录,也就是对被签发域名的 zone 有写权限。现实中常常不成立:
- 域名 DNS 托管在客户或合作方那里,如
partner.com。 - 服务商没有 API,或 API 无法自动化。
- 跨账号、跨项目,为一张证书打通 IAM 不划算。
- 安全收敛:给一个 CronJob 发主 zone 的
roles/dns.admin,等于交出了改任意 A 记录的能力,安全评审第一个就会卡住。
第四条是我们真正的驱动力。
CNAME 委派
CA 校验 _acme-challenge.<域名> 时会跟随 CNAME 链,协议允许这种行为。于是在被签发域名的 zone 里一次性配置:
_acme-challenge.portal.partner.com. CNAME portal-validation.example.com.
这条记录配完即永久有效。之后:
- 只需对
example.com这个专门放验证记录的 zone 有写权限; - acme.sh 把 TXT 写到
example.com一侧; - CA 查询
_acme-challenge.portal.partner.com,沿 CNAME 找到 TXT,校验通过。
sequenceDiagram
participant S as acme.sh
participant V as 验证 zone
participant CA as Let's Encrypt
participant P as 业务 zone
S->>V: 写入 TXT
CA->>P: 查 _acme-challenge TXT
P-->>CA: CNAME 指向验证 zone
CA->>V: 跟随查询 TXT
V-->>CA: 返回 TXT 值
全程不需要对 partner.com 有任何 API 权限。再配合“只放验证记录的独立 zone”,即使凭据泄露,攻击者也只能改一堆没有业务价值的 TXT,劫持不了真实流量。
参数陷阱
acme.sh 提供两个名字相近的参数,我们第一版用错了,签发一直失败:
区别只有一点:TXT 记录名里的 _acme-challenge. 前缀由谁来补。
| 参数 | TXT 写入位置 | CNAME 应指向 |
|---|---|---|
--challenge-alias X |
_acme-challenge.X(acme.sh 自动补前缀) |
_acme-challenge.X |
--domain-alias X |
X(原样使用,不补前缀) |
X |
CNAME 的目标必须与 acme.sh 实际写入 TXT 的名字严格一致。我们的情况是:对方配的 CNAME 目标是不带前缀的完整记录名 portal-validation.example.com,而第一版用了 --challenge-alias,TXT 被写到 _acme-challenge.portal-validation.example.com。CA 顺着 CNAME 查的是没有前缀的那个名字,得到 NXDOMAIN,日志里只有 Invalid status, Verify error,没有任何“写错位置”的提示。
改成 --domain-alias 后一次通过,提交只有一行:
- acme_cmd="$acme_cmd --challenge-alias $alias_domain"
+ acme_cmd="$acme_cmd --domain-alias $alias_domain"
这个行为可以在 acme.sh 源码里确认:--domain-alias 会给值加上“原样使用”的标记,--challenge-alias 则在拼接时补上 _acme-challenge.。不同版本的行为以实际为准,不要凭记忆选参数,用下面的方法验证。
定位方法
签发过程中,直接查 TXT 到底写到了哪里:
# 1. 确认委派链本身是通的
dig +short CNAME _acme-challenge.portal.partner.com
# → portal-validation.example.com.
# 2. 让 acme.sh 运行,在它等待 DNS 生效时另开终端查两个候选名
dig +short TXT portal-validation.example.com
dig +short TXT _acme-challenge.portal-validation.example.com
# 3. 模拟 CA 的完整查询路径(跟随 CNAME)
dig +trace TXT _acme-challenge.portal.partner.com
第 2 步中有值的那个名字,就是 acme.sh 实际使用的写入位置;它与第 1 步 CNAME 的目标不一致,就是参数选错了。第 3 步最贴近真相:如果 dig +trace 拿不到 TXT,CA 也拿不到。
落到脚本里
alias 域名是配置的可选第五列,存在就加参数,否则走原路径:
renew_certificate() {
local domain="$1" email="$2" namespace="$3" secret_name="$4"
local alias_domain="${5:-}"
# ... 到期判定 ...
local acme_args=(--issue -d "$domain" --dns dns_gcloud --force)
if [[ -n "$alias_domain" ]]; then
log "Using DNS alias mode: $alias_domain"
acme_args+=(--domain-alias "$alias_domain")
fi
acme.sh "${acme_args[@]}" || { log "issue failed: $domain"; return 1; }
update_k8s_secret "$domain" "$namespace" "$secret_name"
}
用数组而不是 eval 拼命令,可以避免配置值里的特殊字符带来注入风险。同一个 CronJob 因此能混合两类域名:自己 zone 里的走常规 DNS-01,别人 zone 里的走 alias,配置文件一眼可辨。
实践建议
- CNAME 目标一律写 FQDN(带结尾点)。很多 DNS 控制台会把不带点的名字当相对名,补成
portal-validation.example.com.partner.com,错误极其隐蔽。 - 一个域名一个 alias 目标。多个域名共用同一目标,并发签发时 TXT 会互相覆盖。本方案是串行循环,仍建议分开。
- 泛域名的约束不变。
*.example.com与裸域example.com使用同一个 challenge 记录名,两者都要签时需要同时存在两条 TXT,DNS 插件必须支持追加;alias 只是把这个约束转移到了验证 zone 上。 - 委派配好即可验证。
dig +short CNAME _acme-challenge.<域名>有返回就是通的,让对方配 CNAME 时把这条命令一起发过去当验收标准。
写回 Secret
必须用 fullchain
只把叶子证书写进 Secret 是一个高频线上事故:浏览器能正常访问(会补全中间证书),而 curl、Java 客户端、移动端 SDK 全部报 unable to get local issuer certificate。脚本做了三级降级:
cert_dir="$ACME_HOME/${domain}_ecc" # ECC 证书目录带 _ecc 后缀
if [[ -f "$cert_dir/fullchain.cer" ]]; then
final_cert="$cert_dir/fullchain.cer"
elif [[ -f "$cert_dir/ca.cer" ]]; then
final_cert=$(mktemp)
cat "$cert_dir/$domain.cer" "$cert_dir/ca.cer" > "$final_cert" # 手动拼链
else
log "WARN: no intermediate cert, chain verification may fail"
final_cert="$cert_dir/$domain.cer"
fi
注意目录是 ${domain}_ecc:新版 acme.sh 默认签 ECC 证书,路径与 RSA 不同。硬编码了无后缀路径的脚本,升级后会突然找不到文件。
幂等写入
kubectl create secret tls "$secret_name" -n "$namespace" \
--cert="$final_cert" --key="$cert_dir/$domain.key" \
--dry-run=client -o yaml | kubectl apply -f -
create --dry-run | apply 是“创建或更新”最干净的写法:不用先判断存在,也不用 delete 再 create,没有证书缺失的窗口期。
有更新才重启网关
Kong 理论上会感知 Secret 变更,但我们观察到边缘情况下新证书不能稳定生效,所以补了显式 rollout,并加了开关:
# update_k8s_secret 成功时置位 SECRETS_UPDATED=1
if [[ "$SECRETS_UPDATED" -eq 1 ]]; then
kubectl rollout restart deployment/ingress-kong -n kong
else
log "No secrets updated, skip restart"
fi
绝大多数日子证书都还有几十天有效期,不会有任何更新,网关也就不会被碰。每天凌晨无条件重启生产入口,是“平时没事,出事就是大事”的设计。
权限与调度
RBAC 与 IAM
K8s 侧用 ClusterRole(要跨 namespace 写 Secret):
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "patch"] # rollout restart 本质是 patch Pod 模板注解
没有 delete:脚本永远不删 Secret。即使逻辑出错,最坏是证书没更新(有告警),而不是证书被删导致 502。deployments.apps is forbidden 是我们第一版漏掉 patch 时的报错。
GCP 侧,服务账号只需 roles/dns.admin,并且只授予验证 zone 所在的项目,业务域名所在项目完全不碰。这就是 alias mode 在权限上的落点。
脚本启动时认证并显式指定 CA:
export GOOGLE_APPLICATION_CREDENTIALS="$GCP_DNS_KEY"
setup_gcp_dns() {
gcloud auth activate-service-account --key-file="$GCP_DNS_KEY"
gcloud config set project "$GCP_PROJECT"
acme.sh --set-default-ca --server letsencrypt # v3 起默认 CA 为 ZeroSSL
acme.sh --register-account -m "$DEFAULT_EMAIL"
}
--set-default-ca 不能省:默认 CA 是 ZeroSSL,需要 EAB 凭据,不指定会在毫不相干的地方报注册失败。
CronJob 参数
spec:
schedule: "0 2 * * *"
startingDeadlineSeconds: 300 # 错过窗口超过 5 分钟不补跑
concurrencyPolicy: Forbid # 上一次没跑完,这一次不启动
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
concurrencyPolicy: Forbid 必须设置。签发有 DNS 传播等待,某次卡住后若并发进入,会同时操作同一批 TXT 记录和同一个 Secret,结果不可预测。
CI 用 GitHub Actions,workflow_dispatch 手动触发、按环境选择凭据,部署只换镜像 tag:
kubectl set image cronjob/acme-cert-renewer \
acme-renewer=<REGION>-docker.pkg.dev/$PROJECT_ID/<REPO>/<IMAGE>:$IMAGE_TAG \
-n ssl-renew
只改镜像不重建 CronJob,不影响正在运行的 Job。
踩坑清单
| 现象 | 根因 | 解法 |
|---|---|---|
Invalid status, Verify error,TXT 已写入 |
alias 参数选错,TXT 写到了 CNAME 目标之外 | dig +trace 看 CA 视角,对齐参数 |
| 每天强制签发,一周后被限流 | BusyBox date -d 解析失败,判定恒失败 |
安装 coreutils |
| 浏览器正常,curl / Java 报链错误 | Secret 里只有叶子证书 | 用 fullchain.cer 或手动拼链 |
| 升级 acme.sh 后找不到证书文件 | 默认 ECC,目录带 _ecc |
路径加后缀,或用 --install-cert 指定输出 |
| 注册账号报 EAB 错误 | v3 起默认 CA 是 ZeroSSL | --set-default-ca --server letsencrypt |
deployments.apps is forbidden |
RBAC 缺 patch |
补权限 |
| CNAME 查出奇怪的长域名 | 目标没写结尾点,被补全 | 一律用 FQDN |
已知欠债
这套方案目前有几个明确的问题:
error()里exit 0:本意是避免 CronJob 因单个域名问题反复重试,副作用是失败在 K8s 层面看起来是成功的,kubectl get jobs永远是绿的。更合理的做法:单域名失败不中断循环(已做到),但整体有失败时以非零码退出,再用backoffLimit控制重试次数。- 没有主动告警:目前依赖外部的证书过期监控兜底。更好的是脚本结束时推送成功/失败数,而不是等证书快过期才被动发现。
--force没有兜底:速率限制的唯一防线是到期判定。可以在 Secret 上打last-attempt注解,同一域名当日尝试超过 N 次就跳过。
什么时候选 cert-manager
大部分团队应该直接用 cert-manager:CRD 模型、状态机、事件、社区支持都很完善,并且它同样支持 DNS-01 的 CNAME 委派(在 Issuer 里配置 cnameStrategy: Follow,配合独立的 solver zone,效果与本文相同)。
自研的合理场景是:
- 域名数量不多(十几到几十个),但分布在很多 namespace;
- 入口组件需要额外的自定义动作(如这里的 rollout Kong);
- 团队对 Shell + kubectl 的调试成本远低于理解 Order/Challenge 状态机;
- 希望整个流程能在本地一行命令复现,出问题时看
kubectl logs就能读懂全部逻辑。
总结
本文的核心收获是:DNS alias mode 是让“自动化签发”和“最小权限”同时成立的方式。哪怕用的是 cert-manager,也值得花半小时把验证记录委派到一个独立的、没有业务价值的 zone 里。真正高权限的那把钥匙,不该挂在一个每天跑一次的定时任务上。
延伸阅读:
- acme.sh Wiki:DNS alias mode
- cert-manager 文档:DNS01 的 CNAME 委派(
cnameStrategy) - Let’s Encrypt:Rate Limits