Xiaojun 的技术笔记

GKE 证书自动续期:DNS alias 实践

网络约 8 分钟读完
目录

“K8s 上的证书自动续期”看起来早已被 cert-manager 解决。但在一个真实的 GKE 生产项目里,我们最终没有用它,而是用一个 CronJob 加一个 Shell 脚本(acme.sh + DNS-01)完成。本文拆解这套方案的设计,并重点讲其中最容易踩坑、资料又散的一点:当被签发域名的 DNS 不在你手里时,如何用 DNS alias mode 完成 DNS-01 验证。

文中项目 ID、集群名、域名、邮箱均已脱敏为占位符,代码为生产脚本的节选。

背景与约束

选择自研而非 cert-manager,源于四个约束:

  1. 签发对象分散在多个命名空间:业务按环境和租户拆成十几个 namespace,每个都需要自己的 kubernetes.io/tls Secret。
  2. 入口是 Kong:证书更新后需要一次 rollout,才能确保新证书被稳定加载。
  3. 部分域名的 DNS 在别的 GCP 项目或别的团队手里,拿不到该 zone 的写权限。
  4. 不想为每天跑一次的任务常驻 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      [email protected]  prod-a  api-tls
console.example.com  [email protected]  prod-b  console-tls
portal.partner.com   [email protected]  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