希曼日记

给 ArgoCD 换 TLS 入口:Cilium Gateway、TLSRoute 与一个绕不过的 ALPN 坑

约 8 分钟2,932 字

起因很小: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」是全文的主线:

ArgoCD 的三条入口:浏览器经网关透传、CLI 的 REST/grpc-web 直连、CLI 的原生 gRPC 走裸 TCP 30002

其中「Cilium Gateway」那一格只出现在 UI 那条路上:它是 TLS/Passthrough 的 listener,按 SNI 把 TLS 字节转给 argocd-server,全程不解密。

坑一:Pangolin 的资源认证只认浏览器

第一个症状是 CLI 报一个很难读的错:

Text
rpc error: code = Unknown desc = unexpected HTTP status code received from server: 302 (Found);
malformed header: missing HTTP content-type

而同一个域名在浏览器里打开是正常的。

去 node1 翻 Traefik 的访问日志,能看到这条请求的原始去向:

JSON
{"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 字节:

Text
HTTP/2 500
content-length: 21
Internal Server Error

21 字节正好是 Traefik 的默认错误页。而ArgoCD 服务端日志里一条对应请求都没有——这很关键:说明问题不在后端。

叛定过程分三步:

Bash
# 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 直接被 reset
kubectl -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 '{}' # 401
kubectl -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 崩了:

Text
Readiness probe failed: Get "https://10.244.1.254:8080/healthz": read: connection reset by peer
Liveness probe failed: Get "https://10.244.1.254:8080/healthz?full=true": read: connection reset by peer
Normal Killing Container server failed liveness probe, will be restarted

探针用 HTTPS 打 8080 却被 reset → 说明 8080 仍然是明文。查 Deployment 才发现原因:

Bash
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 定位:

Bash
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 直接把容器杀掉。

所以切模式时必须成对修改:

模式argsliveness/readiness scheme
http保留 --insecureHTTP
tls去掉 --insecureHTTPS

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:

YAML
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: argo-passthrough-gateway
namespace: argocd
spec:
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/v1
kind: TLSRoute
metadata:
name: argocd-api
namespace: argocd
spec:
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 配置,实际用一条命令就能排除:

Bash
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 ← 后端根本没选 h2

ArgoCD 的 TLS 服务端不在 ALPN 里广播 h2,因为它用 cmux 按字节嗅探:客户端自己直接发 HTTP/2 preface 才走 gRPC,发 HTTP/1.1 就走 REST。它不需要、也不提供 ALPN 协商。

而 Traefik 是 HTTP 反向代理,它必须先通过 ALPN 知道后端说的是 h1 还是 h2,再决定自己怎么转发。后端不广播 h2 → Traefik 只能按 h1 转发 → gRPC 的双向流在中间被拆散。

L7 与 L4 的差别:L7 代理重新发起 TLS 并依赖 ALPN,被降级成 http/1.1;L4 只转发字节,h2 preface 直达后端

换个说法:L7 代理必须读懂你在说什么,L4 只负责搬箱子。gRPC 的 HTTP/2 语义藏在字节流里,只有当没人拆开信封时才能完整到达。

这条结论的适用范围比 ArgoCD 更大:任何「客户端说 h2、但服务端不广播 ALPN h2」的组合,经过 L7 代理都拿不到原生 gRPC。想验证一个后端有没有这个问题,用上面那条 curl --http2 看 ALPN 就够了。

坑七:面板把端口粘进了主机名

走 L4 需要新建一条 Pangolin 的 raw TCP 资源。按面板填完之后,端口上没有任何 TLS 响应。查数据库发现主机名变成了这样:

Text
ip = argocd-server.argocd.svc.cluster.local443 ← 冒号被吃掉,端口粘进了主机名
port = 443

面板的「目标地址」只该填主机名,端口填在右侧的端口框里;把 host:443 写进地址框会被存成 host443,newt 去连一个不存在的主机,表现是端口静默不通。

同一轮里还有第二个低级错误:CLI 的命令不能写协议前缀。

Bash
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:port

argocd login 的位置参数就是 host:port,协议由 --insecure / --plaintext 决定。

TLS 版:完整操作步骤

前置检查三件事:LB 池有空闲地址、GatewayClass cilium 存在、cert-manager 的 CRD 已安装。

Bash
kubectl get ciliumloadbalancerippool # 看 IPS AVAILABLE
kubectl get crd certificates.cert-manager.io tlsroutes.gateway.networking.k8s.io

一条命令完成切换:

Bash
bash components/argo/mode-tls.sh # 加 --dry-run 只渲染清单,不动集群

脚本按这个顺序做六件事,顺序不能反:

  1. 建 argo-passthrough-gateway + 两条 TLSRoute(UI 的 SNI 与 CLI 的 SNI 各一条);
  2. 用 cert-manager 签发 argocd-server-tls,并等 Certificate 变成 Ready;
  3. 从 args 里删掉 --insecure;
  4. 把 liveness/readiness 的 scheme 改成 HTTPS;
  5. 滚动重启 argocd-server,等 rollout 完成;
  6. 删掉明文 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 独占它、自动续期:

YAML
apiVersion: cert-manager.io/v1
kind: Certificate
metadata: {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 只在启动时读证书。所以续期后要手动重启一次:

Bash
kubectl -n argocd rollout restart deploy/argocd-server

提前 60 天续期,就是给这次重启留窗口。

Pangolin 侧要改两处

资源目标端口启用 TLSTLS 服务器名称
argocd(UI)https://<新 VIP>443打开argocd.dev.test
argocd-api(CLI)https://argocd-server.argocd.svc.cluster.local443打开argocd-api.dev.test

填了「TLS 服务器名称」之后,Pangolin 会自动生成这样的 Traefik 配置,自签或私签证书都不用另外配 CA:

JSON
"71-transport": {"serverName": "argocd-api.dev.test", "insecureSkipVerify": true}

改完 5 秒内生效(Traefik 每 5 秒轮询一次 Pangolin 的动态配置)。但如果改的是目标 IP,newt 需要重载才会重新建立隧道:

Bash
kubectl -n pangolin rollout restart deploy/newt

验证

Bash
# 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-web
curl -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"}' # 401
argocd login argocd-api.apikv.com --username admin --grpc-web

客户端信任(想免掉 --insecure)

证书由集群根 CA 签发,所以能不能免掉 --insecure 取决于客户端信不信这张根 CA:

Bash
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 路由,只做字节转发:

JSON
"tcp": {"routers": {"77-argocd-grpc-router": {"entryPoints": ["tcp-30002"], "rule": "HostSNI(`*`)"}}}

客户端这样连:

Bash
argocd login argocd-api.apikv.com:30002 --username admin --insecure

为什么要 --insecure:裸 TCP 链路上没有 Pangolin 帮你跳过证书校验。装了根 CA 之后可以去掉:

Bash
argocd login argocd-api.apikv.com:30002 --username admin # 已信任 my-global-root-ca 时

判断有没有真的走原生 gRPC,看有没有这条告警就够了:

Text
# 走 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
bash components/argo/mode-http.sh

它会:删掉 TLSRoute 与 passthrough Gateway、把 --insecure 加回 args、探针改回 HTTP、重启 server、重建明文 HTTPRoute。Pangolin 侧对应改回:

资源目标端口说明
argocdhttps://10.10.31.240443共享网关终结 TLS,SNI argocd.dev.test
argocd-apihttp://argocd-server.argocd.svc.cluster.local80明文,CLI 必须 --grpc-web

数据库侧我还留了一份回滚 SQL,在 node1 的 /root/argocd-tls-rollback.sql。

一个必须记住的前提:重跑 install.sh 会先 apply 上游清单,把 args 和探针改回默认值。所以重装时要用同模式的变量,不要在 TLS 模式下裸跑安装:

Bash
ARGOCD_SERVER_MODE=tls bash components/argo/install.sh

代价与取舍

方案原生 gRPCUI/浏览器额外成本
HTTP(原状)✗ 必须 --grpc-web网关终结 TLS,证书由网关提供无
TLS + 网关透传✗(ALPN 限制)透传,浏览器看到 Argo 的证书多一个 LB VIP;改内部 DNS;续期要重启
TLS + 裸 TCP 30002✓同上多一个公网裸端口;客户端要 --insecure 或装 CA

如果重来一次,我会先花两分钟验证 ALPN,再决定要不要动 TLS:

Bash
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 下:

一份最小验证清单,留给下一次改入口的自己:

Bash
kubectl -n argocd get gateway,pod,svc
kubectl -n argocd get certificate argocd-server-tls
kubectl -n argocd get tlsroute
kubectl -n pangolin logs deploy/newt --tail=50 | grep -E 'argocd|tcp proxy'