给 ArgoCD 换 TLS 入口:Cilium Gateway、TLSRoute 与一个绕不过的 ALPN 坑
起因很小:argocd login 在公网入口上报 500,同时我还在纠结「新版 CLI 每次都要带 --grpc-web,能不能干脆用原生 gRPC」。
结果这条线一路往下钻,穿过了 Pangolin、Traefik、newt、Cilium Gateway、cert-manager 和 ArgoCD 自己的启动参数,最后得到的结论比预想的更硬:只要链路上有一个 L7 反向代理,ArgoCD 就给不了你原生 gRPC。这跟开不开 TLS 无关,跟 ArgoCD 的协议实现有关。
这篇按真实过程写:先讲清楚环境、再逐个拆坑、然后是 TLS 与「不用 HTTP」两套操作步骤、最后是回滚和取舍。代码与清单都来自我自己的集群仓库,路径在文末。
先把环境说清楚
这套东西不是标准 k8s 发行版自带的,读之前需要知道每一层是什么、谁管 TLS。
| 层 | 组件 | 在这件事里的角色 |
|---|---|---|
| 公网入口 | Pangolin(fosrl/pangolin + Traefik v3) | 按域名建「资源」;HTTP 资源会被它解密再重新发起,TCP 资源只做字节转发 |
| 隧道 | newt(跑在集群里的一个 Pod,pangolin 命名空间) | 把 Traefik 的连接接到集群内 Service;日志里能看到 Started tcp proxy to <host>:<port> |
| 集群入口 | Cilium Gateway API(gatewayClassName: cilium) | 共享网关 cilium-gateway 终结 TLS;L4 场景需要独立 Gateway 做 TLS/Passthrough |
| 证书 | cert-manager(ClusterIssuer global-ca-issuer,根 CA my-global-root-ca) | 给 ArgoCD 签服务端证书,写进 Secret |
| 被访问方 | ArgoCD(官方 install.yaml,单副本) | argocd-server 默认带 --insecure,此时 8080 只讲 HTTP/1.1 |
三条入口最终长这样——注意中间那一段「谁在重新发起 TLS」是全文的主线:
其中「Cilium Gateway」那一格只出现在 UI 那条路上:它是 TLS/Passthrough 的 listener,按 SNI 把 TLS 字节转给 argocd-server,全程不解密。
坑一:Pangolin 的资源认证只认浏览器
第一个症状是 CLI 报一个很难读的错:
rpc error: code = Unknown desc = unexpected HTTP status code received from server: 302 (Found);malformed header: missing HTTP content-type而同一个域名在浏览器里打开是正常的。
去 node1 翻 Traefik 的访问日志,能看到这条请求的原始去向:
{"RequestHost":"argocd-api.apikv.com","RequestPath":"/session.SessionService/Create", "DownstreamStatus":302,"OriginStatus":0,"RouterName":"71-argocd-api-router@http"}OriginStatus: 0 意味着请求根本没到后端,302 是 Pangolin 自己发的,跳转目标是 pangolin.apikv.com/auth/resource/<id>。
原因:这个资源开了 Resource Auth。浏览器有 Pangolin 的会话 Cookie,所以能过;CLI 没有 Cookie、也不会跟着 302 去登录页,于是 gRPC 客户端拿到一个无法解析的响应头。
后来我把该资源的认证关掉了(只保留来源 IP 规则),302 消失。
坑二:h2c 上游让 Traefik 返回 500
认证关掉之后,同样的请求变成了 500。响应体只有 21 字节:
HTTP/2 500content-length: 21
Internal Server Error21 字节正好是 Traefik 的默认错误页。而ArgoCD 服务端日志里一条对应请求都没有——这很关键:说明问题不在后端。
叛定过程分三步:
# 1) Pod 内部直连,证明服务端是好的(返回 401 而不是 500)kubectl -n argocd exec deploy/argocd-server -- \ argocd login localhost:8080 --username admin --password x --plaintext --insecure
# 2) 集群内走 Service,HTTP/1.1 正常,h2c 直接被 resetkubectl -n argocd exec <probe> -- curl -s -o /dev/null -w '%{http_code}\n' \ -X POST http://argocd-server/api/v1/session -H 'content-type: application/json' -d '{}' # 401kubectl -n argocd exec <probe> -- curl -s -o /dev/null -w '%{http_code}\n' --http2-prior-knowledge \ -X POST http://argocd-server/api/v1/session -H 'content-type: application/json' -d '{}' # 000
# 3) 回头看 Pangolin 的 target 配置# method='h2c' —— 全站唯一一个 h2c 目标也就是说:Traefik 按 h2c:// 去连后端,而 --insecure 的 argocd-server 只在 8080 上说 HTTP/1.1,h2c 的 prior-knowledge 握手直接被拒。把 targets.method 改成 http 之后,REST 立刻恢复正常。
这也顺带解释了另一个疑问:为什么 --insecure 的 ArgoCD 连 h2c 都不支持,却能跑 gRPC? 因为它用的是 cmux —— 靠嗅探连接开头的字节流来分流,而不是靠 ALPN 协商。这一点在坑六会变成主角。
坑三:--insecure 写死在 args 里,改 ConfigMap 没用
思路转到「让 argocd-server 自己出 TLS」。第一版脚本的做法很自然:把 argocd-cmd-params-cm 里的 server.insecure 改成 false,再滚动重启。
结果新 Pod 崩了:
Readiness probe failed: Get "https://10.244.1.254:8080/healthz": read: connection reset by peerLiveness probe failed: Get "https://10.244.1.254:8080/healthz?full=true": read: connection reset by peerNormal Killing Container server failed liveness probe, will be restarted探针用 HTTPS 打 8080 却被 reset → 说明 8080 仍然是明文。查 Deployment 才发现原因:
kubectl -n argocd get deploy argocd-server -o jsonpath='{.spec.template.spec.containers[0].args}'# ["/usr/local/bin/argocd-server","--port=8080","--metrics-port=8083","--insecure"]--insecure 是写死在 args 里的,命令行的 flag 优先级高于 ConfigMap 里的同名键。所以那一版改动等于什么都没做,只把探针改成了 HTTPS,自己把自己弄崩了。
正确的做法是改 args,而且不要假设下标固定,用 jq 定位:
cur=$(kubectl -n argocd get deploy argocd-server -o json | jq -c '.spec.template.spec.containers[0].args')idx=$(jq -r 'to_entries[] | select(.value=="--insecure") | .key' <<<"$cur" | head -1)kubectl -n argocd patch deploy argocd-server --type=json \ -p "[{\"op\":\"remove\",\"path\":\"/spec/template/spec/containers/0/args/$idx\"}]"顺带把 ConfigMap 里的 server.insecure 键删掉,避免出现两个真相源。
坑四:探针的 scheme 必须跟着 server 的 TLS 状态走
kubelet 的 httpGet 探针会照 scheme 字段发请求。server 出 TLS 之后探针还是 HTTP,就会拿明文去打 TLS 端口,三次失败(failureThreshold: 3、periodSeconds: 10)之后 liveness 直接把容器杀掉。
所以切模式时必须成对修改:
| 模式 | args | liveness/readiness scheme |
|---|---|---|
| http | 保留 --insecure | HTTP |
| tls | 去掉 --insecure | HTTPS |
kubelet 的 HTTPS 探针不校验证书,自签也不影响,这一点不需要额外配 CA。
坑五:Passthrough 与 Terminate 不能共用一个 443
既然 argocd-server 要自己出 TLS,网关就只能做透传。我一开始想直接在共享网关上加一个 listener,被仓库里已有的注释拦住:
不在这里放 TLS Passthrough listener。Passthrough 与 Terminate 抢同一个 443 端口,一个 Gateway 里两个 listener 同端口不同协议是非法的。
规则是真的:同端口不同协议的 listener 会让 Gateway Programmed=False。所以透传入口必须自带一个 Gateway,也就自带一个 LoadBalancer VIP。这个集群的 default-pool 还剩 5 个空闲地址,够用,但每次改 VIP 都要同步改 Pangolin 的 target 和内部域名解析。
TLSRoute 的写法在仓库里已有先例(PostgreSQL 的 passthrough 入口),关键两点:protocol 必须是 TLS 而不是 TCP/HTTPS,tls.mode 必须是 Passthrough:
apiVersion: gateway.networking.k8s.io/v1kind: Gatewaymetadata: name: argo-passthrough-gateway namespace: argocdspec: gatewayClassName: cilium listeners: - name: argo-tls port: 443 protocol: TLS tls: mode: Passthrough allowedRoutes: kinds: [{kind: TLSRoute}] namespaces: {from: Same}---apiVersion: gateway.networking.k8s.io/v1kind: TLSRoutemetadata: name: argocd-api namespace: argocdspec: parentRefs: [{name: argo-passthrough-gateway, sectionName: argo-tls}] hostnames: ["argocd-api.dev.test"] rules: - backendRefs: [{name: argocd-server, port: 443}]⚠️ TLSRoute 必须用 apiVersion: gateway.networking.k8s.io/v1:这个集群的 CRD 只 served v1,写 v1alpha2 会直接 apply 失败。
坑六:ArgoCD 不广播 ALPN h2,所以 L7 永远拿不到原生 gRPC
这是全文最核心、也最容易被误判的一条。
切到 TLS 之后,UI 正常、REST 正常、--grpc-web 正常,但原生 gRPC 依旧回退。按直觉会怀疑 Pangolin 的证书或 TLS 配置,实际用一条命令就能排除:
kubectl -n argocd exec <probe> -- sh -c \ 'curl -skv --http2 https://argocd-server.argocd.svc.cluster.local/healthz 2>&1 | grep ALPN'
# * ALPN: curl offers h2,http/1.1# * ALPN: server accepted http/1.1 ← 后端根本没选 h2ArgoCD 的 TLS 服务端不在 ALPN 里广播 h2,因为它用 cmux 按字节嗅探:客户端自己直接发 HTTP/2 preface 才走 gRPC,发 HTTP/1.1 就走 REST。它不需要、也不提供 ALPN 协商。
而 Traefik 是 HTTP 反向代理,它必须先通过 ALPN 知道后端说的是 h1 还是 h2,再决定自己怎么转发。后端不广播 h2 → Traefik 只能按 h1 转发 → gRPC 的双向流在中间被拆散。
换个说法:L7 代理必须读懂你在说什么,L4 只负责搬箱子。gRPC 的 HTTP/2 语义藏在字节流里,只有当没人拆开信封时才能完整到达。
这条结论的适用范围比 ArgoCD 更大:任何「客户端说 h2、但服务端不广播 ALPN h2」的组合,经过 L7 代理都拿不到原生 gRPC。想验证一个后端有没有这个问题,用上面那条 curl --http2 看 ALPN 就够了。
坑七:面板把端口粘进了主机名
走 L4 需要新建一条 Pangolin 的 raw TCP 资源。按面板填完之后,端口上没有任何 TLS 响应。查数据库发现主机名变成了这样:
ip = argocd-server.argocd.svc.cluster.local443 ← 冒号被吃掉,端口粘进了主机名port = 443面板的「目标地址」只该填主机名,端口填在右侧的端口框里;把 host:443 写进地址框会被存成 host443,newt 去连一个不存在的主机,表现是端口静默不通。
同一轮里还有第二个低级错误:CLI 的命令不能写协议前缀。
argocd login https://argocd-api.apikv.com:30002 --insecure# error dial proxy: dial tcp: address https://...:30002: too many colons in address
argocd login argocd-api.apikv.com:30002 --insecure # 正确:host:portargocd login 的位置参数就是 host:port,协议由 --insecure / --plaintext 决定。
TLS 版:完整操作步骤
前置检查三件事:LB 池有空闲地址、GatewayClass cilium 存在、cert-manager 的 CRD 已安装。
kubectl get ciliumloadbalancerippool # 看 IPS AVAILABLEkubectl get crd certificates.cert-manager.io tlsroutes.gateway.networking.k8s.io一条命令完成切换:
bash components/argo/mode-tls.sh # 加 --dry-run 只渲染清单,不动集群脚本按这个顺序做六件事,顺序不能反:
- 建
argo-passthrough-gateway+ 两条TLSRoute(UI 的 SNI 与 CLI 的 SNI 各一条); - 用 cert-manager 签发
argocd-server-tls,并等 Certificate 变成 Ready; - 从
args里删掉--insecure; - 把 liveness/readiness 的
scheme改成HTTPS; - 滚动重启
argocd-server,等 rollout 完成; - 删掉明文
HTTPRoute(同一 hostname 不能同时挂在 terminate 和 passthrough 上)。
第 2 步为什么要等:Secret 不存在时 argocd-server 会退回自签证书,先重启就白重启了。
证书这一块有个必须避开的坑:不要往 argocd-secret 里塞。那个 Secret 还存着 admin.password、server.secretkey、webhook secret,让 cert-manager 接管会整份覆盖,登录立刻失效。ArgoCD 支持专用 Secret argocd-server-tls,让 cert-manager 独占它、自动续期:
apiVersion: cert-manager.io/v1kind: Certificatemetadata: {name: argocd-server-tls, namespace: argocd}spec: secretName: argocd-server-tls duration: 2160h # 90 天 renewBefore: 1440h # 提前 60 天续期 privateKey: {algorithm: ECDSA, size: 256, rotationPolicy: Always} usages: [server auth] dnsNames: - argocd.dev.test - argocd-api.dev.test - argocd-server - argocd-server.argocd.svc - argocd-server.argocd.svc.cluster.local - argocd.apikv.com - argocd-api.apikv.com issuerRef: {name: global-ca-issuer, kind: ClusterIssuer}⚠️ 续期:cert-manager 会在到期前 60 天自动换掉 Secret,但 argocd-server 只在启动时读证书。所以续期后要手动重启一次:
kubectl -n argocd rollout restart deploy/argocd-server提前 60 天续期,就是给这次重启留窗口。
Pangolin 侧要改两处
| 资源 | 目标 | 端口 | 启用 TLS | TLS 服务器名称 |
|---|---|---|---|---|
argocd(UI) | https://<新 VIP> | 443 | 打开 | argocd.dev.test |
argocd-api(CLI) | https://argocd-server.argocd.svc.cluster.local | 443 | 打开 | argocd-api.dev.test |
填了「TLS 服务器名称」之后,Pangolin 会自动生成这样的 Traefik 配置,自签或私签证书都不用另外配 CA:
"71-transport": {"serverName": "argocd-api.dev.test", "insecureSkipVerify": true}改完 5 秒内生效(Traefik 每 5 秒轮询一次 Pangolin 的动态配置)。但如果改的是目标 IP,newt 需要重载才会重新建立隧道:
kubectl -n pangolin rollout restart deploy/newt验证
# 1) 网关透传看到的是 Argo 自己的证书echo | openssl s_client -connect <VIP>:443 -servername argocd.dev.test 2>/dev/null \ | openssl x509 -noout -subject -issuer
# 2) 严格校验(在集群内用 ca.crt,避免 --insecure 掩盖问题)curl -s --cacert ca.crt -o /dev/null -w '%{http_code}\n' \ --resolve argocd.dev.test:443:<VIP> https://argocd.dev.test/healthz # 200
# 3) REST 与 grpc-webcurl -s -o /dev/null -w '%{http_code}\n' -X POST https://argocd-api.apikv.com/api/v1/session \ -H 'content-type: application/json' -d '{"username":"admin","password":"x"}' # 401argocd login argocd-api.apikv.com --username admin --grpc-web客户端信任(想免掉 --insecure)
证书由集群根 CA 签发,所以能不能免掉 --insecure 取决于客户端信不信这张根 CA:
kubectl -n cert-manager get secret global-root-ca-secret \ -o jsonpath='{.data.tls\.crt}' | base64 -d > my-global-root-ca.crt
# macOS:Go 在 darwin 上不读 SSL_CERT_FILE,只认系统钥匙串(实测踩过)sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain my-global-root-ca.crt
# Linux:环境变量就够SSL_CERT_FILE=./my-global-root-ca.crt argocd login argocd-api.apikv.com --username admin不用 HTTP 的版本:L4 透传
如果目标就是「把 --grpc-web 干掉」,那么 L7 这条路走不通——原因就是坑六。剩下的选择只有 L4:让客户端的 TLS 直接落到 argocd-server 上,中间没有任何人解密。
这套集群的 Pangolin 预置了 raw TCP 端口(compose 已发布、安全组已放行、Traefik 已有 tcp-30002 entryPoint),所以不需要改 compose,也不需要动安全组,只要新建一条 TCP 资源:
| 字段 | 值 |
|---|---|
| 资源名称 | argocd-grpc |
| 类型 | Raw TCP(不是 HTTP) |
| 端口 | 30002 |
| 站点 | k8s-cluster |
| 目标地址 | argocd-server.argocd.svc.cluster.local(只填主机名) |
| 目标端口 | 443 |
| 认证 | 不启用(裸 TCP 走不了 HTTP 鉴权,靠 Argo 自己的登录) |
保存后 Traefik 会生成一条 HostSNI(*) 的 TCP 路由,只做字节转发:
"tcp": {"routers": {"77-argocd-grpc-router": {"entryPoints": ["tcp-30002"], "rule": "HostSNI(`*`)"}}}客户端这样连:
argocd login argocd-api.apikv.com:30002 --username admin --insecure为什么要 --insecure:裸 TCP 链路上没有 Pangolin 帮你跳过证书校验。装了根 CA 之后可以去掉:
argocd login argocd-api.apikv.com:30002 --username admin # 已信任 my-global-root-ca 时判断有没有真的走原生 gRPC,看有没有这条告警就够了:
# 走 L7(失败回退){"level":"warning","msg":"Failed to invoke grpc call. Use flag --grpc-web in grpc calls."}# 走 L4(成功){"level":"fatal","msg":"rpc error: code = Unauthenticated desc = Invalid username or password"}回滚到 HTTP
两版脚本是互斥且可重入的,回滚也是一条命令:
bash components/argo/mode-http.sh它会:删掉 TLSRoute 与 passthrough Gateway、把 --insecure 加回 args、探针改回 HTTP、重启 server、重建明文 HTTPRoute。Pangolin 侧对应改回:
| 资源 | 目标 | 端口 | 说明 |
|---|---|---|---|
argocd | https://10.10.31.240 | 443 | 共享网关终结 TLS,SNI argocd.dev.test |
argocd-api | http://argocd-server.argocd.svc.cluster.local | 80 | 明文,CLI 必须 --grpc-web |
数据库侧我还留了一份回滚 SQL,在 node1 的 /root/argocd-tls-rollback.sql。
一个必须记住的前提:重跑 install.sh 会先 apply 上游清单,把 args 和探针改回默认值。所以重装时要用同模式的变量,不要在 TLS 模式下裸跑安装:
ARGOCD_SERVER_MODE=tls bash components/argo/install.sh代价与取舍
| 方案 | 原生 gRPC | UI/浏览器 | 额外成本 |
|---|---|---|---|
| HTTP(原状) | ✗ 必须 --grpc-web | 网关终结 TLS,证书由网关提供 | 无 |
| TLS + 网关透传 | ✗(ALPN 限制) | 透传,浏览器看到 Argo 的证书 | 多一个 LB VIP;改内部 DNS;续期要重启 |
| TLS + 裸 TCP 30002 | ✓ | 同上 | 多一个公网裸端口;客户端要 --insecure 或装 CA |
如果重来一次,我会先花两分钟验证 ALPN,再决定要不要动 TLS:
kubectl -n argocd exec <probe> -- sh -c \ 'curl -skv --http2 https://argocd-server.argocd.svc.cluster.local/healthz 2>&1 | grep ALPN'看到 server accepted http/1.1 就该知道:TLS 改造解决的是「加密与证书」,解决不了「原生 gRPC」。后者只能靠 L4。
不过 TLS 这一版也不是白做:进 ArgoCD 的那一跳从明文变成了 TLS,证书也从「启动时自签」变成了 cert-manager 统一签发、可续期、SAN 齐全。这些是独立的价值,跟 gRPC 无关。
仓库与文件
所有清单和脚本都在 kubernetes 仓的 components/argo 下:
- 仓库:https://github.com/lens077/kubernetes
- 组件目录:https://github.com/lens077/kubernetes/tree/main/components/argo
mode-tls.sh/mode-http.sh:两种暴露模式,互斥、可重入,支持--dry-rungateway/tls/tlsroute.yaml:passthrough Gateway + 两条 TLSRoutegateway/tls/certificate.yaml:cert-manager 签发的服务端证书gateway/http/httproute.yaml:回退路径用的明文路由README.md:两种模式的取舍、踩坑与验证命令
一份最小验证清单,留给下一次改入口的自己:
kubectl -n argocd get gateway,pod,svckubectl -n argocd get certificate argocd-server-tlskubectl -n argocd get tlsroutekubectl -n pangolin logs deploy/newt --tail=50 | grep -E 'argocd|tcp proxy'