把静态站发到三个地方:Cloudflare、EdgeOne 与 Kubernetes 的 GitOps 流水线
一个静态站要在三个地方出现:海外的 Cloudflare Pages、大陆的 EdgeOne Pages,以及 site.example.com 这个自有主入口。前两个是纯静态托管,第三个要走 Kubernetes,经反向代理进入 Cilium Gateway。
这篇用一个示例站(下称 example-site)说明落地结构:一次 push 到 main,构建站点与 amd64 镜像,回写镜像 tag,ArgoCD 从 GitLab 镜像同步到集群,EdgeOne 与 Cloudflare 各自发布。项目名、域名、仓库、集群地址都写成占位符,换成你自己的即可。
三端各管什么
| 端 | 地址 | 托管 | 谁更新 |
|---|---|---|---|
| 海外站 | <cf-project>.pages.dev | Cloudflare Pages | GitHub Actions |
| 大陆镜像 | pages.example.com | EdgeOne Pages | GitHub Actions |
| 主站 | site.example.com | Kubernetes(ArgoCD + Helm) | CI 回写 tag → ArgoCD |
镜像域名只做静态副本,不接管主域;主域带反向代理资源,负责国内入口。三端共用同一份 dist/,所以文案、截图和语言路由不会分叉。
EdgeOne 这一端能打开后,截图是示例站的大陆镜像首屏:

前置准备
不要先复制命令。先确认这些工具和值都就绪:
| 依赖 | 检查 | 缺失时 |
|---|---|---|
| Git / GitHub CLI | git --version、gh auth status | 安装后 gh auth login |
| Node.js ≥ 18 | node -v | brew install node 或 nvm |
| Docker + buildx | docker buildx version | Docker Desktop / OrbStack / colima |
| kubectl | kubectl config current-context | 配置 kubeconfig |
| Helm ≥ 3 | helm version --short | 官方脚本或包管理器 |
| Wrangler | npx wrangler@4 whoami | npx wrangler@4 login;CI 用 API Token |
| EdgeOne CLI | edgeone whoami | npm i -g --prefix ~/.local edgeone@latest && edgeone login --site china |
| jq / curl / openssl | 对应 --version | 包管理器安装 |
还需要向使用者确认:网关名称与命名空间、对外 VIP、TLS 监听器与证书 Secret、镜像 registry、三个域名、Cloudflare 账号 ID。这些不要猜。
CI:一次 push,三个动作
工作流只由 main 的 push 或手动 workflow_dispatch 触发,做三件事:
- 构建
dist/,发布 Cloudflare Pages 与 EdgeOne Pages。 - 构建
linux/amd64镜像推到 registry,产出<sha>@sha256:<digest>。 - 回写
values-prod.yaml的image.tag,提交并镜像到 GitLab。
之所以要把 digest 写回 Git,而不是让 CI 直接 kubectl apply,是因为集群由 ArgoCD 管。CI 一旦直接改集群,ArgoCD 的 selfHeal 会立刻把手工改动拉回 Git 的旧 tag,形成来回抖动。Git 是唯一状态源,CI 只负责把新镜像写进去。
回写提交带 [skip ci],并且工作流用 github.actor != 'github-actions[bot]' 挡住自己的提交,否则「提交 → 触发 → 再提交」会转圈。
为什么 ArgoCD 读 GitLab,不读 GitHub
最初 Application 直接指向 GitHub,还专门建了只读 deploy key。结果 ArgoCD 的 repo-server 日志停在:
Initializing git@github.com:<org>/<repo>.git ...git fetch origin --tags --force --prune此后一分钟没有任何进展,Application 的 revision 是空的。原因是集群里的 argocd-repo-server clone GitHub 不稳定。改成 GitLab 镜像 <gitlab-org>/<repo> 后,ArgoCD 几秒内就完成了 clone 和 helm template。
这不是「多此一举的镜像」,而是网络事实。GitHub 仍然是开发主仓;GitLab 只承担「给集群读」的角色,CI 每次发布后把 main 快进推过去。
Helm:静态站也值得一张 chart
一张 web Deployment 就够,但该有的边界要写清楚:
ConfigMap放 nginx 配置(非密钥),挂到/etc/nginx/conf.d/default.conf;ExternalSecret从 OpenBao 取镜像拉取凭据;HTTPRoute挂网关的 https 监听,另有一条 80 → 443 的 301;TLSRoute是默认关闭的示例:网关已经在 https 监听器终结 TLS,再开 passthrough 会打架;CiliumNetworkPolicy只放行网关与探针,出站只放 DNS;PDB minAvailable: 1,滚动更新时至少留一个 Pod。
关键片段:
spec: parentRefs: - name: <gateway-name> namespace: <gateway-namespace> sectionName: https hostnames: - site.example.com rules: - matches: - path: { type: PathPrefix, value: / } backendRefs: - name: <app> port: 80镜像从 nginx:1.27-alpine(root、80 端口)换成 nginxinc/nginx-unprivileged:1.27-alpine(非 root、8080)。换完之后 Deployment 的 securityContext 才真正有意义:runAsNonRoot、readOnlyRootFilesystem、drop: ALL,再把 cache、run、tmp 三个目录挂成 emptyDir。
七个卡点
1. ArgoCD 拉不动 GitHub
见上。修法是换 GitLab 源,并给 ArgoCD 建一个只读 Project Access Token(read_repository)。不要用账号级 token,也不要把 token 写进 values。
2. EdgeOne 默认域名带 401
EdgeOne Makers 部署成功后会给出:
Deploy URL: https://<eo-project>.edgeone.cool?eo_token=...&eo_time=...不带 eo_token 直接访问是 401。它不是一个可以对外发布的地址。要绑 pages.example.com,并在 DNS 里加两条记录:站点 CNAME,以及证书校验用的 _dnsauth.pages → pages.example.com.<eoacme-domain>。后者漏了,HTTPS 会一直挂着共享证书。
3. root nginx 进不了受限 Pod
官方 nginx 镜像默认监听 80、以 root 启动。即使你写了 runAsNonRoot,它也会因为绑定低端口和写 /var/run 失败。换成 nginx-unprivileged、监听 8080,并让 chart 的 ConfigMap 接管配置,才是干净的解法。
4. CNP 把测试 Pod 也拦了
CiliumNetworkPolicy 只允许 fromEntities: [ingress, host, remote-node] 访问 8080。于是从集群里起一个 curl Pod 直连 Service,得到的是超时——这不是站点坏了,是策略按预期工作。正确验收入口是从 VIP 走网关:
kubectl -n <namespace> run g --image=curlimages/curl:8.10.1 --restart=Never --command -- \ curl -s -k -H 'Host: site.example.com' -o /dev/null -w '%{http_code}\n' https://<gateway-vip>/# 2005. OpenBao 的 push token 没有新路径权限
想用 PushSecret 把已有的拉取凭据推进 OpenBao,写入可能返回:
GET /v1/secret/data/k8s/hosting/<app>Code: 403 permission deniedopenbao-push 的 token 只覆盖已存在的那几条路径。要真正把凭据交给 OpenBao,需要管理员给它新路径的 create/update 权限。在权限补齐之前,chart 留一个显式开关:externalSecret.enabled: false + 普通 Secret 兜底。这不是「假装做了」,而是把待办留在 values 里。
6. 旧容器与旧路由
先跑在主机容器里、后改到 Kubernetes 时,容器删了,但旧的 Service 和 HTTPRoute 可能还在。两个 HTTPRoute 抢同一个 hostname,访问会随机命中旧后端。清理要连同:
kubectl -n <namespace> delete deploy <app> svc <app> httproute <app>一起做,不能只删 Deployment。
7. Cloudflare 的 CI token 不是 OAuth token
本机 wrangler login 拿到的是 OAuth token,临时指向 CLOUDFLARE_API_TOKEN 也能跑,但 expiration_time 只有一小时。把它塞进 GitHub Secrets,第二天就失效。CI 需要的是控制台创建的、带 Pages 编辑权限的 Account API Token。
Cloudflare Token:最小权限
- 登录 Cloudflare Dashboard → 右上角头像 → API Tokens → Create Token → Create Custom Token。
- Permissions 只加:Account → Cloudflare Pages → Edit。不要加 Zone、DNS、Workers、R2。
- Account Resources:Include → 指定这一个账号,不要 All accounts。
- TTL 按团队轮换周期设置;Client IP Filtering 在 GitHub 出口不固定时留空。
- 创建后复制 token(只显示一次)。
# token 有效性curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ https://api.cloudflare.com/client/v4/user/tokens/verify | jq .success
# 最小权限实际验证(Edit 包含读)CLOUDFLARE_ACCOUNT_ID=<account-id> npx --yes wrangler@4 pages project list
# 写进 Secretsgh secret set CLOUDFLARE_API_TOKENgh secret set CLOUDFLARE_ACCOUNT_ID --body "<account-id>"账号 ID 显式传入时,不需要额外的 Account Settings: Read。只有当你还要列项目或管理自定义域名,才补 Pages: Read / Zone: DNS: Edit。
手动的那一步:反向代理
反向代理面板没有可用的 CLI/API 凭据时,资源创建留在面板里。CI 每次部署都会把参数写进 Job Summary 和 artifact:
域名: site.example.com站点: <proxy-site>Upstream: <gateway-vip>端口: 443协议: HTTPSHost: 保留原始 Host认证: 公开,关闭 SSO参数固定,资源只建一次;以后发版只换镜像,不需要再动代理面板。
验收清单
# 1. ArgoCD 同步与健康kubectl -n argocd get app <app>-prod -o wide# Synced / Healthy
# 2. 镜像确实是 CI 写回的那个 digestkubectl -n <namespace> get deploy <app> \ -o jsonpath='{.spec.template.spec.containers[0].image}'
# 3. 走网关(不是直连 Service)kubectl -n <namespace> run g --image=curlimages/curl:8.10.1 --restart=Never --command -- \ curl -s -k -H 'Host: site.example.com' -o /dev/null -w '%{http_code}\n' https://<gateway-vip>/
# 4. 两个 Pages 端curl -s -o /dev/null -w '%{http_code}\n' https://<cf-project>.pages.dev/curl -s -o /dev/null -w '%{http_code}\n' https://pages.example.com/上线前还要确认
- Cloudflare 的 CI API Token 已创建并写入 Secrets,权限只有 Pages: Edit。
- OpenBao 的目标路径已有写权限,
ExternalSecret已从enabled: false切回true。 - 反向代理的主域资源已创建,
curl https://site.example.com/返回 200。 - EdgeOne 自定义域的证书已签发,不是共享证书。
- 三端用的是同一个
dist,不是三份各自维护的副本。
流水线的价值不在于「全自动」这四个字,而在于把不能自动的部分显式地列出来:哪些是凭据、哪些是一次性资源、哪些是网络限制。剩下的,才交给 CI。