希曼日记

把静态站发到三个地方:Cloudflare、EdgeOne 与 Kubernetes 的 GitOps 流水线

约 5 分钟1,763 字

一个静态站要在三个地方出现:海外的 Cloudflare Pages、大陆的 EdgeOne Pages,以及 site.example.com 这个自有主入口。前两个是纯静态托管,第三个要走 Kubernetes,经反向代理进入 Cilium Gateway。

这篇用一个示例站(下称 example-site)说明落地结构:一次 push 到 main,构建站点与 amd64 镜像,回写镜像 tag,ArgoCD 从 GitLab 镜像同步到集群,EdgeOne 与 Cloudflare 各自发布。项目名、域名、仓库、集群地址都写成占位符,换成你自己的即可。

拓扑:GitHub Actions、两个 Pages、GitLab 镜像、ArgoCD、Kubernetes 网关与反向代理

三端各管什么

端地址托管谁更新
海外站<cf-project>.pages.devCloudflare PagesGitHub Actions
大陆镜像pages.example.comEdgeOne PagesGitHub Actions
主站site.example.comKubernetes(ArgoCD + Helm)CI 回写 tag → ArgoCD

镜像域名只做静态副本,不接管主域;主域带反向代理资源,负责国内入口。三端共用同一份 dist/,所以文案、截图和语言路由不会分叉。

EdgeOne 这一端能打开后,截图是示例站的大陆镜像首屏:

示例站的大陆镜像首屏:状态条、标题与产品物件

前置准备

不要先复制命令。先确认这些工具和值都就绪:

依赖检查缺失时
Git / GitHub CLIgit --version、gh auth status安装后 gh auth login
Node.js ≥ 18node -vbrew install node 或 nvm
Docker + buildxdocker buildx versionDocker Desktop / OrbStack / colima
kubectlkubectl config current-context配置 kubeconfig
Helm ≥ 3helm version --short官方脚本或包管理器
Wranglernpx wrangler@4 whoaminpx wrangler@4 login;CI 用 API Token
EdgeOne CLIedgeone whoaminpm 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 触发,做三件事:

  1. 构建 dist/,发布 Cloudflare Pages 与 EdgeOne Pages。
  2. 构建 linux/amd64 镜像推到 registry,产出 <sha>@sha256:<digest>。
  3. 回写 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 日志停在:

Text
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。

关键片段:

YAML
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 部署成功后会给出:

Text
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 走网关:

Bash
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>/
# 200

5. OpenBao 的 push token 没有新路径权限

想用 PushSecret 把已有的拉取凭据推进 OpenBao,写入可能返回:

Text
GET /v1/secret/data/k8s/hosting/<app>
Code: 403 permission denied

openbao-push 的 token 只覆盖已存在的那几条路径。要真正把凭据交给 OpenBao,需要管理员给它新路径的 create/update 权限。在权限补齐之前,chart 留一个显式开关:externalSecret.enabled: false + 普通 Secret 兜底。这不是「假装做了」,而是把待办留在 values 里。

6. 旧容器与旧路由

先跑在主机容器里、后改到 Kubernetes 时,容器删了,但旧的 Service 和 HTTPRoute 可能还在。两个 HTTPRoute 抢同一个 hostname,访问会随机命中旧后端。清理要连同:

Bash
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:最小权限

  1. 登录 Cloudflare Dashboard → 右上角头像 → API Tokens → Create Token → Create Custom Token。
  2. Permissions 只加:Account → Cloudflare Pages → Edit。不要加 Zone、DNS、Workers、R2。
  3. Account Resources:Include → 指定这一个账号,不要 All accounts。
  4. TTL 按团队轮换周期设置;Client IP Filtering 在 GitHub 出口不固定时留空。
  5. 创建后复制 token(只显示一次)。
Bash
# 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
# 写进 Secrets
gh secret set CLOUDFLARE_API_TOKEN
gh secret set CLOUDFLARE_ACCOUNT_ID --body "<account-id>"

账号 ID 显式传入时,不需要额外的 Account Settings: Read。只有当你还要列项目或管理自定义域名,才补 Pages: Read / Zone: DNS: Edit。

手动的那一步:反向代理

反向代理面板没有可用的 CLI/API 凭据时,资源创建留在面板里。CI 每次部署都会把参数写进 Job Summary 和 artifact:

Text
域名: site.example.com
站点: <proxy-site>
Upstream: <gateway-vip>
端口: 443
协议: HTTPS
Host: 保留原始 Host
认证: 公开,关闭 SSO

参数固定,资源只建一次;以后发版只换镜像,不需要再动代理面板。

验收清单

Bash
# 1. ArgoCD 同步与健康
kubectl -n argocd get app <app>-prod -o wide
# Synced / Healthy
# 2. 镜像确实是 CI 写回的那个 digest
kubectl -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。