Author SHA1 Message Date
panxiao81 f00b5e3fc2 排除 OCI HelmRepository 正常缺失 Ready 条件的误报 2026-09-25 19:39:04 +00:00
panxiao81 9a800f55fc 补齐证书、秘密同步、GitOps 与数据服务监控
yaml / yaml (pull_request) Successful in 3m20s
2026-09-25 19:35:38 +00:00
194 changed files with 59 additions and 10263 deletions
-32
View File
@@ -1,32 +0,0 @@
---
name: dind-fuse-image
on:
workflow_dispatch:
push:
branches: [main]
paths:
- platform/gitea-runner/images/dind-fuse/**
- .gitea/workflows/dind-fuse-image.yml
jobs:
publish:
runs-on: [self-hosted, pod]
steps:
- uses: actions/checkout@v4
- name: Log in to the Gitea container registry
uses: docker/login-action@v3
with:
registry: git.ddupan.top
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and publish
uses: docker/build-push-action@v6
with:
context: platform/gitea-runner/images/dind-fuse
push: true
tags: |
git.ddupan.top/panxiao81/dind-fuse:29.7.1
git.ddupan.top/panxiao81/dind-fuse:latest
-24
View File
@@ -1,24 +0,0 @@
---
name: kata-runner-smoke
on:
workflow_dispatch:
push:
branches:
- poc/kata-runner-smoke
paths:
- .gitea/workflows/kata-runner-smoke.yml
jobs:
smoke:
runs-on: [self-hosted, pod]
steps:
- name: Verify isolated job environment
shell: sh
run: |
set -eu
uname -a
grep -q '^ID=alpine$' /etc/os-release
test -f /.dockerenv
test ! -e /dev/kvm
printf 'kata ephemeral runner job ok\n'
-63
View File
@@ -1,63 +0,0 @@
---
name: kind-on-kata-smoke
on:
push:
branches:
- poc/kind-on-kata
paths:
- .gitea/workflows/kind-on-kata-smoke.yml
workflow_dispatch:
jobs:
smoke:
runs-on: [self-hosted, pod]
steps:
- name: Create nested kind cluster
shell: sh
env:
KIND_VERSION: v0.27.0
KIND_NODE_IMAGE: kindest/node:v1.32.2@sha256:f226345927d7e348497136874b6d207e0b32cc52154ad8323129352923a3142f
run: |
set -eu
apk add --no-cache ca-certificates curl docker-cli
curl --retry 5 --retry-all-errors --connect-timeout 15 -fsSLo /tmp/kind \
"https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64"
curl --retry 5 --retry-all-errors --connect-timeout 15 -fsSLo /tmp/kind.sha256sum \
"https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64.sha256sum"
expected="$(awk '{print $1}' /tmp/kind.sha256sum)"
printf '%s %s\n' "$expected" /tmp/kind | sha256sum -c -
install -m 0755 /tmp/kind /usr/local/bin/kind
docker info --format 'kernel={{.KernelVersion}} driver={{.Driver}}'
test "$(docker info --format '{{.Driver}}')" = overlay2
cleanup() {
kind delete cluster --name nested >/dev/null 2>&1 || true
}
trap cleanup EXIT
cat >/tmp/kind-config.yaml <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: nested
nodes:
- role: control-plane
extraMounts:
- hostPath: /dev/kmsg
containerPath: /dev/kmsg
EOF
kind create cluster -v 9 --retain --config /tmp/kind-config.yaml --image "$KIND_NODE_IMAGE" --wait 5m
docker exec nested-control-plane kubectl \
--kubeconfig=/etc/kubernetes/admin.conf wait \
--for=condition=Ready node/nested-control-plane --timeout=2m
docker exec nested-control-plane kubectl \
--kubeconfig=/etc/kubernetes/admin.conf run smoke \
--image=docker.io/library/busybox:1.37 --restart=Never \
--command -- sh -c 'echo kind-on-kata-ok'
docker exec nested-control-plane kubectl \
--kubeconfig=/etc/kubernetes/admin.conf wait \
--for=jsonpath='{.status.phase}'=Succeeded pod/smoke --timeout=2m
test "$(docker exec nested-control-plane kubectl \
--kubeconfig=/etc/kubernetes/admin.conf logs smoke)" = kind-on-kata-ok
kind delete cluster --name nested
trap - EXIT
-10
View File
@@ -18,13 +18,3 @@ OCI digest 固定镜像。
Flux 等待 ExternalSecret 和 Deployment 就绪;任何前置缺失都会使该
Kustomization 保持 NotReady,而不会回退到明文 Secret。
## 镜像更新
`panxiao81/backstage` 的 CI 在 main push 后只推送 `:latest`。
`platform/flux-image-automation` 跟踪 `latest` 背后的 digest,由 `flux-bot`
直接提交到本仓库 main,不经过 PR。`deployment.yaml` 中 image 行的
`$imagepolicy` 注释是 setter 标记,删除后自动更新会静默停止。
旧 digest 失去 tag 后会被 zot GC 回收(24h),因此不要把 Deployment 手工固定到
一个已不是 `latest` 的 digest 并长期保留。
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
type: RuntimeDefault
containers:
- name: backstage
image: zot.ad.ddupan.top/panxiao81/backstage:latest@sha256:da55be5c2f5c8b33de2d87ee0ef02edeacf52a349c8b19a5fac9be4152b2723d # {"$imagepolicy": "flux-system:backstage"}
image: zot.ad.ddupan.top/panxiao81/backstage@sha256:e5a12550726f19a680bc7c40e2cc07cc624318f9279ee121814d293a006ef210
imagePullPolicy: IfNotPresent
env:
- name: BACKSTAGE_BASE_URL
+6 -61
View File
@@ -1,69 +1,14 @@
# Hydra 与独立 IAM 登录接入
# Hydra 与 OIDC Login/Consent PoC
本目录提供独立 Hydra 签发服务。当前分支将实验性 Hydra 登录入口接至 Spring `iam-login`
开发实例,由其执行 AD 密码、WebAuthn 和授权确认;不改变 issuer、Gitea 登录源或数据库。
旧 Go OIDC 适配器继续部署以便回退,Gitea 的直接 Authelia 登录源也保留。
该切换已于 2026-10-01 经 PR #168 合并并由 Flux 应用;下面原有 Go/Authelia 说明保留为回退路径资料。
本目录提供独立 Hydra 签发服务,以及一个薄的 **OIDC 上游适配器**。当前上游配置为
Authelia;适配器不连接 LDAP,也不管理用户目录。Samba AD、密码和 MFA 继续由现有
Authelia 链路负责。第一轮只接入人类和 Gitea,不实现 agent 动态授权。
```text
Gitea → Hydra → iam-login(Tailscale 开发实例)→ Samba AD + WebAuthn
Gitea → Hydra → OIDC Login/Consent → Authelia → Samba AD
← OIDC ← 经验证的上游身份 ← OIDC callback
```
## Spring 开发路径验收
源码版本:[iam-login 5f49a7c](https://git.ddupan.top/panxiao81/iam-login/commit/5f49a7c)。
开发 UI 是 `https://laptop.tail7e769.ts.net:18082`,已有 Passkey RP ID 保持不变。
Hydra 回调为 `/oauth2/start`、`/oauth2/consent`、`/oauth2/logout`;默认注销回到 `/signin`。
浏览器需要 Tailscale 连通性;开发服务暂由本地 JVM 容器运行,并非生产 Native 部署。
开发服务通过 `kubectl -n hydra port-forward --address 127.0.0.1 service/hydra-admin 18445:4445`
访问私有 Admin API。转发需在 Hydra 滚动更新后重连;验收运行器会循环重建该通道。
该通道与本地容器是临时验收依赖,必须保持运行;不修改 NetworkPolicy、
不新增 Admin HTTPRoute。临时运行配置与旧版回退备份仅保存在受限本地运行目录,不提交秘密。
验收前已只读核对目标 Gitea 账号的现有 Hydra 外部关联,并将旧 sub 显式绑定到 AD
稳定主体;不按邮箱重建关联、不修改 Gitea 账号或仓库权限。客户端管理仅允许有效 MFA 且
直接属于 AD Domain Admins 的用户;这是验收期明确指定的粗粒度管理组。
### 客户端管理 `/console`
iam-login 的客户端管理 API 只接受 Hydra 签发、带 `iam.clients.manage` scope 的 access token;
`/console` 是调用该 API 的前端,在 Hydra 中登记为普通 **public** 客户端 `iam-admin-ui`。
该 scope 只在 consent 时发给 `iam-admin-ui` 且直接属于管理组的用户,规则在 iam-login。
浏览器直接向 Hydra 换取 token,因此 `hydra.yaml` 为 public 端口开启 CORS,只允许开发实例
origin;不放行 cookie 凭据。Gitea 等服务端客户端不受影响。
`iam-admin-ui` 无 secret,与 `gitea` 一样经 Admin 带外登记(它是 public 客户端,iam-login API
看不到也删不掉它,恢复同样走此通道):
```sh
curl -fsS -X POST http://127.0.0.1:18445/admin/clients -H 'Content-Type: application/json' -d '{
"client_id": "iam-admin-ui", "client_name": "IAM 管理",
"redirect_uris": ["https://laptop.tail7e769.ts.net:18082/console/callback"],
"grant_types": ["authorization_code"], "response_types": ["code"],
"scope": "openid iam.clients.manage", "token_endpoint_auth_method": "none",
"subject_type": "public", "metadata": {"iam_login_enabled": true}}'
```
开发实例另需 `iam.clients.admin-ui-client-id=iam-admin-ui`;未配置时 `/console` 不提供,API
无法获得可用 token(fail closed)。
从 Gitea `/user/oauth2/hydra` 发起,应进入新密码/Passkey 页,确认授权后返回原账号,核对
仓库权限。当前 Gitea client 未登记 front/back-channel 或 post-logout 回调,不能声称 Gitea 会话会
随 IAM 注销。应用的注销协议兼容性需单独验收。旧 `authelia` 登录源始终保留。
开发配置关闭 Boot 默认 LDAP health indicator:它未配置目录连接,不对应同用户 bind 的
AD 仓储。总健康检查仍验证数据库,真实 AD 认证由本次人类登录验收,不将 readiness 当作
AD 凭据有效性的证明。
Hydra 的 login/consent URL 是全局配置,本次影响全部经 Hydra 的登录请求(当前接入 Gitea),
不影响直接走 Authelia 的应用。不要在无法访问 Tailscale 开发实例时合并此切换。
验收失败时 revert 本次回调变更,恢复旧 Go 的 `/login`、`/consent`;不删除客户端、签名密钥、
数据库、用户关联或 Passkey。最终 `auth.ddupan.top` 同源部署另开 PR,不在此次切换范围内。
## 原 Go/Authelia 路径与回退资料
目标入口:
- `https://hydra.ad.ddupan.top`:Hydra 公共 OAuth2/OIDC endpoint。
+2 -18
View File
@@ -1,20 +1,6 @@
serve:
public:
port: 4444
# The iam-login /console admin UI is a public OIDC client that exchanges its code from the
# browser, so only that origin may call the public endpoints cross-origin. Server-side
# clients such as Gitea are unaffected by CORS.
cors:
enabled: true
allowed_origins:
- https://laptop.tail7e769.ts.net:18082
allowed_methods:
- GET
- POST
allowed_headers:
- Authorization
- Content-Type
allow_credentials: false
admin:
port: 4445
tls:
@@ -26,10 +12,8 @@ urls:
self:
issuer: https://hydra.ad.ddupan.top
public: https://hydra.ad.ddupan.top
login: https://laptop.tail7e769.ts.net:18082/oauth2/start
consent: https://laptop.tail7e769.ts.net:18082/oauth2/consent
logout: https://laptop.tail7e769.ts.net:18082/oauth2/logout
post_logout_redirect: https://laptop.tail7e769.ts.net:18082/signin
login: https://hydra-login.ad.ddupan.top/login
consent: https://hydra-login.ad.ddupan.top/consent
ttl:
access_token: 15m
id_token: 15m
@@ -1,16 +0,0 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: flux-image-automation
namespace: flux-system
spec:
dependsOn:
- name: external-secrets
interval: 10m
path: ./platform/flux-image-automation
prune: true
sourceRef:
kind: GitRepository
name: flux-system
timeout: 5m
wait: true
File diff suppressed because it is too large Load Diff
-1
View File
@@ -8,7 +8,6 @@ resources:
- apps/external-secrets.yaml
- apps/gitea.yaml
- apps/backstage.yaml
- apps/flux-image-automation.yaml
- apps/http-echo.yaml
- apps/openebs.yaml
- apps/nats.yaml
-3
View File
@@ -27,9 +27,6 @@ homelab_dns:
- { zone: ad.ddupan.top, name: zot, type: A, values: [192.168.10.127] }
- { zone: ad.ddupan.top, name: zot-push, type: A, values: [192.168.10.127] }
- { zone: ad.ddupan.top, name: pg-prod, type: A, values: [192.168.10.2] }
- { zone: ad.ddupan.top, name: pg-dev, type: A, values: [192.168.10.127] }
split_horizon:
# backends records the current adoption boundary. obj is deliberately not
# emitted to CoreDNS yet, preserving the current pod resolver behaviour.
-6
View File
@@ -1,6 +0,0 @@
.terraform/
*.tfstate*
*.tfplan
*.tfvars
!*.tfvars.example
__pycache__/
-165
View File
@@ -1,165 +0,0 @@
# Homelab shared etcd
独立于 PostgreSQL 与 k3s 的三成员协调服务。Ansible 管主机、证书文件和 etcd 账号;
独立 Terraform root 管现有 Bao PKI 下的签发角色与 policy,不管理 CA 或秘密值。
旧 Ansible Vault 不在本次迁移范围。
状态:2026-09-25 已部署三成员 etcd,启用 Bao mTLS 和 RBAC;三个端点健康检查通过。
PVE 两个 LXC 的 rootfs 已通过 `pct move-volume` 从 local-lvm 迁入 `pve-rg` SSD DRBD 池,
保留容器与 etcd 数据。当前 PVE LXC 迁卷要求停止容器,维护入口按成员串行执行,回归健康后再处理下一个。
| 成员 | 承载 | 地址 | 资源 |
| --- | --- | --- | --- |
| etcd-laptop | laptop systemd | 192.168.10.127 | MemoryHigh 256 MiB / MemoryMax 384 MiB |
| etcd-pve1 | pve1 新无特权 LXC 150 | 10.60.0.20 | 1 core / 1536 MiB / 8 GiB SSD DRBD |
| etcd-pve2 | pve2 新无特权 LXC 151 | 10.60.0.21 | 1 core / 768 MiB / 8 GiB SSD DRBD |
资源限额是初始测试预算,不是已测容量。LXC 已增加 PG standby/备份的独立 HDD mp0 与内存上限,生产 PG standby 与备份仓库已启动。
etcd 自身的 MemoryHigh/MemoryMax 仍为 256/384 MiB。labnet 依赖现有 VyOS 路由;切换 bare-metal 时复用服务角色,更换主机 inventory。
## 接入方式与认证
客户端使用三个 `https://地址:2379` endpoint,校验 Bao 中央 CA,出示独立客户端证书。
成员间 2380 也开启 mTLS,额外限制 peer CN 为 `homelab-etcd-peer`。
管理员证书 CN 为 `root`,只保存在受管成员 root 可读的文件中;对应 etcd root 用户不设置密码。
服务进程只能读取自己的 server/peer 私钥,不能读取管理员私钥。
Patroni 走 etcd3 gateway,使用无 CN 的客户端证书及独立 username/password 完成 RBAC。
实测带 CN 的证书会被 gateway 拒绝;管理员 CN=root 证书仅用于原生 etcdctl。
成员另有无 CN 的 gateway 证书供内部 gateway→gRPC 连接及密码核对使用,不能将管理员证书复用到 HTTP API。首个消费者为
`patroni-pg-prod`,只允许 `/homelab/patroni/pg-prod/`。其 Bao KV v2 路径为
`kv/infra/etcd/consumers/patroni-pg-prod`,包含 username/password/prefix;不在本文复制实际值。
配置 Patroni 时对应 `namespace: /homelab/patroni/` 与 `scope: pg-prod`。
第一次接入应由管理流程交付 CA、客户端证书及秘密引用,然后使用自己的身份对自己的 prefix
执行一次 put/get/delete。跨 prefix 应被拒绝;不要把 root 证书复制给应用。
生产消费者证书已签发,Patroni 已接入并验证主从自动切换。
## 安装与维护入口
需要 Ansible、community.crypto,以及目标机 Python/systemd。当前二进制固定为 etcd 3.7.2
linux-amd64,并固定官方发布资产 SHA-256;版本升级必须单独评估兼容性、备份与 quorum。
Terraform 使用已登录的 Vault 兼容 provider 身份;Ansible 读取控制端 `BAO_TOKEN` 环境变量,
token 不下发目标机。`homelab-etcd-provisioner` policy 不自动绑定到现有身份,先由 Bao 管理流程授权。
```bash
cd infrastructure/etcd/terraform
terraform init
terraform plan
# 审阅计划后 apply;只新增此服务的 PKI roles/policies。
cd ../ansible
ansible-galaxy collection install -r requirements.yml
ansible-playbook lxc.yml --check
ansible-playbook lxc.yml
ansible-playbook site.yml
ansible-playbook bootstrap-auth.yml -e etcd_bootstrap_auth=true
ansible-playbook consumers.yml
ansible-playbook verify.yml
```
Terraform 使用现有 S3 tfstate bucket,独立 key `etcd/terraform.tfstate`,启用原生 lockfile;不复用 Bao 的 state。
从仓库根目录运行 `python3 infrastructure/etcd/run.py terraform <子命令>` 或
`python3 infrastructure/etcd/run.py ansible <playbook.yml>`,复用当前 Bao 会话并在内存中取得所需凭据。
`lxc.yml` 是创建入口,不接管已有未知 VMID,也不自动调整运行中容器的网络与容量;配置漂移会报错。
部署前核对模板与 `pve-rg` 存储可用性、IP 冲突、SSH host key、laptop sudo 和网络访问控制。
数据库角色不会创建/删除这些 LXC 或 etcd 成员。
`site.yml` 管安装、CSR 本地生成、Bao 签发、配置和逐成员激活。全体健康检查通过才记录激活指纹;
上次中断后重跑仍能识别尚未激活的磁盘配置。已有集群每个成员激活后检查健康,再处理下一个。
首次建群先让三个服务启动,再检查 quorum。`bootstrap-auth.yml` 与日常收敛分开,认证已启用时不重建。
首次认证启用前应限制客户端网络,不能把“已有中央 CA 签名”当作业务授权。
`consumers.yml` 首次生成 48 字符随机密码并以 KV v2 CAS=0 写入,之后只复用。
元数据存在但数据被删除、Bao 403/超时、或 etcd 用户存在但秘密丢失时均失败,不自动换密码。
并发 CAS 冲突会中止此次执行,重跑读取胜出的版本。既有额外角色或不同 prefix 权限也报错,
不默默扩大权限。账号删除、prefix 迁移和密码轮换均不包含在普通收敛里。
密码使用 stdin 送给 etcdctl,涉及秘密的任务 `no_log: true`;禁止用 `--diff` 打印未来消费者秘密文件。
证书有效期 60 天,`site.yml` 在剩余不足 14 天时续签;需要 Bao 的控制端身份。
续签 IaC 已实现于 `terraform/renewal.tf`、`controller/` 和 `ansible/controller.yml`:
独立 cert auth 挂载信任中央 CA,并限制 laptop 的 DNS SAN;用现有 peer 客户端证书登录,
换取 10 分钟 token,仅允许签发 server/peer/admin/gateway 四类证书,不授予 KV 读取权限。
控制端每日运行现有健康检查与串行部署,结束撤销 token;不保存长期 token,不依赖人工 OIDC 会话。
控制程序安装为 root 管理的固定副本,以 panxiao81 身份复用现有 Ansible/SSH/sudo 环境。
**机器身份和 timer 已启用**(2026-09-25):维护者恢复 OIDC 登录后,Terraform 新增三个
续签资源,`controller.yml` 登录预检通过。首次实际运行三成员均 `changed=0`,服务 Result=success。
Bao cert auth 需要非空 CN 作为 identity alias,不能使用无 CN 的 gateway 证书;因此登录使用
同一主机已有 peer 证书,仍由 `allowed_dns_sans=etcd-laptop` 限制身份,未扩大到其他成员。
手动触发 `systemctl start homelab-etcd-renew.service`,检查 journal 和
`/var/lib/homelab-etcd-controller/last-success`;每日 timer 04:10 UTC 加随机延迟。
本流程也续签自身登录所用 peer 证书;若超过有效期仍未修复,则需要管理身份重新签发。
成员启动本身仅用本地证书,不实时依赖 Bao。
## 备份与故障处理
每个成员有每日一次的 `homelab-etcd-snapshot.timer`,快照保存在各自
`/var/backups/homelab-etcd`,root-only,保留最近 3 个成功快照;snapshot status 验证成功才淘汰旧快照。
首备可执行 `systemctl start homelab-etcd-snapshot.service`,用 journal 和 `etcdutl snapshot status`
检查,不根据文件名推断备份成功。三处本地快照不等于异地备份。
quota 初始 256 MiB,按 1 小时保留进行自动 compaction;defrag 另择维护窗口逐成员做,
不能三个成员同时执行。metrics 绑定 loopback 与成员内网地址的 2381 端口,独立 listener 不提供 KV API;
与既有 LAN exporter 一样通过内网 HTTP 采集,禁止将端口映射到公网。
现有 VictoriaMetrics 使用 `platform/observability/metrics/scrapes/shared-etcd.yaml` 采集,
对应 VMRule 覆盖成员不可采集、少于两个可采集成员、无 leader、容量、WAL fsync 和频繁选举。
采集失败不等于 quorum 丢失,需结合管理员 endpoint health 判断。
备份年龄、证书到期与续签检查的主机采集已于 2026-09-27 部署,
告警规则已随 [PR #166](https://git.ddupan.top/panxiao81/homelab-infra/pulls/166) 合并并由 Flux 接管;
部署、阈值和排障见 [维护监控](../shared-data-monitoring/README.md)。既有 Alertmanager 已接入 Telegram,
维护者已收到本次成员无 leader 与频繁选举通知;接收器配置由现有监控栈维护。
### DRBD I/O 故障导致成员根卷只读
`SharedEtcdNoLeader` 是单成员指标,不等于全体没有 leader。先检查三个 endpoint,
并检查宿主机内核的 DRBD quorum、PingAck、I/O error 和 ext4 journal 日志。
`findmnt` 显示 `rw,emergency_ro` 仍表示文件系统已因错误停止写入;DRBD 恢复 quorum 不会自动修复它。
2026-09-25 17:16 UTC 起,CT150 根卷发生 quorum 短暂丢失、journal I/O 错误和 emergency_ro,
另外两个 etcd 成员保持健康。期间两个新 HDD 数据卷初始同步伴随多个 DRBD 资源 PingAck 超时。
只对本项目两个新数据卷设置 `DrbdOptions/PeerDevice/c-max-rate=10240`(10 MiB/s),
入口为 `../shared-postgresql/ansible/limit-resync.yml`,创建卷流程也复用该任务。
限速后短期未见新增超时,尚不构成唯一根因证明;未调整全局协议、quorum 或超时。
恢复顺序:确认另外两个成员健康并保存快照,正常停止故障容器,确认根卷卸载、DRBD quorum
和副本 UpToDate,再执行 `pct fsck 150 --device rootfs --force 1`。fsck 返回 1 表示已修复,
PVE 包装命令仍会报非零;需再次检查返回 0 才启动容器。不能在已挂载卷上执行 fsck,不能直接强制 remount。
本次修复后 CT150 启动,三个 endpoint 健康且 Raft term/index 一致;两个容器根卷与 mp0 均可写。
频繁选举告警含 15 分钟历史窗口,应核对最新计数和健康状态,不通过关闭规则消除通知。
- 单成员故障:先确认其他两成员仍健康,修复原成员;普通部署不删数据目录。
- 成员永久丢失:需单独执行 member remove/add 流程并同步 inventory,不用重新 init 覆盖。
- 全集群丢失:先恢复 Bao/信任根与恢复凭据,隔离全部旧成员,用同一有效快照恢复新逻辑集群。
开放客户端前逐个协调消费者状态。Patroni 必须核对真实主库、timeline、lease/DCS 状态;
不因旧快照宣称某节点为主就允许它写入。完整跨服务灾难恢复演练尚未完成。
## 验证
```bash
# 本机 loopback 三节点,临时测试 CA 与假的 Bao HTTP 服务,不接触线上秘密。
ETCD_TEST_BIN=/path/to/etcd-v3.7.2-linux-amd64 python3 tests/integration.py
cd terraform && terraform validate
cd ../ansible && ansible-playbook --syntax-check site.yml
```
集成测试验证实际模板启动、mTLS、认证初始化幂等、消费者幂等、CAS 首次创建、秘密缺失失败关闭、
prefix 隔离、gateway 账号登录和单成员故障。假 Bao 不验证真实 PKI 签发或中央 policy 的权限,
也不证明 PVE SSD DRBD 在业务负载下的延迟、systemd 内存预算、在线证书轮换和生产网络符合要求。
源码边界与 PostgreSQL 后续设计见 [研究记录](../shared-postgresql/RESEARCH.md)。
2026-09-25 验证结果:Terraform validate 通过;Ansible lint 零文件级问题;隔离三成员测试通过,
认证初始化与消费者第二次执行均 `changed=0`,包含 gateway 写入、密码漂移拒绝、快照离线恢复、
停止一成员后继续写入。测试低负载 RSS 约 36.6–40.4 MiB/成员,仅作开销参考。
现场验证:Bao PKI roles/policies 已应用,三成员已签发证书并启动;认证初始化与
`patroni-pg-prod` 账号创建成功,随机密码已保存 Bao,实际 gateway 登录验证通过。
两台 LXC 的 rootfs 均为 `pve-rg`,运行状态正常;迁移后全部端点成功提交健康探测。
现有监控已接入;自动续签已启用;备份/证书年龄告警于 2026-09-27 补齐并启用。PostgreSQL 部署状态见其 README。
监控接入验收(2026-09-25):三个 `up{job="shared-etcd"}` 均为 1,六条规则 health=ok,
三端点健康检查通过。新增续签调度四项失败/成功路径测试通过,Terraform validate 与 Ansible lint 通过,
隔离三成员测试再次通过。[IaC PR #159](https://git.ddupan.top/panxiao81/homelab-infra/pulls/159) 已合并。
2026-09-25 合并后核实:Flux observability Ready,应用版本 `f6d12d6`,
etcd/PG 的两份 VMRule 与两份 VMStaticScrape 均已纳入 inventory,状态 operational;
三个 etcd 与两个 Patroni 抓取目标均 up=1,相关 11 条告警 health=ok、inactive。备份/证书年龄告警不包含在这六条规则中。
-6
View File
@@ -1,6 +0,0 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
retry_files_enabled = False
host_key_checking = True
interpreter_python = auto_silent
@@ -1,60 +0,0 @@
---
# 与常规 site 分离,防止重建/恢复时默默创建新的认证域。
- name: 初始化共享 etcd 认证
hosts: etcd[0]
become: true
gather_facts: false
pre_tasks:
- name: 加载共享默认参数
ansible.builtin.import_role:
name: shared_etcd
tasks_from: context
environment:
ETCDCTL_ENDPOINTS: "https://{{ etcd_address }}:{{ etcd_client_port }}"
ETCDCTL_CACERT: "{{ etcd_config_dir }}/ca.crt"
ETCDCTL_CERT: "{{ etcd_config_dir }}/admin.crt"
ETCDCTL_KEY: "{{ etcd_config_dir }}/admin.key"
tasks:
- name: 读取认证状态
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, auth, status]
changed_when: false
register: etcd_auth_status
check_mode: false
- name: 初始化管理员与认证
when: not ((etcd_auth_status.stdout | from_json).enabled | default(false))
block:
- name: 要求显式初始化参数
ansible.builtin.assert:
that: etcd_bootstrap_auth | default(false) | bool
fail_msg: 首次初始化需要 -e etcd_bootstrap_auth=true;常规运行不能重建认证。
- name: 读取现有用户
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, user, list]
changed_when: false
register: etcd_users
- name: 创建仅证书认证的 root 用户
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", user, add, root, --no-password]
when: "'root' not in ((etcd_users.stdout | from_json).users | default([], true))"
changed_when: true
- name: 读取管理员角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, user, get, root]
changed_when: false
register: etcd_root_roles
- name: 授予 root 管理角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", user, grant-role, root, root]
when: "'root' not in ((etcd_root_roles.stdout | from_json).roles | default([], true))"
changed_when: true
- name: 开启认证
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", auth, enable]
changed_when: true
-37
View File
@@ -1,37 +0,0 @@
---
- name: 收敛消费者账号及独立 prefix
hosts: etcd[0]
become: true
gather_facts: false
pre_tasks:
- name: 加载共享默认参数
ansible.builtin.import_role:
name: shared_etcd
tasks_from: context
environment:
ETCDCTL_ENDPOINTS: "https://{{ etcd_address }}:{{ etcd_client_port }}"
ETCDCTL_CACERT: "{{ etcd_config_dir }}/ca.crt"
ETCDCTL_CERT: "{{ etcd_config_dir }}/admin.crt"
ETCDCTL_KEY: "{{ etcd_config_dir }}/admin.key"
tasks:
- name: 检查认证状态
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, auth, status]
register: etcd_auth_status
changed_when: false
check_mode: false
- name: 要求认证已启用
ansible.builtin.assert:
that:
- ((etcd_auth_status.stdout | from_json).enabled | default(false))
- etcd_consumers | map(attribute='name') | unique | length == etcd_consumers | length
- etcd_bao_token | length > 0
no_log: true
- name: 逐个收敛消费者
ansible.builtin.include_tasks: tasks/consumer.yml
loop: "{{ etcd_consumers }}"
loop_control:
loop_var: etcd_consumer
label: "{{ etcd_consumer.name }}"
@@ -1,74 +0,0 @@
---
# 仅在 Terraform 身份配置完成后部署;预检失败不启用定时器。
- name: 安装独立于人工会话的续签调度
hosts: etcd-laptop
become: true
gather_facts: false
tasks:
- name: 创建 root 管理的程序目录
ansible.builtin.file:
path: /opt/homelab-etcd-controller
state: directory
mode: '0755'
- name: 安装证书登录与调度程序
ansible.builtin.copy:
src: "../controller/{{ item }}"
dest: "/opt/homelab-etcd-controller/{{ item }}"
owner: root
group: root
mode: '0755'
loop: [login.py, renew.py]
- name: 以机器身份预检(不输出 token)
ansible.builtin.command:
argv: [/usr/bin/python3, /opt/homelab-etcd-controller/login.py]
changed_when: false
no_log: true
- name: 安装固定的 Ansible 配置副本
ansible.builtin.copy:
src: "{{ playbook_dir }}/"
dest: /opt/homelab-etcd-controller/ansible/
owner: root
group: root
mode: preserve
- name: 创建调度状态目录
ansible.builtin.file:
path: /var/lib/homelab-etcd-controller
state: directory
owner: panxiao81
group: panxiao81
mode: '0700'
- name: 安装续签 oneshot unit
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-etcd-renew.service
mode: '0644'
content: |
[Unit]
Description=Renew shared etcd certificates through Bao machine identity
After=network-online.target
[Service]
Type=oneshot
User=panxiao81
Environment=HOME=/home/panxiao81
Environment=PATH=/home/panxiao81/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/python3 /opt/homelab-etcd-controller/renew.py
TimeoutStartSec=30min
UMask=0077
- name: 安装每日续签 timer
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-etcd-renew.timer
mode: '0644'
content: |
[Unit]
Description=Daily shared etcd certificate renewal check
[Timer]
OnCalendar=*-*-* 04:10:00 UTC
RandomizedDelaySec=15min
Persistent=true
[Install]
WantedBy=timers.target
- name: 启用续签调度
ansible.builtin.systemd_service:
name: homelab-etcd-renew.timer
daemon_reload: true
enabled: true
state: started
@@ -1,20 +0,0 @@
---
etcd_bao_url: https://bao.ad.ddupan.top:8200
etcd_bao_pki_mount: pki
etcd_bao_kv_mount: kv
etcd_bao_secret_base: infra/etcd/consumers
# 只在控制端使用,不下发 Bao token。也不读取或复制历史 .vault_pass。
etcd_bao_token: "{{ lookup('env', 'BAO_TOKEN') }}"
etcd_consumers:
- name: patroni-pg-prod
prefix: /homelab/patroni/pg-prod/
etcd_lxc_template: laptop:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst
etcd_lxc_storage: pve-rg
etcd_lxc_bridge: labnet
etcd_lxc_gateway: 10.60.0.1
etcd_lxc_memory: 512
etcd_lxc_disk_gb: 8
etcd_lxc_pubkey: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
# 与现有 LAN exporter 相同的内网采集边界;禁止映射到公网。
etcd_metrics_urls: "http://127.0.0.1:2381,http://{{ etcd_address }}:2381"
@@ -1,33 +0,0 @@
---
# 已部署成员;共置 PG standby/备份的承载预算,数据库数据用独立 HDD mp0。
all:
vars:
ansible_user: root
children:
etcd:
hosts:
etcd-laptop:
ansible_connection: local
ansible_host: 192.168.10.127
ansible_user: panxiao81
etcd_address: 192.168.10.127
etcd-pve1:
ansible_host: 10.60.0.20
etcd_address: 10.60.0.20
etcd-pve2:
ansible_host: 10.60.0.21
etcd_address: 10.60.0.21
etcd_pve:
hosts:
pve1:
ansible_host: 192.168.10.4
etcd_lxc_vmid: 150
etcd_lxc_hostname: etcd-pve1
etcd_lxc_address: 10.60.0.20/24
etcd_lxc_memory: 1536
pve2:
ansible_host: 192.168.10.7
etcd_lxc_vmid: 151
etcd_lxc_hostname: etcd-pve2
etcd_lxc_address: 10.60.0.21/24
etcd_lxc_memory: 768
-126
View File
@@ -1,126 +0,0 @@
---
# 只创建已声明且尚不存在的 LXC;不接管未知 VMID,不重启现有容器。
- name: 创建独立 etcd LXC
hosts: etcd_pve
become: true
gather_facts: false
tasks:
- name: 读取全局资源,避免 VMID 在其他节点已占用
ansible.builtin.command:
argv: [pvesh, get, /cluster/resources, --type, vm, --output-format, json]
register: etcd_pve_resources
changed_when: false
check_mode: false
- name: 保存同号资源
ansible.builtin.set_fact:
etcd_lxc_existing: >-
{{ etcd_pve_resources.stdout
| from_json
| selectattr('vmid', 'equalto', etcd_lxc_vmid)
| list }}
- name: 拒绝接管未知资源
ansible.builtin.assert:
that:
- >-
etcd_lxc_existing | length == 0 or
(etcd_lxc_existing[0].type == 'lxc' and etcd_lxc_existing[0].node == inventory_hostname
and etcd_lxc_existing[0].name == etcd_lxc_hostname
and 'shared-etcd' in (etcd_lxc_existing[0].tags | default('')))
- name: 新建无特权 LXC
when: etcd_lxc_existing | length == 0 and not ansible_check_mode
block:
- name: 暂存 SSH 公钥
ansible.builtin.copy:
content: "{{ etcd_lxc_pubkey }}\n"
dest: /run/shared-etcd-bootstrap.pub
mode: '0600'
- name: 创建声明的容器
ansible.builtin.command:
argv:
- pct
- create
- "{{ etcd_lxc_vmid }}"
- "{{ etcd_lxc_template }}"
- --hostname
- "{{ etcd_lxc_hostname }}"
- --unprivileged
- '1'
- --cores
- '1'
- --memory
- "{{ etcd_lxc_memory }}"
- --swap
- '0'
- --rootfs
- "{{ etcd_lxc_storage }}:{{ etcd_lxc_disk_gb }}"
- --net0
- "name=eth0,bridge={{ etcd_lxc_bridge }},ip={{ etcd_lxc_address }},gw={{ etcd_lxc_gateway }},type=veth"
- --nameserver
- 192.168.10.5
- --searchdomain
- ad.ddupan.top
- --ssh-public-keys
- /run/shared-etcd-bootstrap.pub
- --onboot
- '1'
- --tags
- ansible;shared-etcd
changed_when: true
always:
- name: 删除暂存公钥
ansible.builtin.file:
path: /run/shared-etcd-bootstrap.pub
state: absent
- name: 读取容器配置
ansible.builtin.command:
argv: [pct, config, "{{ etcd_lxc_vmid }}"]
changed_when: false
register: etcd_lxc_config
when: not ansible_check_mode or etcd_lxc_existing | length > 0
- name: 配置漂移先报错,不直接改运行中的网络/资源
ansible.builtin.assert:
that:
- >-
('ip=' ~ etcd_lxc_address ~ ',') in etcd_lxc_config.stdout or
('ip=' ~ etcd_lxc_address ~ '\n') in etcd_lxc_config.stdout
- "('bridge=' ~ etcd_lxc_bridge ~ ',') in etcd_lxc_config.stdout"
- "'unprivileged: 1' in etcd_lxc_config.stdout"
- "('memory: ' ~ etcd_lxc_memory) in etcd_lxc_config.stdout"
- "('rootfs: ' ~ etcd_lxc_storage ~ ':') in etcd_lxc_config.stdout"
when: etcd_lxc_config is not skipped
- name: 读取容器运行状态
ansible.builtin.command:
argv: [pct, status, "{{ etcd_lxc_vmid }}"]
changed_when: false
register: etcd_lxc_status
when: not ansible_check_mode
- name: 启动容器
ansible.builtin.command:
argv: [pct, start, "{{ etcd_lxc_vmid }}"]
changed_when: true
when: not ansible_check_mode and 'running' not in etcd_lxc_status.stdout
- name: 通过可信宿主机取得容器 SSH 公钥
ansible.builtin.command:
argv: [pct, exec, "{{ etcd_lxc_vmid }}", --, cat, /etc/ssh/ssh_host_ed25519_key.pub]
register: etcd_lxc_hostkey
changed_when: false
retries: 12
delay: 5
until: etcd_lxc_hostkey.rc == 0
when: not ansible_check_mode
- name: 保存经宿主机验证的 SSH host key
ansible.builtin.known_hosts:
name: "{{ etcd_lxc_address.split('/')[0] }}"
key: "{{ etcd_lxc_address.split('/')[0] }} {{ etcd_lxc_hostkey.stdout }}"
delegate_to: localhost
become: false
when: not ansible_check_mode
@@ -1,90 +0,0 @@
---
# 首次部署承载调整,逐节点迁移;普通 lxc.yml 不自动移动磁盘。
- name: 逐个将新 etcd 容器迁入声明的 SSD 池
hosts: etcd_pve
become: true
gather_facts: false
serial: 1
any_errors_fatal: true
vars:
etcd_move_health_command:
- /opt/homelab-etcd/etcdctl
- --endpoints=https://192.168.10.127:2379,https://10.60.0.20:2379,https://10.60.0.21:2379
- --cacert=/etc/homelab-etcd/ca.crt
- --cert=/etc/homelab-etcd/admin.crt
- --key=/etc/homelab-etcd/admin.key
- endpoint
- health
tasks:
- name: 核对容器配置
ansible.builtin.command:
argv: [pct, config, "{{ etcd_lxc_vmid }}"]
register: etcd_move_config
changed_when: false
check_mode: false
- name: 限定本项目新建容器与允许的源池
ansible.builtin.assert:
that:
- etcd_lxc_vmid in [150, 151]
- "('hostname: ' ~ etcd_lxc_hostname) in etcd_move_config.stdout"
- "'shared-etcd' in etcd_move_config.stdout"
- "'rootfs: local-lvm:' in etcd_move_config.stdout or 'rootfs: pve-rg:' in etcd_move_config.stdout"
- etcd_lxc_storage == 'pve-rg'
- name: 验证迁移前全部端点健康
ansible.builtin.command:
argv: "{{ etcd_move_health_command }}"
delegate_to: localhost
changed_when: false
check_mode: false
- name: 迁移当前尚在本地池的根卷
when: "'rootfs: local-lvm:' in etcd_move_config.stdout and not ansible_check_mode"
block:
- name: 创建已验证的集群快照
ansible.builtin.command:
argv: [systemctl, start, homelab-etcd-snapshot.service]
delegate_to: localhost
changed_when: true
- name: 正常关闭一个容器
ansible.builtin.command:
argv: [pct, shutdown, "{{ etcd_lxc_vmid }}", --timeout, '60']
changed_when: true
- name: 复制成功后移除该新建容器的原卷
ansible.builtin.command:
argv:
- pct
- move-volume
- "{{ etcd_lxc_vmid }}"
- rootfs
- "{{ etcd_lxc_storage }}"
- --delete
- '1'
- --bwlimit
- '32768'
changed_when: true
always:
- name: 核对容器运行状态
ansible.builtin.command:
argv: [pct, status, "{{ etcd_lxc_vmid }}"]
register: etcd_move_status
changed_when: false
- name: 重新启动容器
ansible.builtin.command:
argv: [pct, start, "{{ etcd_lxc_vmid }}"]
when: "'running' not in etcd_move_status.stdout"
changed_when: true
- name: 等待当前成员回归
ansible.builtin.command:
argv: "{{ etcd_move_health_command }}"
delegate_to: localhost
changed_when: false
register: etcd_move_health
retries: 18
delay: 5
until: etcd_move_health.rc == 0
when: not ansible_check_mode
@@ -1,4 +0,0 @@
---
collections:
- name: community.crypto
version: 3.2.1
@@ -1,17 +0,0 @@
---
etcd_version: 3.7.2
etcd_archive_checksum: sha256:3a3679bc51a4ee9d30bccea1da7cd4fe62c6fc1d2ca1255068d2c53bf3026135
etcd_install_dir: /opt/homelab-etcd
etcd_config_dir: /etc/homelab-etcd
etcd_data_dir: /var/lib/homelab-etcd
etcd_cluster_token: homelab-shared-etcd-v1
etcd_client_port: 2379
etcd_peer_port: 2380
etcd_metrics_port: 2381
etcd_quota_bytes: 268435456
etcd_memory_high: 256M
etcd_memory_max: 384M
etcd_certificate_ttl: 1440h
etcd_renew_before: +14d
etcd_snapshot_dir: /var/backups/homelab-etcd
etcd_snapshot_keep: 3
@@ -1,93 +0,0 @@
---
- name: 在成员本地生成私钥
community.crypto.openssl_privatekey:
path: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.key"
type: ECC
curve: secp256r1
owner: root
group: "{{ etcd_cert.group }}"
mode: '0640'
- name: 本地生成 CSR
community.crypto.openssl_csr:
path: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.csr"
privatekey_path: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.key"
common_name: "{{ etcd_cert.cn | default(omit, true) }}"
subject_alt_name: >-
{{ ['IP:' ~ etcd_address, 'DNS:' ~ inventory_hostname] if etcd_cert.name in ['server', 'peer']
else (['DNS:' ~ inventory_hostname] if etcd_cert.name == 'gateway' else []) }}
use_common_name_for_san: false
extended_key_usage: "{{ etcd_cert.eku }}"
key_usage: [digitalSignature]
mode: '0644'
register: etcd_csr_state
- name: 检查已有证书
ansible.builtin.stat:
path: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.crt"
register: etcd_cert_file
- name: 检查续签窗口
community.crypto.x509_certificate_info:
path: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.crt"
valid_at:
renewal: "{{ etcd_renew_before }}"
register: etcd_cert_info
when: etcd_cert_file.stat.exists
- name: 通过中央 CA 签发需更新的证书
when: >-
not etcd_cert_file.stat.exists or etcd_csr_state is changed or
not (etcd_cert_info.valid_at.renewal | default(false))
block:
- name: 仅在确需签发时要求 Bao 凭据
ansible.builtin.assert:
that: etcd_bao_token | length > 0
no_log: true
- name: 读取 CSR 公共内容
ansible.builtin.slurp:
src: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.csr"
register: etcd_csr
- name: 控制端提交 Bao 签名请求
ansible.builtin.uri:
url: "{{ etcd_bao_url }}/v1/{{ etcd_bao_pki_mount }}/sign/{{ etcd_cert.role }}"
method: POST
headers:
X-Vault-Token: "{{ etcd_bao_token }}"
body_format: json
body:
csr: "{{ etcd_csr.content | b64decode }}"
ttl: "{{ etcd_certificate_ttl }}"
status_code: 200
delegate_to: localhost
become: false
register: etcd_signed
no_log: true
when: not ansible_check_mode
- name: 保存签发的证书链
ansible.builtin.copy:
content: |
{{ etcd_signed.json.data.certificate }}
{{ etcd_signed.json.data.ca_chain | join('\n') }}
dest: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.crt"
owner: root
group: "{{ etcd_cert.group }}"
mode: '0644'
when: not ansible_check_mode
- name: 保存中央 CA 信任链
ansible.builtin.copy:
content: |
{{ etcd_signed.json.data.ca_chain | join('\n') }}
dest: "{{ etcd_config_dir }}/ca.crt"
owner: root
group: homelab-etcd
mode: '0644'
when: not ansible_check_mode
- name: 标记证书已更新
ansible.builtin.set_fact:
etcd_certificates_changed: true
@@ -1,3 +0,0 @@
---
# 只加载 role defaults,供运维入口复用。
[]
@@ -1,134 +0,0 @@
---
- name: 验证拓扑和签发配置
ansible.builtin.assert:
that:
- groups['etcd'] | length == 3
- groups['etcd'] | map('extract', hostvars, 'etcd_address') | unique | length == 3
- ansible_facts['architecture'] == 'x86_64'
- etcd_bao_url is match('^https://')
- etcd_archive_checksum is match('^sha256:[a-f0-9]{64}$')
no_log: true
- name: 安装证书处理依赖
ansible.builtin.package:
name: [python3-cryptography, openssl]
state: present
- name: 创建独立 etcd 组
ansible.builtin.group:
name: homelab-etcd
system: true
- name: 创建独立 etcd 用户
ansible.builtin.user:
name: homelab-etcd
group: homelab-etcd
system: true
shell: /usr/sbin/nologin
create_home: false
- name: 创建受管目录
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: homelab-etcd
mode: "{{ item.mode }}"
loop:
- {path: "{{ etcd_config_dir }}", owner: root, mode: '0750'}
- {path: "{{ etcd_data_dir }}", owner: homelab-etcd, mode: '0700'}
- {path: "{{ etcd_install_dir }}", owner: root, mode: '0755'}
- name: 下载固定版本及校验归档
ansible.builtin.get_url:
url: >-
https://github.com/etcd-io/etcd/releases/download/v{{ etcd_version }}/etcd-v{{ etcd_version }}-linux-amd64.tar.gz
dest: "{{ etcd_install_dir }}/etcd-v{{ etcd_version }}.tar.gz"
checksum: "{{ etcd_archive_checksum }}"
mode: '0644'
register: etcd_download
retries: 3
delay: 5
until: etcd_download is succeeded
- name: 展开固定版本
ansible.builtin.unarchive:
src: "{{ etcd_install_dir }}/etcd-v{{ etcd_version }}.tar.gz"
dest: "{{ etcd_install_dir }}"
remote_src: true
creates: "{{ etcd_install_dir }}/etcd-v{{ etcd_version }}-linux-amd64/etcd"
- name: 安装版本链接
ansible.builtin.file:
src: "{{ etcd_install_dir }}/etcd-v{{ etcd_version }}-linux-amd64/{{ item }}"
dest: "{{ etcd_install_dir }}/{{ item }}"
state: link
loop: [etcd, etcdctl, etcdutl]
register: etcd_binary_links
- name: 签发成员与管理员证书
ansible.builtin.include_tasks: certificate.yml
loop:
- {name: server, role: homelab-etcd-server, cn: "{{ inventory_hostname }}", eku: [serverAuth], group: homelab-etcd}
- {name: peer, role: homelab-etcd-peer, cn: homelab-etcd-peer, eku: [serverAuth, clientAuth], group: homelab-etcd}
- {name: gateway, role: homelab-etcd-gateway, cn: "", eku: [clientAuth], group: homelab-etcd}
- {name: admin, role: homelab-etcd-admin, cn: root, eku: [clientAuth], group: root}
loop_control:
loop_var: etcd_cert
- name: 写入独立 etcd 配置
ansible.builtin.template:
src: etcd.yml.j2
dest: "{{ etcd_config_dir }}/etcd.yml"
owner: root
group: homelab-etcd
mode: '0640'
register: etcd_config_file
- name: 写入独立 systemd unit
ansible.builtin.template:
src: homelab-etcd.service.j2
dest: /etc/systemd/system/homelab-etcd.service
mode: '0644'
register: etcd_unit
# 跨失败重跑记录激活状态,防止上一轮写文件后中断导致漏掉必要重启。
- name: 计算受管文件校验和
ansible.builtin.stat:
path: "{{ item }}"
checksum_algorithm: sha256
loop:
- "{{ etcd_config_dir }}/etcd.yml"
- "{{ etcd_config_dir }}/server.crt"
- "{{ etcd_config_dir }}/peer.crt"
- "{{ etcd_config_dir }}/gateway.crt"
- "{{ etcd_config_dir }}/ca.crt"
- /etc/systemd/system/homelab-etcd.service
register: etcd_managed_files
- name: 计算期望激活指纹
ansible.builtin.set_fact:
etcd_config_fingerprint: >-
{{ ((etcd_managed_files.results | map(attribute='stat.checksum') | list | join(':'))
~ ':' ~ etcd_version) | hash('sha256') }}
when: not ansible_check_mode
- name: 检查已激活指纹
ansible.builtin.stat:
path: "{{ etcd_config_dir }}/activated.sha256"
register: etcd_activated_file
- name: 读取已激活指纹
ansible.builtin.slurp:
src: "{{ etcd_config_dir }}/activated.sha256"
register: etcd_activated
when: etcd_activated_file.stat.exists
- name: 判断是否需要滚动激活
ansible.builtin.set_fact:
etcd_config_changed: >-
{{ not etcd_activated_file.stat.exists or
(etcd_activated.content | default('') | b64decode | trim) != etcd_config_fingerprint | default('check-mode') }}
- name: 管理本地快照任务
ansible.builtin.import_tasks: snapshot.yml
@@ -1,26 +0,0 @@
---
- name: 创建仅 root 可访问的快照目录
ansible.builtin.file:
path: "{{ etcd_snapshot_dir }}"
state: directory
owner: root
group: root
mode: '0700'
- name: 写入快照脚本和 systemd 任务
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- {src: snapshot.sh.j2, dest: "{{ etcd_install_dir }}/snapshot", mode: '0700'}
- {src: snapshot.service.j2, dest: /etc/systemd/system/homelab-etcd-snapshot.service, mode: '0644'}
- {src: snapshot.timer.j2, dest: /etc/systemd/system/homelab-etcd-snapshot.timer, mode: '0644'}
register: etcd_snapshot_units
- name: 启用本地快照计划
ansible.builtin.systemd_service:
name: homelab-etcd-snapshot.timer
daemon_reload: "{{ etcd_snapshot_units is changed }}"
enabled: true
state: started
@@ -1,33 +0,0 @@
# Ansible 管理;与 k3s、数据库生命周期独立。
name: {{ inventory_hostname | to_json }}
data-dir: {{ etcd_data_dir | to_json }}
listen-client-urls: https://{{ etcd_address }}:{{ etcd_client_port }}
advertise-client-urls: https://{{ etcd_address }}:{{ etcd_client_port }}
listen-peer-urls: https://{{ etcd_address }}:{{ etcd_peer_port }}
initial-advertise-peer-urls: https://{{ etcd_address }}:{{ etcd_peer_port }}
initial-cluster: "{% for member in groups['etcd'] %}{{ member }}=https://{{ hostvars[member].etcd_address }}:{{ etcd_peer_port }}{{ ',' if not loop.last else '' }}{% endfor %}"
initial-cluster-token: {{ etcd_cluster_token | to_json }}
initial-cluster-state: new
# 已存在的数据目录优先;成员替换必须走单独 runbook,不删除数据重建。
client-transport-security:
cert-file: {{ etcd_config_dir }}/server.crt
key-file: {{ etcd_config_dir }}/server.key
client-cert-file: {{ etcd_config_dir }}/gateway.crt
client-key-file: {{ etcd_config_dir }}/gateway.key
trusted-ca-file: {{ etcd_config_dir }}/ca.crt
client-cert-auth: true
peer-transport-security:
cert-file: {{ etcd_config_dir }}/peer.crt
key-file: {{ etcd_config_dir }}/peer.key
trusted-ca-file: {{ etcd_config_dir }}/ca.crt
client-cert-auth: true
allowed-cn: [homelab-etcd-peer]
# 独立 metrics listener 仅提供指标/健康,不开放 KV API;只绑定受管内网地址。
listen-metrics-urls: {{ etcd_metrics_urls | default("http://127.0.0.1:" ~ etcd_metrics_port) | to_json }}
quota-backend-bytes: {{ etcd_quota_bytes }}
auto-compaction-mode: periodic
auto-compaction-retention: '1h'
heartbeat-interval: 100
election-timeout: 1000
logger: zap
log-level: info
@@ -1,23 +0,0 @@
[Unit]
Description=Homelab shared etcd
Wants=network-online.target
After=network-online.target
[Service]
User=homelab-etcd
Group=homelab-etcd
ExecStart={{ etcd_install_dir }}/etcd --config-file={{ etcd_config_dir }}/etcd.yml
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
MemoryHigh={{ etcd_memory_high }}
MemoryMax={{ etcd_memory_max }}
UMask=0077
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths={{ etcd_data_dir }}
[Install]
WantedBy=multi-user.target
@@ -1,11 +0,0 @@
[Unit]
Description=Snapshot homelab shared etcd
After=homelab-etcd.service
[Service]
Type=oneshot
ExecStart={{ etcd_install_dir }}/snapshot
User=root
UMask=0077
TimeoutStartSec=300
Nice=10
@@ -1,20 +0,0 @@
#!/bin/bash
set -euo pipefail
umask 077
export ETCDCTL_ENDPOINTS="https://{{ etcd_address }}:{{ etcd_client_port }}"
export ETCDCTL_CACERT="{{ etcd_config_dir }}/ca.crt"
export ETCDCTL_CERT="{{ etcd_config_dir }}/admin.crt"
export ETCDCTL_KEY="{{ etcd_config_dir }}/admin.key"
repo="{{ etcd_snapshot_dir }}"
exec 9>"$repo/.lock"
flock -n 9 || exit 0
output="$repo/$(date -u +%Y%m%dT%H%M%SZ).db"
trap 'rm -f "$output.partial" "$output.partial.part"' EXIT
{{ etcd_install_dir }}/etcdctl snapshot save "$output.partial"
{{ etcd_install_dir }}/etcdutl snapshot status "$output.partial" >/dev/null
mv "$output.partial" "$output"
# 只有新快照成功且验证可读才清理旧备份。目录仅由此任务管理。
mapfile -t snapshots < <(find "$repo" -maxdepth 1 -type f -name '????????T??????Z.db' -printf '%f\n' | sort -r)
for old in "${snapshots[@]:{{ etcd_snapshot_keep }}}"; do
rm -- "$repo/$old"
done
@@ -1,10 +0,0 @@
[Unit]
Description=Daily homelab etcd snapshot
[Timer]
OnCalendar=*-*-* 03:20:00 UTC
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target
-66
View File
@@ -1,66 +0,0 @@
---
# 先配置全体,再滚动启动:首次集群形成不能在第一个节点等待 quorum。
- name: 配置 shared etcd 成员
hosts: etcd
become: true
roles:
- shared_etcd
- name: 滚动启动并验证 shared etcd
hosts: etcd
become: true
serial: 1
pre_tasks:
- name: 加载共享默认参数
ansible.builtin.import_role:
name: shared_etcd
tasks_from: context
tasks:
- name: 启动已配置的成员
ansible.builtin.systemd_service:
name: homelab-etcd
enabled: true
daemon_reload: true
state: "{{ 'restarted' if etcd_config_changed | bool else 'started' }}"
- name: 等待本机客户端端口
ansible.builtin.wait_for:
host: "{{ etcd_address }}"
port: "{{ etcd_client_port }}"
timeout: 60
when: not ansible_check_mode
- name: 已有集群每次激活后等待本成员恢复 quorum 通信
ansible.builtin.command:
argv:
- "{{ etcd_install_dir }}/etcdctl"
- --endpoints=https://{{ etcd_address }}:{{ etcd_client_port }}
- --cacert={{ etcd_config_dir }}/ca.crt
- --cert={{ etcd_config_dir }}/admin.crt
- --key={{ etcd_config_dir }}/admin.key
- endpoint
- health
changed_when: false
register: etcd_member_health
retries: 12
delay: 5
until: etcd_member_health.rc == 0
when: etcd_activated_file.stat.exists and not ansible_check_mode
- name: 核对全部成员
ansible.builtin.import_playbook: verify.yml
- name: 记录已成功激活的配置
hosts: etcd
become: true
pre_tasks:
- name: 加载共享默认参数
ansible.builtin.import_role:
name: shared_etcd
tasks_from: context
tasks:
- name: 写入激活指纹(全体健康检查通过后)
ansible.builtin.copy:
content: "{{ etcd_config_fingerprint }}\n"
dest: "{{ etcd_config_dir }}/activated.sha256"
mode: '0644'
when: not ansible_check_mode
@@ -1,170 +0,0 @@
---
- name: 验证消费者范围
ansible.builtin.assert:
that:
- etcd_consumer.name is match('^[a-z][a-z0-9-]+$')
- etcd_consumer.name != 'root'
- etcd_consumer.prefix is match('^/homelab/[a-zA-Z0-9/_-]+/$')
- etcd_consumer.prefix | length > 10
- name: 读取已有用户和角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, "{{ item }}", list]
loop: [user, role]
register: etcd_identities
changed_when: false
check_mode: false
# 404 data 也可能代表被删除/销毁的旧秘密;只有 metadata 不存在才允许生成。
- name: 控制端读取 Bao 秘密元数据
ansible.builtin.uri:
url: "{{ etcd_bao_url }}/v1/{{ etcd_bao_kv_mount }}/metadata/{{ etcd_bao_secret_base }}/{{ etcd_consumer.name }}"
headers:
X-Vault-Token: "{{ etcd_bao_token }}"
status_code: [200, 404]
delegate_to: localhost
become: false
no_log: true
register: etcd_secret_metadata
check_mode: false
- name: 禁止已有用户丢失秘密后自动换密码
ansible.builtin.assert:
that: >-
etcd_secret_metadata.status == 200 or
etcd_consumer.name not in ((etcd_identities.results[0].stdout | from_json).users | default([], true))
fail_msg: etcd 用户已存在但 Bao 秘密缺失;需恢复原秘密或执行显式轮换。
- name: 首次创建随机秘密且禁止覆盖已有版本
ansible.builtin.uri:
url: "{{ etcd_bao_url }}/v1/{{ etcd_bao_kv_mount }}/data/{{ etcd_bao_secret_base }}/{{ etcd_consumer.name }}"
method: POST
headers:
X-Vault-Token: "{{ etcd_bao_token }}"
body_format: json
body:
options: {cas: 0}
data:
username: "{{ etcd_consumer.name }}"
password: "{{ lookup('ansible.builtin.password', '/dev/null', length=48, chars=['ascii_letters', 'digits']) }}"
prefix: "{{ etcd_consumer.prefix }}"
status_code: 200
delegate_to: localhost
become: false
no_log: true
changed_when: true
when:
- etcd_secret_metadata.status == 404
- not ansible_check_mode
- name: 控制端读取既有秘密
ansible.builtin.uri:
url: "{{ etcd_bao_url }}/v1/{{ etcd_bao_kv_mount }}/data/{{ etcd_bao_secret_base }}/{{ etcd_consumer.name }}"
headers:
X-Vault-Token: "{{ etcd_bao_token }}"
status_code: 200
delegate_to: localhost
become: false
register: etcd_consumer_secret
no_log: true
when: etcd_secret_metadata.status == 200 or not ansible_check_mode
check_mode: false
- name: 核对已保存秘密归属
ansible.builtin.assert:
that:
- etcd_consumer_secret.json.data.data.username == etcd_consumer.name
- etcd_consumer_secret.json.data.data.prefix == etcd_consumer.prefix
- etcd_consumer_secret.json.data.data.password | length >= 32
no_log: true
when: etcd_secret_metadata.status == 200 or not ansible_check_mode
- name: 新建消费者用户(密码只通过 stdin 传递)
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", user, add, "{{ etcd_consumer.name }}", --interactive=false]
stdin: "{{ etcd_consumer_secret.json.data.data.password }}"
no_log: true
changed_when: true
when:
- etcd_consumer.name not in ((etcd_identities.results[0].stdout | from_json).users | default([], true))
- not ansible_check_mode
- name: 新建消费者角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", role, add, "{{ etcd_consumer.name }}"]
changed_when: true
when: etcd_consumer.name not in ((etcd_identities.results[1].stdout | from_json).roles | default([], true))
- name: 读取角色权限
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, role, get, "{{ etcd_consumer.name }}"]
register: etcd_role_state
changed_when: false
when: >-
not ansible_check_mode or
etcd_consumer.name in ((etcd_identities.results[1].stdout | from_json).roles | default([], true))
- name: 拒绝不符合声明的既有权限(不自动扩大或删除)
ansible.builtin.assert:
that:
- >-
((etcd_role_state.stdout | from_json).perm | default([], true)) in
[[], [{'permType': 2, 'key': etcd_consumer.prefix | b64encode,
'range_end': (etcd_consumer.prefix[:-1] ~ '0') | b64encode}]]
fail_msg: 既有角色权限与声明不同,请显式审查权限迁移。
when: etcd_role_state is not skipped
- name: 授予唯一 prefix 读写权限
ansible.builtin.command:
argv:
- "{{ etcd_install_dir }}/etcdctl"
- role
- grant-permission
- "{{ etcd_consumer.name }}"
- readwrite
- "{{ etcd_consumer.prefix }}"
- --prefix=true
changed_when: true
when:
- etcd_role_state is not skipped
- ((etcd_role_state.stdout | from_json).perm | default([], true)) | length == 0
- name: 读取用户角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, user, get, "{{ etcd_consumer.name }}"]
register: etcd_user_state
changed_when: false
when: >-
not ansible_check_mode or
etcd_consumer.name in ((etcd_identities.results[0].stdout | from_json).users | default([], true))
- name: 拒绝消费者已有额外角色
ansible.builtin.assert:
that: >-
((etcd_user_state.stdout | from_json).roles | default([], true))
| difference([etcd_consumer.name]) | length == 0
when: etcd_user_state is not skipped
- name: 绑定消费者角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", user, grant-role, "{{ etcd_consumer.name }}", "{{ etcd_consumer.name }}"]
changed_when: true
when:
- etcd_user_state is not skipped
- etcd_consumer.name not in ((etcd_user_state.stdout | from_json).roles | default([], true))
# 使用 gateway 的真实密码登录来核对 Bao 与 etcd 一致性,不能只看用户已存在。
- name: 验证消费者密码可经 gateway 登录
ansible.builtin.uri:
url: "https://{{ etcd_address }}:{{ etcd_client_port }}/v3/auth/authenticate"
method: POST
client_cert: "{{ etcd_config_dir }}/gateway.crt"
client_key: "{{ etcd_config_dir }}/gateway.key"
ca_path: "{{ etcd_config_dir }}/ca.crt"
body_format: json
body:
name: "{{ etcd_consumer.name }}"
password: "{{ etcd_consumer_secret.json.data.data.password }}"
status_code: 200
no_log: true
when: not ansible_check_mode
-29
View File
@@ -1,29 +0,0 @@
---
- name: 验证 shared etcd 全部端点
hosts: etcd[0]
become: true
gather_facts: false
pre_tasks:
- name: 加载共享默认参数
ansible.builtin.import_role:
name: shared_etcd
tasks_from: context
tasks:
- name: 通过管理员 mTLS 检查所有端点健康
ansible.builtin.command:
argv:
- "{{ etcd_install_dir }}/etcdctl"
- >-
--endpoints={{ groups['etcd'] | map('extract', hostvars, 'etcd_address')
| map('regex_replace', '^(.*)$', 'https://\1:' ~ etcd_client_port) | join(',') }}
- --cacert={{ etcd_config_dir }}/ca.crt
- --cert={{ etcd_config_dir }}/admin.crt
- --key={{ etcd_config_dir }}/admin.key
- endpoint
- health
changed_when: false
register: etcd_health
retries: 12
delay: 5
until: etcd_health.rc == 0
when: not ansible_check_mode
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/python3
"""仅由受控调用者捕获 stdout;不得手动运行以免在终端输出短期 token。"""
import json
import ssl
import sys
import urllib.request
def main():
context = ssl.create_default_context()
context.load_cert_chain('/etc/homelab-etcd/peer.crt', '/etc/homelab-etcd/peer.key')
request = urllib.request.Request(
'https://bao.ad.ddupan.top:8200/v1/auth/homelab-etcd-renewal/login',
data=json.dumps({'name': 'etcd-laptop'}).encode(),
headers={'Content-Type': 'application/json'}, method='POST',
)
with urllib.request.urlopen(request, context=context, timeout=30) as response:
token = json.load(response)['auth']['client_token']
if not isinstance(token, str) or not token:
raise ValueError('empty token')
sys.stdout.write(token)
if __name__ == '__main__':
try:
main()
except Exception:
# Bao 响应和异常对象可能带敏感内容,不写入 journal。
sys.stderr.write('Bao certificate login failed\n')
sys.exit(1)
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/python3
"""每日一次,机器证书换短期 token;使用现有串行 Ansible 收敛,不常驻 agent。"""
import fcntl
import os
from pathlib import Path
import subprocess
import sys
import urllib.request
ROOT = Path('/opt/homelab-etcd-controller')
def main():
with open('/var/lib/homelab-etcd-controller/renew.lock', 'a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return 0
login = subprocess.run(
['sudo', '-n', '/usr/bin/python3', str(ROOT / 'login.py')],
capture_output=True, text=True, timeout=40,
)
if login.returncode or not login.stdout.strip():
print('Bao 机器证书登录失败;保留现有证书与运行中的 etcd。', file=sys.stderr)
return 1
token = login.stdout.strip()
env = dict(os.environ, BAO_TOKEN=token)
try:
for play in ['verify.yml', 'site.yml']:
result = subprocess.run(
['/home/panxiao81/.local/bin/ansible-playbook', play],
cwd=ROOT / 'ansible', env=env, timeout=750,
)
if result.returncode:
return result.returncode
Path('/var/lib/homelab-etcd-controller/last-success').touch()
return 0
finally:
request = urllib.request.Request(
'https://bao.ad.ddupan.top:8200/v1/auth/token/revoke-self',
data=b'{}', headers={'X-Vault-Token': token}, method='POST',
)
try:
with urllib.request.urlopen(request, timeout=15):
pass
except Exception:
print('短期 token 撤销未确认,将由 TTL 自动失效。', file=sys.stderr)
if __name__ == '__main__':
raise SystemExit(main())
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env python3
"""从当前 Bao 登录会话向子进程传递凭据,不写临时秘密文件或输出秘密。"""
import json
import os
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parent
def main():
if len(sys.argv) < 3 or sys.argv[1] not in ('terraform', 'ansible'):
raise SystemExit('用法:python3 run.py terraform <args> | ansible <playbook> [args]')
env = dict(os.environ)
env.setdefault('BAO_ADDR', 'https://bao.ad.ddupan.top:8200')
token = env.get('BAO_TOKEN') or env.get('VAULT_TOKEN')
if not token:
token = Path('~/.vault-token').expanduser().read_text().strip()
env['BAO_TOKEN'] = env['VAULT_TOKEN'] = token
if sys.argv[1] == 'terraform':
response = subprocess.run(
['bao', 'kv', 'get', '-format=json', 'kv/k8s/seaweedfs-s3'],
env=env, capture_output=True, text=True,
)
if response.returncode:
raise SystemExit('读取 tfstate 受限身份失败;请检查 Bao 登录和授权。')
config = json.loads(response.stdout)['data']['data']['seaweedfs_s3_config']
config = json.loads(config) if isinstance(config, str) else config
identities = [i for i in config['identities'] if i['name'] == 'terraform']
if len(identities) != 1 or len(identities[0]['credentials']) != 1:
raise SystemExit('tfstate 身份不唯一,拒绝猜测凭据。')
credential = identities[0]['credentials'][0]
env['AWS_ACCESS_KEY_ID'] = credential['accessKey']
env['AWS_SECRET_ACCESS_KEY'] = credential['secretKey']
command, cwd = ['terraform', *sys.argv[2:]], ROOT / 'terraform'
else:
command, cwd = ['ansible-playbook', *sys.argv[2:]], ROOT / 'ansible'
raise SystemExit(subprocess.run(command, cwd=cwd, env=env).returncode)
if __name__ == '__main__':
main()
-22
View File
@@ -1,22 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/vault" {
version = "4.8.0"
constraints = "~> 4.0"
hashes = [
"h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=",
"zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6",
"zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136",
"zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25",
"zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf",
"zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937",
"zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c",
"zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c",
"zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98",
"zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952",
"zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83",
]
}
-15
View File
@@ -1,15 +0,0 @@
# 与既有服务复用受限 tfstate 身份,使用独立对象与原生锁;不复用 Bao 的 state。
terraform {
backend "s3" {
bucket = "tfstate"
key = "etcd/terraform.tfstate"
endpoints = { s3 = "https://s3.ad.ddupan.top" }
region = "us-east-1"
use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
use_lockfile = true
}
}
-93
View File
@@ -1,93 +0,0 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "~> 4.0"
}
}
}
provider "vault" {
address = var.bao_address
}
variable "bao_address" {
type = string
default = "https://bao.ad.ddupan.top:8200"
}
variable "pki_mount" {
type = string
default = "pki"
}
variable "kv_mount" {
type = string
default = "kv"
}
variable "member_names" {
type = list(string)
default = ["etcd-laptop", "etcd-pve1", "etcd-pve2"]
}
variable "consumer_names" {
type = set(string)
default = ["patroni-pg-prod"]
}
# 仅管理既有 PKI 下的新 role/policy,不纳管 CA 私钥、mount 或秘密值。
locals {
certificate_roles = {
server = { names = var.member_names, server = true, client = false, ips = true }
peer = { names = concat(var.member_names, ["homelab-etcd-peer"]), server = true, client = true, ips = true }
admin = { names = ["root"], server = false, client = true, ips = false }
gateway = { names = var.member_names, server = false, client = true, ips = false }
client = { names = [for name in var.consumer_names : "etcd-${name}"], server = false, client = true, ips = false }
}
}
resource "vault_pki_secret_backend_role" "etcd" {
for_each = local.certificate_roles
backend = var.pki_mount
name = "homelab-etcd-${each.key}"
allowed_domains = each.value.names
allow_bare_domains = true
allow_subdomains = false
allow_glob_domains = false
allow_any_name = false
allow_localhost = false
allow_wildcard_certificates = false
allow_ip_sans = each.value.ips
server_flag = each.value.server
client_flag = each.value.client
key_type = "ec"
key_bits = 256
ttl = 5184000
max_ttl = 5184000
require_cn = !contains(["gateway", "client"], each.key)
use_csr_common_name = true
use_csr_sans = true
}
resource "vault_policy" "etcd_provisioner" {
name = "homelab-etcd-provisioner"
policy = <<-EOT
path "${var.pki_mount}/sign/homelab-etcd-*" {
capabilities = ["update"]
}
path "${var.kv_mount}/metadata/infra/etcd/consumers/*" {
capabilities = ["read"]
}
path "${var.kv_mount}/data/infra/etcd/consumers/*" {
capabilities = ["create", "read", "update"]
}
EOT
}
# 身份绑定沿用既有控制端认证方式,创建 policy 不自动授权任何身份。
resource "vault_policy" "etcd_consumer" {
for_each = var.consumer_names
name = "homelab-etcd-${each.key}"
policy = <<-EOT
path "${var.kv_mount}/data/infra/etcd/consumers/${each.key}" {
capabilities = ["read"]
}
EOT
}
-28
View File
@@ -1,28 +0,0 @@
# 独立挂载:不接管全局认证,不保存长期 token,也不授权 KV 消费者秘密读取。
data "vault_generic_secret" "etcd_ca" {
path = "${var.pki_mount}/cert/ca"
}
resource "vault_auth_backend" "etcd_renewal" {
type = "cert"
path = "homelab-etcd-renewal"
}
resource "vault_policy" "etcd_renewal" {
name = "homelab-etcd-renewal"
policy = join("\n", concat([
for kind in ["server", "peer", "admin", "gateway"] :
"path \"${var.pki_mount}/sign/homelab-etcd-${kind}\" { capabilities = [\"update\"] }"
], ["path \"auth/token/revoke-self\" { capabilities = [\"update\"] }"]))
}
resource "vault_cert_auth_backend_role" "etcd_renewal" {
backend = vault_auth_backend.etcd_renewal.path
name = "etcd-laptop"
certificate = data.vault_generic_secret.etcd_ca.data["certificate"]
allowed_dns_sans = ["etcd-laptop"]
token_policies = [vault_policy.etcd_renewal.name]
token_no_default_policy = true
token_ttl = 600
token_max_ttl = 900
}
-233
View File
@@ -1,233 +0,0 @@
#!/usr/bin/env python3
"""临时三成员 mTLS/RBAC 集成测试;仅绑定 loopback,不访问生产 Bao。"""
import base64
import json
import os
from pathlib import Path
import re
import shutil
import ssl
import subprocess
import tempfile
import threading
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import jinja2
import yaml
ROOT = Path(__file__).resolve().parents[1]
BIN = Path(os.environ.get('ETCD_TEST_BIN', '/tmp/etcd-v3.7.2-linux-amd64'))
def run(argv, **kwargs):
return subprocess.run([str(x) for x in argv], capture_output=True, text=True, check=True, **kwargs)
class FakeBao(BaseHTTPRequestHandler):
records = {}
versions = {}
writes = 0
deny = False
def log_message(self, *_):
pass
def respond(self, status, body):
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(body).encode())
def do_GET(self):
if self.deny:
return self.respond(403, {'errors': ['permission denied']})
name = self.path.rsplit('/', 1)[-1]
if '/metadata/' in self.path and name in self.versions:
return self.respond(200, {'data': {'current_version': self.versions[name]}})
if '/data/' in self.path and name in self.records:
return self.respond(200, {'data': {'data': self.records[name]}})
self.respond(404, {'errors': []})
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
name = self.path.rsplit('/', 1)[-1]
if body['options']['cas'] != 0 or name in self.versions:
return self.respond(400, {'errors': ['CAS mismatch']})
self.records[name] = body['data']
self.versions[name] = 1
type(self).writes += 1
self.respond(200, {'data': {'version': 1}})
def main():
for key in ('HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'):
os.environ.pop(key, None)
os.environ['NO_PROXY'] = '*'
processes = []
handles = []
server = None
with tempfile.TemporaryDirectory(prefix='shared-etcd-test-') as work:
w = Path(work)
w.chmod(0o700)
try:
run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', w/'ca.key',
'-out', w/'ca.crt', '-days', '1', '-subj', '/CN=isolated-test-ca'])
def cert(name, cn, eku, ip=None):
run(['openssl', 'req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', w/f'{name}.key',
'-out', w/f'{name}.csr', '-subj', f'/CN={cn}' if cn else '/'])
ext = w/f'{name}.ext'
ext.write_text(f'extendedKeyUsage={eku}\n' + (f'subjectAltName=IP:{ip},IP:127.0.0.1\n' if ip else ''))
run(['openssl', 'x509', '-req', '-in', w/f'{name}.csr', '-CA', w/'ca.crt',
'-CAkey', w/'ca.key', '-CAcreateserial', '-out', w/f'{name}.crt', '-days', '1', '-extfile', ext])
cert('admin', 'root', 'clientAuth')
cert('client', '', 'clientAuth')
cert('gateway', '', 'clientAuth')
env = jinja2.Environment(loader=jinja2.FileSystemLoader(ROOT/'ansible/roles/shared_etcd/templates'), undefined=jinja2.StrictUndefined)
env.filters['to_json'] = json.dumps
defaults = yaml.safe_load((ROOT/'ansible/roles/shared_etcd/defaults/main.yml').read_text())
names = ['test1', 'test2', 'test3']
hosts = {name: {'etcd_address': f'127.0.0.{i+2}'} for i, name in enumerate(names)}
for name in names:
d = w/name
d.mkdir()
for kind, cn, eku in [('server', name, 'serverAuth'), ('peer', 'homelab-etcd-peer', 'serverAuth,clientAuth')]:
cert(f'{name}-{kind}', cn, eku, hosts[name]['etcd_address'])
for suffix in ['crt', 'key']:
shutil.copy(w/f'{name}-{kind}.{suffix}', d/f'{kind}.{suffix}')
shutil.copy(w/'ca.crt', d/'ca.crt')
for suffix in ['crt', 'key']:
shutil.copy(w/f'gateway.{suffix}', d/f'gateway.{suffix}')
values = dict(defaults, inventory_hostname=name, groups={'etcd': names}, hostvars=hosts,
etcd_address=hosts[name]['etcd_address'], etcd_config_dir=str(d), etcd_data_dir=str(d/'data'),
etcd_client_port=22379, etcd_peer_port=22380, etcd_metrics_port=0)
config = env.get_template('etcd.yml.j2').render(**values)
# 三成员共享进程命名空间,metrics 用独立 loopback IP。
config = config.replace('http://127.0.0.1:0', f"http://{hosts[name]['etcd_address']}:22381")
(d/'config.yml').write_text(config)
handle = (d/'etcd.log').open('w')
handles.append(handle)
processes.append(subprocess.Popen([str(BIN/'etcd'), '--config-file='+str(d/'config.yml')], stdout=handle, stderr=handle))
base_env = dict(os.environ, ETCDCTL_ENDPOINTS='https://127.0.0.2:22379', ETCDCTL_CACERT=str(w/'ca.crt'),
ETCDCTL_CERT=str(w/'admin.crt'), ETCDCTL_KEY=str(w/'admin.key'), ETCDCTL_DIAL_TIMEOUT='2s', ETCDCTL_COMMAND_TIMEOUT='3s')
def ctl(*args, input=None, env=None):
return run([BIN/'etcdctl', *args], input=input, env=env or base_env)
for attempt in range(5):
try:
ctl('endpoint', 'health')
break
except subprocess.CalledProcessError:
if any(p.poll() is not None for p in processes):
for name in names:
print((w/name/'etcd.log').read_text()[-4000:])
raise RuntimeError('test etcd exited during startup')
time.sleep(0.2)
else:
print((w/'test1'/'etcd.log').read_text()[-5000:])
raise RuntimeError('test quorum did not form')
server = ThreadingHTTPServer(('127.0.0.1', 0), FakeBao)
threading.Thread(target=server.serve_forever, daemon=True).start()
inventory = w/'hosts.yml'
inventory.write_text(yaml.safe_dump({'all': {'children': {'etcd': {'hosts': {'test1': {'ansible_connection': 'local'}}}}}}))
extra = {
'ansible_become': False, 'etcd_address': '127.0.0.2', 'etcd_client_port': 22379,
'etcd_install_dir': str(BIN), 'etcd_config_dir': str(w), 'etcd_bootstrap_auth': True,
'etcd_bao_url': f'http://127.0.0.1:{server.server_port}', 'etcd_bao_token': 'isolated-test-token',
'etcd_bao_kv_mount': 'kv', 'etcd_bao_secret_base': 'infra/etcd/consumers',
'etcd_consumers': [{'name': 'patroni-pg-prod', 'prefix': '/homelab/patroni/pg-prod/'}],
}
(w/'extra.json').write_text(json.dumps(extra))
ansible_env = dict(os.environ, ANSIBLE_LOCAL_TEMP=str(w/'ansible-tmp'), ANSIBLE_NOCOLOR='1',
ANSIBLE_ROLES_PATH=str(ROOT/'ansible/roles'))
def play(name, fail=False):
r = subprocess.run(['ansible-playbook', '-i', str(inventory), str(ROOT/'ansible'/name), '-e', '@'+str(w/'extra.json')],
capture_output=True, text=True, env=ansible_env)
(w/(name+'.log')).write_text(r.stdout+r.stderr)
if fail:
assert r.returncode != 0, 'expected a fail-closed playbook error'
elif r.returncode:
# Tasks use no_log for secret data; retain useful task/line diagnostic.
print(r.stdout[-5000:]);print(r.stderr[-2000:])
raise RuntimeError(name+' failed')
return r.stdout
play('bootstrap-auth.yml')
assert re.search(r'changed=0\s', play('bootstrap-auth.yml'))
play('consumers.yml')
secret = FakeBao.records['patroni-pg-prod']['password']
assert len(secret) == 48
assert re.search(r'changed=0\s', play('consumers.yml'))
assert FakeBao.writes == 1
role = json.loads(ctl('--write-out=json', 'role', 'get', 'patroni-pg-prod').stdout)
assert role['perm'][0]['key'] == base64.b64encode(b'/homelab/patroni/pg-prod/').decode()
client_env = dict(base_env, ETCDCTL_CERT=str(w/'client.crt'), ETCDCTL_KEY=str(w/'client.key'),
ETCDCTL_USER='patroni-pg-prod', ETCDCTL_PASSWORD=secret)
ctl('put', '/homelab/patroni/pg-prod/test', 'ok', env=client_env)
try:
ctl('put', '/homelab/other/test', 'denied', env=client_env)
raise AssertionError('cross-prefix write succeeded')
except subprocess.CalledProcessError:
pass
# 模拟 Patroni 的 gateway 协议,证明 mTLS + username/password 可组合使用。
ctx = ssl.create_default_context(cafile=str(w/'ca.crt'))
ctx.load_cert_chain(w/'client.crt', w/'client.key')
body = json.dumps({'name':'patroni-pg-prod', 'password':secret}).encode()
req = urllib.request.Request('https://127.0.0.2:22379/v3/auth/authenticate', data=body, headers={'Content-Type':'application/json'})
with urllib.request.urlopen(req, context=ctx) as response:
token = json.load(response)['token']
gateway_body = json.dumps({'key': base64.b64encode(b'/homelab/patroni/pg-prod/gateway').decode(),
'value': base64.b64encode(b'ok').decode()}).encode()
req = urllib.request.Request('https://127.0.0.2:22379/v3/kv/put', data=gateway_body,
headers={'Content-Type': 'application/json', 'Authorization': token})
with urllib.request.urlopen(req, context=ctx) as response:
assert response.status == 200
# 对已有用户,秘密值被意外覆盖也必须失败,而不是悄悄改 etcd 密码。
FakeBao.records['patroni-pg-prod']['password'] = 'x' * 48
play('consumers.yml', fail=True)
FakeBao.records['patroni-pg-prod']['password'] = secret
FakeBao.deny = True
play('consumers.yml', fail=True)
FakeBao.deny = False
saved = FakeBao.records.pop('patroni-pg-prod')
play('consumers.yml', fail=True) # metadata 存在、data 已删除
FakeBao.versions.clear()
play('consumers.yml', fail=True) # 用户存在但 metadata 丢失
assert FakeBao.writes == 1
FakeBao.records['patroni-pg-prod'] = saved
FakeBao.versions['patroni-pg-prod'] = 1
ctl('snapshot', 'save', str(w/'snapshot.db'))
snapshot = json.loads(run([BIN/'etcdutl', '--write-out=json', 'snapshot', 'status', w/'snapshot.db']).stdout)
assert snapshot['totalKey'] > 0
run([BIN/'etcdutl', 'snapshot', 'restore', w/'snapshot.db', '--data-dir='+str(w/'restored')])
rss = []
for process in processes:
match = re.search(r'^VmRSS:\s+(\d+)', Path(f'/proc/{process.pid}/status').read_text(), re.M)
rss.append(round(int(match[1]) / 1024, 1))
processes[2].terminate();processes[2].wait(timeout=10)
ctl('put', '/homelab/patroni/pg-prod/after-member-loss', 'ok', env=client_env)
print('PASS: three-member mTLS, auth bootstrap/idempotence, Bao create-once/fail-closed, prefix isolation, gateway auth, snapshot/restore, one-member loss')
print('Idle test member RSS MiB (not a production capacity result):', rss)
finally:
if server:
server.shutdown();server.server_close()
for process in processes:
if process.poll() is None:
process.terminate()
try: process.wait(timeout=10)
except subprocess.TimeoutExpired: process.kill();process.wait()
for handle in handles:
handle.close()
if __name__ == '__main__':
main()
-71
View File
@@ -1,71 +0,0 @@
"""续签调度失败关闭测试;不会连接真实 Bao 或运行 Ansible。"""
import importlib.util
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, Mock
spec = importlib.util.spec_from_file_location('renew', Path(__file__).parents[1] / 'controller/renew.py')
renew = importlib.util.module_from_spec(spec)
spec.loader.exec_module(renew)
class RenewalTests(unittest.TestCase):
def execute(self, results):
with tempfile.TemporaryDirectory() as directory:
lock = Path(directory) / 'lock'
success = Path(directory) / 'last-success'
original_open = open
response = Mock()
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
with patch('builtins.open', side_effect=lambda *_: original_open(lock, 'a')), \
patch.object(renew.subprocess, 'run', side_effect=results) as run, \
patch.object(renew.urllib.request, 'urlopen', return_value=response) as revoke, \
patch.object(renew, 'Path', return_value=success):
rc = renew.main()
return rc, run.call_args_list, revoke.call_count, success.exists()
def test_login_failure_never_changes_members(self):
rc, calls, revokes, success = self.execute([subprocess.CompletedProcess([], 1, '', '')])
self.assertEqual(rc, 1)
self.assertEqual(len(calls), 1)
self.assertEqual(revokes, 0)
self.assertFalse(success)
def test_unhealthy_cluster_never_runs_site(self):
rc, calls, revokes, success = self.execute([
subprocess.CompletedProcess([], 0, 'test-only-token'),
subprocess.CompletedProcess([], 2),
])
self.assertEqual(rc, 2)
self.assertEqual(calls[1].args[0][-1], 'verify.yml')
self.assertEqual(len(calls), 2)
self.assertEqual(revokes, 1)
self.assertFalse(success)
def test_success_checks_then_converges_and_revokes(self):
rc, calls, revokes, success = self.execute([
subprocess.CompletedProcess([], 0, 'test-only-token'),
subprocess.CompletedProcess([], 0),
subprocess.CompletedProcess([], 0),
])
self.assertEqual(rc, 0)
self.assertEqual([c.args[0][-1] for c in calls[1:]], ['verify.yml', 'site.yml'])
self.assertEqual(revokes, 1)
self.assertTrue(success)
def test_site_failure_is_not_success(self):
rc, calls, revokes, success = self.execute([
subprocess.CompletedProcess([], 0, 'test-only-token'),
subprocess.CompletedProcess([], 0),
subprocess.CompletedProcess([], 2),
])
self.assertEqual(rc, 2)
self.assertEqual(revokes, 1)
self.assertFalse(success)
if __name__ == '__main__':
unittest.main()
-29
View File
@@ -1,29 +0,0 @@
# laptop 内存预算
laptop 同时承载 Kubernetes、数据库和 libvirt VM。ARC 使用明确的 **4 GiB** 上限,
为服务留下突发余量;不修改自动 ARC 下限、swap、业务内存或 VM I/O 策略。
在 laptop 上执行:
```bash
ansible-playbook -i localhost, infrastructure/host-memory/laptop-arc.yml --check
ansible-playbook -i localhost, infrastructure/host-memory/laptop-arc.yml
```
play 在线写入 `/sys/module/zfs/parameters/zfs_arc_max`,并持久化到
`/etc/modprobe.d/homelab-zfs-arc.conf`。配置变化时以低 CPU/I/O 优先级更新所有已安装
内核的 initramfs。不重启、不卸载 ZFS。ARC 当前使用量不保证立即降到上限以下;
`zfs_arc_max` 也不是 ZFS 全部内存的硬上限。
验证 `/proc/spl/kstat/zfs/arcstats` 的 `c_max=4294967296`,并在 Grafana 比较
`node_zfs_arc_size`、`node_zfs_arc_c_max`、内存/I/O PSI、换页及数据库请求延迟。
命中率下降或磁盘读压力上升时按实际负载重新评估预算,不通过重启或清缓存进行验收。
回滚时先从运行中的 `arcstats` 核实原自动上限,再在线写回该非零值。
删除本 play 的 modprobe 文件并更新 initramfs,可恢复以后启动时的自动策略。
不要依赖运行时写入 `0` 恢复自动计算;该行为受 OpenZFS 参数实现限制。
2026-09-26 故障关联:winadmin 自动更新期间虚拟磁盘写入与 ARC 增长、内存回收和
Gitea 超时重合;限制 ARC 是内存预算措施,不代表已证明或解决全部存储延迟原因。
排查证据与后续验收见
[宿主机 ARC 预算](https://git.ddupan.top/panxiao81/homelab-wiki/src/branch/main/guides/laptop-arc-budget.md)。
-43
View File
@@ -1,43 +0,0 @@
---
- name: 为 laptop 的 VM 和服务预留内存
hosts: localhost
connection: local
become: true
gather_facts: true
vars:
laptop_zfs_arc_max_bytes: 4294967296
tasks:
- name: 确认目标和 ARC 预算
ansible.builtin.assert:
that:
- ansible_facts.hostname == 'laptop'
- laptop_zfs_arc_max_bytes | int == 4294967296
fail_msg: 此 play 仅用于 laptop 的 4 GiB ARC 预算。
- name: 读取运行中的 ARC 上限参数
ansible.builtin.command: cat /sys/module/zfs/parameters/zfs_arc_max
register: arc_max_before
changed_when: false
check_mode: false
- name: 持久化 ARC 上限
ansible.builtin.copy:
dest: /etc/modprobe.d/homelab-zfs-arc.conf
owner: root
group: root
mode: '0644'
content: |
# Ansible managed: infrastructure/host-memory/laptop-arc.yml
# 4 GiB ARC budget for laptop; retain the automatic minimum.
options zfs zfs_arc_max={{ laptop_zfs_arc_max_bytes }}
notify: 更新已安装内核的 initramfs
- name: 在线应用 ARC 上限
ansible.builtin.shell: "printf '%s\\n' {{ laptop_zfs_arc_max_bytes | string | quote }} > /sys/module/zfs/parameters/zfs_arc_max"
when: arc_max_before.stdout | int != laptop_zfs_arc_max_bytes | int
changed_when: true
handlers:
- name: 更新已安装内核的 initramfs
ansible.builtin.command: nice -n 10 ionice -c 3 update-initramfs -u -k all
changed_when: true
-72
View File
@@ -1,72 +0,0 @@
# kata-lab
`kata-lab` 是位于 Proxmox `pve2` 的可销毁验证环境,用来验证 nested KVM、k3s、
Kata Containers,以及后续一 job 一 Kata Pod 的 Gitea Runner。它不是 dev/stg
长期服务,也不承载持久数据。
> 状态:VMID 147 已于 2026-09-14 销毁,Terraform state 为空。目录保留首次验证结果
> 与可复现配置;后续验证转向跨 PVE 节点的轻量 LXC worker 方案。
## Terraform bootstrap
1. 将 Proxmox API token 写入被 Git 忽略且权限为 `0600` 的
`terraform/credentials.auto.tfvars`。
2. 取得内部 CA,并仅为当前 Terraform 进程设置 Go TLS trust:
```bash
curl -fsSL https://bao.ad.ddupan.top:8200/v1/pki/ca/pem \
-o /tmp/kata-lab-ddupan-ca.pem
export SSL_CERT_FILE=/tmp/kata-lab-ddupan-ca.pem
```
3. 初始化并审阅 plan:
```bash
cd infrastructure/kata-lab/terraform
terraform init
terraform plan
```
Terraform 使用 `laptop:import/noble-server-cloudimg-amd64.qcow2`(40 GiB
virtual size),在
`pve-rg-hdd` 创建 VM root disk,并把 cloud-init snippet 放到 `laptop`
datastore。VM root disk 设为 41 GiB,以容纳 PVE import 的块对齐增量。VM 使用
DHCP、`cpu.type = host`、4 vCPU 和 4 GiB 内存;cloud-init
安装 `qemu-guest-agent`、单节点 k3s,并记录 `/dev/kvm` 检查结果。
## Kata Containers
Ansible 使用 Kata Containers 4.1.0 官方 `kata-deploy` chart。配置明确选择 k3s,
只安装 `qemu-runtime-rs`,不安装额外 snapshotter,并采用 `job` 模式,安装结束后
不保留 privileged DaemonSet。默认 `RuntimeClass` 名为 `kata`。
Terraform 输出的 DHCP 地址变化时,先更新 `ansible/inventory/hosts.yml`,然后运行:
```bash
cd infrastructure/kata-lab/ansible
ansible-playbook kata.yml
ansible-playbook verify.yml
```
`verify.yml` 首先创建一个 `runtimeClassName: kata` 的短生命周期 Pod,并确认 Pod 内
看到的 Kata guest kernel 与 k3s node 的宿主 kernel 不同。随后它在另一个 Kata Pod
内启动 privileged `dockerd` sidecar,让普通 Docker client 容器通过共享网络命名空间
的 `127.0.0.1:2375` 运行一个嵌套容器。这对应后续 Gitea Runner 的目标形态:runner
直接执行 job,只在需要 Docker API 时连接同 Pod 的 daemon,而不把 Docker executor
作为 job 的外层。Kata 下共享 volume 中的 Unix socket 文件虽然双方可见,但不能跨
容器连接,因此不能在这里沿用普通 Pod 常见的 `/var/run/docker.sock` 方案;loopback
端口没有通过 Service 暴露到 Pod 外。
验证 Pod 会保留以供排查;再次运行时会先替换它们。
2026-09-13 的首次验证结果:Kata guest kernel 为 `6.18.35`,node host kernel 为
`6.8.0-90-generic`;sidecar daemon 成功运行 `busybox:1.37` 嵌套容器。Docker 27.5.1
在当前 Kata guest 中无法挂载 overlay2,自动回退到 `vfs`;功能验证通过,但正式用于
CI 前需要解决或接受其镜像层复制与磁盘性能开销。
不要提交 `credentials.auto.tfvars`、Terraform state 或 saved plan。销毁前确认目标
仍然是 VMID 147 / `kata-lab`:
```bash
terraform plan -destroy
terraform destroy
```
@@ -1,10 +0,0 @@
[defaults]
inventory = inventory/hosts.yml
host_key_checking = True
interpreter_python = auto_silent
retry_files_enabled = False
local_tmp = /tmp/ansible-kata-lab-local
remote_tmp = /tmp/ansible-kata-lab-remote
[ssh_connection]
pipelining = True
@@ -1,8 +0,0 @@
---
all:
hosts:
kata-lab:
ansible_host: 192.168.10.13
ansible_user: ansible
vars:
ansible_become: true
-88
View File
@@ -1,88 +0,0 @@
---
- name: Install Kata Containers in the disposable k3s lab
hosts: kata-lab
gather_facts: false
vars:
kata_version: "4.1.0"
kata_chart_url: >-
https://github.com/kata-containers/kata-containers/releases/download/{{ kata_version }}/kata-deploy-{{ kata_version }}.tgz
tasks:
- name: Check nested KVM is usable
ansible.builtin.stat:
path: /dev/kvm
register: kata_kvm
- name: Require nested KVM
ansible.builtin.assert:
that:
- kata_kvm.stat.exists
- kata_kvm.stat.ischr
fail_msg: /dev/kvm is unavailable; Kata QEMU cannot start efficiently
- name: Install Kata through the k3s Helm controller
ansible.builtin.copy:
dest: /var/lib/rancher/k3s/server/manifests/kata-deploy.yaml
owner: root
group: root
mode: "0644"
content: |
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
name: kata-deploy
namespace: kube-system
spec:
chart: {{ kata_chart_url }}
targetNamespace: kata-system
createNamespace: true
timeout: 15m
failurePolicy: abort
valuesContent: |-
deploymentMode: job
k8sDistribution: k3s
snapshotter:
setup: []
shims:
disableAll: true
qemu-runtime-rs:
enabled: true
runtimeClasses:
enabled: true
createDefault: true
node-feature-discovery:
enabled: false
- name: Wait for the Helm installation job to appear
ansible.builtin.command:
cmd: k3s kubectl get job/helm-install-kata-deploy -n kube-system
register: kata_helm_job
retries: 60
delay: 2
until: kata_helm_job.rc == 0
changed_when: false
- name: Wait for the Helm installation job to finish
ansible.builtin.command:
cmd: >-
k3s kubectl wait --for=condition=complete
job/helm-install-kata-deploy -n kube-system --timeout=20m
changed_when: false
- name: Wait for Kata node installation jobs
ansible.builtin.shell:
cmd: |
set -euo pipefail
jobs=$(k3s kubectl get jobs -n kata-system -l app.kubernetes.io/instance=kata-deploy -o name)
test -n "${jobs}"
k3s kubectl wait --for=condition=complete -n kata-system $jobs --timeout=20m
executable: /bin/bash
changed_when: false
- name: Wait for the default Kata RuntimeClass
ansible.builtin.command:
cmd: k3s kubectl get runtimeclass kata
register: kata_runtime_class
retries: 30
delay: 2
until: kata_runtime_class.rc == 0
changed_when: false
-125
View File
@@ -1,125 +0,0 @@
---
- name: Verify Kata isolation with a disposable Pod
hosts: kata-lab
gather_facts: false
tasks:
- name: Remove an earlier smoke Pod
ansible.builtin.command:
cmd: k3s kubectl delete pod kata-smoke --ignore-not-found --wait=true
changed_when: false
- name: Create the Kata smoke Pod
ansible.builtin.shell:
cmd: |
set -o pipefail
k3s kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: kata-smoke
spec:
runtimeClassName: kata
restartPolicy: Never
containers:
- name: smoke
image: docker.io/library/busybox:1.37
command:
- sh
- -c
- 'printf "guest-kernel="; uname -r; test -c /dev/kvm && exit 1 || true'
EOF
executable: /bin/bash
changed_when: true
- name: Wait for the Kata Pod to finish
ansible.builtin.command:
cmd: k3s kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/kata-smoke --timeout=10m
changed_when: false
- name: Read the guest kernel version
ansible.builtin.command:
cmd: k3s kubectl logs kata-smoke
register: kata_smoke_log
changed_when: false
- name: Read the host kernel version
ansible.builtin.command:
cmd: uname -r
register: kata_host_kernel
changed_when: false
- name: Require a distinct guest kernel
ansible.builtin.assert:
that:
- kata_smoke_log.stdout is match('^guest-kernel=.+')
- kata_smoke_log.stdout | regex_replace('^guest-kernel=', '') != kata_host_kernel.stdout
success_msg: >-
Kata guest {{ kata_smoke_log.stdout }} differs from host-kernel={{ kata_host_kernel.stdout }}
- name: Remove an earlier Docker sidecar smoke Pod
ansible.builtin.command:
cmd: k3s kubectl delete pod kata-docker-smoke --ignore-not-found --wait=true
changed_when: false
- name: Create a Kata Pod with a Docker daemon sidecar
ansible.builtin.shell:
cmd: |
set -o pipefail
k3s kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: kata-docker-smoke
spec:
runtimeClassName: kata
restartPolicy: Never
volumes:
- name: docker-run
emptyDir: {}
containers:
- name: dockerd
image: docker.io/library/docker:27-dind
securityContext:
privileged: true
env:
- name: DOCKER_TLS_CERTDIR
value: ""
volumeMounts:
- name: docker-run
mountPath: /var/run
- name: client
image: docker.io/library/docker:27-cli
env:
- name: DOCKER_HOST
value: tcp://127.0.0.1:2375
volumeMounts:
- name: docker-run
mountPath: /var/run
command:
- sh
- -c
- |
until docker info >/dev/null 2>&1; do sleep 1; done
docker run --rm docker.io/library/busybox:1.37 echo nested-docker-ok
EOF
executable: /bin/bash
changed_when: true
- name: Wait for the Docker client to finish
ansible.builtin.command:
cmd: >-
k3s kubectl wait --for=jsonpath='{.status.containerStatuses[?(@.name=="client")].state.terminated.exitCode}'=0
pod/kata-docker-smoke --timeout=10m
changed_when: false
- name: Read the Docker client result
ansible.builtin.command:
cmd: k3s kubectl logs kata-docker-smoke -c client
register: kata_docker_smoke_log
changed_when: false
- name: Require Docker to run a nested container
ansible.builtin.assert:
that:
- "'nested-docker-ok' in kata_docker_smoke_log.stdout"
success_msg: Docker daemon sidecar completed a nested container inside the Kata VM
-24
View File
@@ -1,24 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/bpg/proxmox" {
version = "0.111.1"
constraints = "0.111.1"
hashes = [
"h1:ML2D3UUZTM99yrll/EBXj7wBYMb8xmQgomqFNybEoxY=",
"zh:18fb7c31a08dde6bffa1a4d4a211e604d6d17eec7092fd59331b3db3c6f3742c",
"zh:1cd60761538289d4dd2a1086b3ae62a7b0bdd4b1a2f824e9a44e243413168dba",
"zh:2eb76f6fc8299b6820ff678c8252332cc3366e226b5ae2e61748fd2449c1ed92",
"zh:45e6f7ebd0bf48911d37060359a4f359b5743b3092e985295733990e406d0416",
"zh:4aa8ba912eae37975d2e983394d173e595ca34fc76b5bf220b37d0e99d76e98c",
"zh:58e0789923103a77d502a0a9fc3eb920625e8eb935ec2d4ac0d006aebd1d186c",
"zh:6df8aa85fb8865915537e946c19b02538ad188018a629759c213c6f03730f642",
"zh:6ed47bc00d0913a1d0880618fa1376115e9edab6b4a658c081061a7f0e4ca360",
"zh:c5b10ff4f33df7e4c29e8f1127d49845b561b37b57517e844fb0954d7923d65e",
"zh:d016510e14b738499f0db9d9b3aafe82fc6877fb4ab4e9f831fb68a8d70a1385",
"zh:d941f394069bbf24351b363da1c64383f487067aaee0a84f9b96476d4912e212",
"zh:ddf271dbc2632ae8ffa8de3972f243ee47d260cb2ac90aa784f2746d98e21a0f",
"zh:ed0caa3501c42f611b7e9622c9b1df69fd85dc25a3cd88d3076381829688cd62",
"zh:f26e0763dbe6a6b2195c94b44696f2110f7f55433dc142839be16b9697fa5597",
]
}
@@ -1,34 +0,0 @@
#cloud-config
hostname: ${hostname}
manage_etc_hosts: true
timezone: Etc/UTC
users:
- default
- name: ansible
groups: [adm, sudo]
shell: /bin/bash
lock_passwd: true
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- ${ssh_public_key}
package_update: true
package_upgrade: false
packages:
- ca-certificates
- curl
- jq
- qemu-guest-agent
runcmd:
- [systemctl, enable, --now, qemu-guest-agent]
- [modprobe, kvm_amd]
- [sh, -c, 'test -c /dev/kvm && echo available > /var/lib/cloud/nested-kvm.status || echo unavailable > /var/lib/cloud/nested-kvm.status']
- [sh, -c, 'curl -sfL https://get.k3s.io -o /tmp/install-k3s.sh']
- [chmod, '0755', /tmp/install-k3s.sh]
- [sh, -c, 'INSTALL_K3S_VERSION="${k3s_version}" INSTALL_K3S_EXEC="server --disable=traefik --write-kubeconfig-mode=0644" /tmp/install-k3s.sh']
- [sh, -c, 'kubectl wait --for=condition=Ready node/${hostname} --timeout=180s']
- [touch, /var/lib/cloud/kata-lab-bootstrap.done]
final_message: "kata-lab bootstrap completed after $UPTIME seconds"
@@ -1,3 +0,0 @@
# Copy this file to credentials.auto.tfvars and replace the placeholder.
# Format: user@realm!token-id=token-secret
proxmox_api_token = "REPLACE_ME"
-86
View File
@@ -1,86 +0,0 @@
locals {
ssh_public_key = trimspace(file(pathexpand(var.ssh_public_key_file)))
cloud_init = templatefile("${path.module}/cloud-init.yaml.tftpl", {
hostname = var.vm_name
ssh_public_key = local.ssh_public_key
k3s_version = var.k3s_version
})
}
data "proxmox_file" "ubuntu_noble" {
content_type = "import"
datastore_id = var.snippet_datastore_id
node_name = var.node_name
file_name = "noble-server-cloudimg-amd64.qcow2"
}
resource "proxmox_virtual_environment_file" "cloud_init" {
content_type = "snippets"
datastore_id = var.snippet_datastore_id
node_name = var.node_name
source_raw {
data = local.cloud_init
file_name = "${var.vm_name}-cloud-init.yaml"
}
}
resource "proxmox_virtual_environment_vm" "kata_lab" {
name = var.vm_name
description = "Disposable single-node k3s and Kata Containers validation environment."
tags = ["terraform", "disposable", "kata"]
node_name = var.node_name
vm_id = var.vm_id
started = true
on_boot = false
stop_on_destroy = true
agent {
enabled = true
timeout = "15m"
}
cpu {
cores = var.vm_cores
type = "host"
}
memory {
dedicated = var.vm_memory_mb
}
operating_system {
type = "l26"
}
scsi_hardware = "virtio-scsi-single"
disk {
datastore_id = var.disk_datastore_id
import_from = data.proxmox_file.ubuntu_noble.id
interface = "scsi0"
iothread = true
discard = "on"
size = var.vm_disk_gb
}
initialization {
datastore_id = var.disk_datastore_id
user_data_file_id = proxmox_virtual_environment_file.cloud_init.id
ip_config {
ipv4 {
address = "dhcp"
}
}
}
network_device {
bridge = var.network_bridge
model = "virtio"
}
serial_device {}
}
@@ -1,12 +0,0 @@
output "vm_id" {
value = proxmox_virtual_environment_vm.kata_lab.vm_id
}
output "vm_ipv4_addresses" {
description = "QEMU guest agent 报告的地址;忽略 loopback 和 CNI 地址后再用于 SSH。"
value = proxmox_virtual_environment_vm.kata_lab.ipv4_addresses
}
output "ssh_user" {
value = "ansible"
}
@@ -1,17 +0,0 @@
provider "proxmox" {
endpoint = var.proxmox_endpoint
api_token = var.proxmox_api_token
# Snippet uploads use SSH. The provider deliberately does not read
# ~/.ssh/config, so map the PVE node explicitly and use the current agent.
ssh {
agent = false
username = "root"
private_key = file(pathexpand(var.proxmox_ssh_private_key_file))
node {
name = "pve2"
address = "192.168.10.7"
}
}
}
@@ -1,83 +0,0 @@
variable "proxmox_endpoint" {
description = "Proxmox VE API endpoint,不包含 /api2/json。"
type = string
default = "https://pve1.ad.ddupan.top:8006/"
}
variable "proxmox_api_token" {
description = "Proxmox API token,格式为 user@realm!token-id=secret。"
type = string
sensitive = true
}
variable "node_name" {
description = "承载 disposable kata-lab VM 的 PVE 节点。"
type = string
default = "pve2"
}
variable "vm_id" {
description = "kata-lab VMID。"
type = number
default = 147
}
variable "vm_name" {
description = "kata-lab VM 名称和 guest hostname。"
type = string
default = "kata-lab"
}
variable "vm_memory_mb" {
description = "VM 固定内存;pve2 只有约 7.4 GiB 可见内存。"
type = number
default = 4096
}
variable "vm_cores" {
description = "VM vCPU 数量。"
type = number
default = 4
}
variable "vm_disk_gb" {
description = "VM root disk 大小。"
type = number
default = 41
}
variable "disk_datastore_id" {
description = "VM root disk 所在 datastore。"
type = string
default = "pve-rg-hdd"
}
variable "snippet_datastore_id" {
description = "启用 snippets 的共享 datastore。"
type = string
default = "laptop"
}
variable "network_bridge" {
description = "连接 kata-lab VM 的 PVE bridge。"
type = string
default = "vmbr0"
}
variable "ssh_public_key_file" {
description = "注入 cloud-init 用户的 SSH 公钥路径。"
type = string
default = "~/.ssh/id_ed25519.pub"
}
variable "proxmox_ssh_private_key_file" {
description = "provider 上传 snippet 时登录 PVE 节点使用的私钥路径。"
type = string
default = "~/.ssh/id_ed25519"
}
variable "k3s_version" {
description = "可选的固定 k3s 版本;空值使用 stable channel。"
type = string
default = ""
}
@@ -1,10 +0,0 @@
terraform {
required_version = ">= 1.10.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "0.111.1"
}
}
}
-180
View File
@@ -1,180 +0,0 @@
# kata-lxc-lab
在 `pve2` 上验证 privileged LXC 内运行 k3s、Kata/QEMU,并直接使用 PVE host 的
`/dev/kvm`、`/dev/vhost-net` 与 `/dev/vhost-vsock`。这是可销毁 PoC,不承载持久数据。
Terraform 复用原 VM PoC 的 ignored credential 文件,不复制 token:
```bash
cd infrastructure/kata-lxc-lab/terraform
export SSL_CERT_FILE=/tmp/kata-lab-ddupan-ca.pem
terraform init
terraform plan \
-var-file=../../kata-lab/terraform/credentials.auto.tfvars
terraform apply \
-var-file=../../kata-lab/terraform/credentials.auto.tfvars
```
当前 PoC 使用 `pve2`、VMID 147、Ubuntu 24.04 LXC template 和 16 GiB
`local-lvm` rootfs。容器为 privileged,并开启 nesting、keyctl、FUSE、mknod;
Kata 所需的 KVM、vhost 与 `/dev/net/tun` 设备显式透传;另透传 `/dev/kmsg`,因为
kubelet 启动时会打开该设备。
PVE 9 的 `force_rw_sys=1` feature 用于开放 kubelet 所需的少量 `/proc/sys` 写操作;
provider 0.111.1 尚未暴露该 feature,因此同样由 `pct` 设置。
k3s/Kata worker 还使用以下 privileged LXC 原生配置;PVE 9 使用 cgroup v2,因此设备
规则采用 `lxc.cgroup2.devices.allow`:
```text
lxc.apparmor.profile: unconfined
lxc.cgroup2.devices.allow: a
lxc.cap.drop:
lxc.mount.auto: proc:rw sys:rw
```
这些设置让容器内 kubelet、containerd 和 Kata shim 能管理 mount、cgroup、设备与
共享宿主 sysctl。因此外层 LXC 不是安全边界;不可信 CI 的安全边界是内层 Kata VM。
`/dev/kmsg` 当前直接透传,没有使用 `/dev/console` symlink fallback。
PVE 将 privileged LXC 的创建限制为 `root@pam`(API token 即使拥有 `PVEAdmin` 也
缺少 root-only 的 `Sys.Modify`)。因此实际创建入口使用 root SSH 上的 Ansible/pct:
```bash
cd infrastructure/kata-lxc-lab/ansible
ansible-playbook create.yml
ansible-playbook bootstrap.yml
ansible-playbook kata.yml
ansible-playbook verify.yml
ansible-playbook verify-fuse.yml
```
Terraform 文件保留用于记录 provider 权限边界与声明式目标;当前 token 执行 apply 会
在创建前返回 403,不会产生资源或 state。
## 验证结果
当前管理地址为 `192.168.10.128`:2026-09-14 主 LAN DHCP 池调整后,从 `.11`
续租迁移到 `.128`,并同步 Ansible inventory。它仍是动态租约,不是固定地址。
2026-09-14,pve2 VMID 147 / `192.168.10.11` 验证通过:
- LXC 内 k3s `v1.36.4+k3s1` node Ready;
- Kata Containers 4.1.0 `qemu-runtime-rs` 安装成功;
- Kata guest kernel `6.18.35`,外层共享的 PVE kernel `7.0.2-6-pve`;
- Kata Pod 内的 Docker 27.5.1 daemon 成功运行 `busybox:1.37` 嵌套容器;
- `/dev/net/tun` 是 Kata 创建 TAP/link 的必要设备,未透传时 runtime-rs 在
`create link` 返回 `ENOENT`;
- `fuse-overlayfs` smoke test 成功:Docker 报告
`storage-driver=fuse-overlayfs`,并成功运行嵌套容器。
正式方案保持 Kata 默认 `emptydir_mode = "shared-fs"`,不向 LXC 暴露 host
`/dev/loop-control` 或 `/dev/loopN`。dockerd 镜像需要包含 `fuse-overlayfs`;启动前在
Kata guest 内创建临时设备节点 `mknod /dev/fuse c 10 229`,随后强制传入
`--storage-driver=fuse-overlayfs`。该 `/dev/fuse` 属于内层 guest kernel,不是 PVE
host 设备透传,并随 Kata Pod 一起销毁。
删除三个 smoke Pod 后,LXC cgroup 统计约 505 MB anonymous memory、1.34 GB file
cache;后者主要来自刚拉取的 Kata/Docker images,可由宿主回收。也就是说常驻 worker
的不可回收 working set 约 500 MB,8 GiB 配置是 cgroup 上限而非预分配。
## Gitea ephemeral runner PoC
`runner/` 定义一次性 Gitea runner Pod。整个 Pod 使用 `runtimeClassName: kata`;
runner 通过 guest 内同 Pod 的 dockerd 创建 job container,不连接 LXC/PVE host 的
Docker socket。dockerd 使用 zot 中预先发布的 `dind-fuse:29.7.1`,其
`/var/lib/docker` 是随 Kata Pod 删除的 `emptyDir`。
runner 以 `GITEA_RUNNER_EPHEMERAL=1` 注册,只接收一个 `runs-on: kata-poc` job;接单
后 Gitea 撤销其 runner credential,job 完成后进程与 Kata VM 一起退出。当前 PoC
手动创建一个 runner 等待 `.gitea/workflows/kata-runner-smoke.yml`,尚未部署
`workflow_job` webhook controller,因此不会保留常驻空闲 runner。
注册 token 只在运行时从现有 OpenBao/ESO 消费副本投递到 PoC 集群的临时 Secret,
不写入此目录。两个 Pod image 使用 `imagePullPolicy: Never`,部署前从可信工作站将
已验证镜像导入 PoC k3s containerd;这是当前 zot 拉取仍要求 SPIFFE 身份时的
bootstrap 边界,不是正式镜像分发方案。
正式接入 zot 前需要把这个 cluster 注册到 SPIRE,并为 runner Pod 创建专用
`ClusterSPIFFEID`。届时 runner 与 dockerd sidecar 在同一路径只读挂载 CSI Workload
API socket,runner 的 `container.options` 再把该路径 bind-mount 到 job container。
zot 只对最终 SPIFFE ID 的 `panxiao81/dind-fuse` 仓库授予
`read/create/update`;禁止授予 namespace 或整个 trust domain 写权限。
2026-09-14 已完成真实 Gitea job 验收:Actions run `242` / job `445` 使用
`alpine:3.22`,10 秒成功;runner container 退出码为 `0`,原生 dockerd sidecar 随后
由 kubelet 终止,Pod 最终为 `Succeeded`,没有保留空闲 Kata VM。guest 内核为
`6.18.35`,dockerd `29.7.1` 使用 `fuse-overlayfs`。
首次尝试暴露了两个部署陷阱:`COPY` 默认保留源文件 mode,入口脚本原为 `0664`,
必须在 Dockerfile 使用 `COPY --chmod=0755`;只配置 `ephemeral-storage: 20Gi` limit
会形成同值 request,使 12 GiB PoC 节点无法调度。当前 manifest 显式 request 1 GiB、
limit 8 GiB。完整 `ubuntu-latest` job image 也不适合这个小 rootfs,基础生命周期验收
改用 Alpine;正式 worker 必须扩容本地 image/cache 空间或使用更精简的 job image。
## Cloud Hypervisor 内存记账验证
2026-09-17 在 pve2 的一次性 VMID 148 中安装 k3s v1.36.4+k3s1 与 Kata 4.1.0,
只启用 `clh-runtime-rs`,验证 Cloud Hypervisor 在 privileged LXC 内不会重现早期
microVM PoC 的 private memfd 双重记账问题。测试完成后已删除 VMID 148。
Cloud Hypervisor `/api/v1/vm.info` 报告:
```json
{"size":3254779904,"shared":true,"hugepages":false,"prefault":false,"thp":true}
```
Kata guest 在 memory-backed `emptyDir` 中写入 1 GiB 后:
- VMM `Pss_Shmem` 从约 247 MiB 增至约 1269 MiB;
- VMM `Pss_Anon` 写入前后均约 1.2 MiB;
- 外层 LXC `shmem` 增加约 1 GiB,`anon` 基本不变;
- LXC `memory.events` 的 `max`、`oom` 与 `oom_kill` 均为 0;
- 删除 Pod 后 VMM 退出,LXC `shmem` 回落到约 1.6 MiB。
因此 Kata 的 Cloud Hypervisor handler 已使用 shared memfd,等价于独立 launcher 的
`--memory shared=on` 修复;后续 OpenSandbox/Kata 方案无需为 guest RAM 预留近似双倍
的 LXC cgroup 内存。部署验收仍应检查 `vm.info.config.memory.shared=true`,防止上游
配置或 runtime handler 变化造成回归。
测试期间还发现 pve2 `local-lvm` thin pool 已达到 100%;临时 rootfs 必须放到
`pve-rg-hdd`,不能根据容器内部 `df` 的剩余空间判断 thin pool 是否可写。
## Block-backed emptyDir 与 CI 构建验证
2026-09-17 又在 pve2 的一次性 VMID 148 中验证 Kata 4.1.0
`clh-runtime-rs` 的以下 drop-in:
```toml
[runtime]
emptydir_mode = "block-plain"
```
结果如下:
- 普通 Kubernetes `emptyDir` 被创建为稀疏 `disk.img`,由 Cloud Hypervisor 直接作为
block device 热插拔;guest 内 `/var/lib/docker` 是 `/dev/vdb` 上的 ext4。
- Docker 29.1.5 成功强制使用原生 `overlay2`,不再需要 `fuse-overlayfs`;整个过程
没有使用 PVE host 或 LXC 的 loop device。
- `emptyDir.sizeLimit: 8Gi` 没有决定虚拟磁盘容量。由于 kubelet 所在文件系统约
12 GiB,guest 看到的 ext4 约 11.7 GiB;这与 Kata 已知的 block-backed emptyDir
限制一致。
- 包含 256 MiB payload、2000 个小文件和一个额外复制层的冷 BuildKit 构建耗时
46 秒;同一 LXC、同一 DRBD HDD 存储上的 runc 对照为 34 秒,Kata 约慢 35%。
- 在 overlay2 容器层内写入并 fsync 512 MiB:Kata 为 12 秒(40.5 MB/s),runc 为
10 秒(50.3 MB/s);创建一万个小文件两者均约 2 秒。
- kind v0.27.0 / Kubernetes v1.32.2 首次启动失败是因为 Kata guest 内的 dockerd
容器没有 `/dev/kmsg`,不是 block storage 或 cgroup 故障。在 privileged dockerd
启动前执行 `mknod /dev/kmsg c 1 11` 后,kind 创建成功:总计 122 秒,进入等待阶段
后 23 秒 control-plane Ready,随后可正常删除。
- 测试时稀疏 backing file 逻辑大小约 12 GiB,构建和 kind 后实际占用约 1.8 GiB。
删除 Pod 后 backing file、Cloud Hypervisor 进程和 kind 容器全部消失;host/LXC
均无 loop device 残留,LXC 没有 OOM 事件。
因此 `block-plain` 能解决 virtio-fs 不能作为 overlayfs upperdir 的问题,并满足
BuildKit 与 kind 的功能要求。正式 CI RuntimeClass 必须使用独立的 block-backed
配置,并在 dockerd bootstrap 中创建 guest `/dev/kmsg`。它仍比同机 runc 构建慢约
20–35%,且 `emptyDir.sizeLimit` 不能可靠限制虚拟磁盘容量。PoC 曾据此建议为 kubelet
目录提供独立文件系统;正式 sandbox 集群最终没有采用该设计:独立 volume 增加了
DRBD 与 LXC 挂载生命周期复杂度,却没有隔离 containerd 等其他节点数据。正式节点
改用单一、容量明确的 rootfs,并以磁盘占用监控、Pod 删除后的 backing-file 回收检查
和实际构建基准作为上线门槛。
@@ -1,10 +0,0 @@
[defaults]
inventory = inventory/hosts.yml
host_key_checking = True
interpreter_python = auto_silent
retry_files_enabled = False
local_tmp = /tmp/ansible-kata-lxc-local
remote_tmp = /tmp/ansible-kata-lxc-remote
[ssh_connection]
pipelining = True
@@ -1,58 +0,0 @@
---
- name: Bootstrap k3s in the Kata LXC worker
hosts: pve2
gather_facts: false
vars:
kata_lxc_id: 147
tasks:
- name: Check base packages in LXC
ansible.builtin.command:
cmd: >-
pct exec {{ kata_lxc_id }} -- dpkg-query -W
ca-certificates curl jq openssh-server
register: kata_lxc_packages
changed_when: false
failed_when: false
- name: Install base packages and SSH server in LXC
ansible.builtin.command:
cmd: >-
pct exec {{ kata_lxc_id }} -- bash -lc
'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y
ca-certificates curl jq openssh-server'
when: kata_lxc_packages.rc != 0
- name: Install k3s in LXC
ansible.builtin.shell:
cmd: |
set -euo pipefail
pct exec {{ kata_lxc_id }} -- bash -lc '
if ! command -v k3s >/dev/null; then
curl -sfL https://get.k3s.io -o /tmp/install-k3s.sh
chmod 0755 /tmp/install-k3s.sh
INSTALL_K3S_EXEC="server --disable=traefik --write-kubeconfig-mode=0644" /tmp/install-k3s.sh
fi
'
executable: /bin/bash
register: kata_lxc_k3s_install
changed_when: "'No change detected' not in kata_lxc_k3s_install.stdout"
- name: Wait for k3s node readiness
ansible.builtin.command:
cmd: >-
pct exec {{ kata_lxc_id }} -- /usr/local/bin/k3s kubectl wait
--for=condition=Ready node/kata-lxc-lab --timeout=180s
changed_when: false
- name: Check LXC virtualization and devices
ansible.builtin.command:
cmd: >-
pct exec {{ kata_lxc_id }} -- bash -lc
'systemd-detect-virt; ls -l /dev/kvm /dev/vhost-net /dev/vhost-vsock;
/usr/local/bin/k3s kubectl get node -o wide'
register: kata_lxc_ready
changed_when: false
- name: Report k3s readiness
ansible.builtin.debug:
var: kata_lxc_ready.stdout_lines
@@ -1,137 +0,0 @@
---
- name: Create the disposable Kata LXC worker
hosts: pve2
gather_facts: false
vars:
kata_lxc_id: 147
kata_lxc_template: laptop:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst
tasks:
- name: Check whether the LXC already exists
ansible.builtin.stat:
path: /etc/pve/lxc/{{ kata_lxc_id }}.conf
register: kata_lxc_config
- name: Create privileged LXC with Kata host devices
ansible.builtin.command:
argv:
- pct
- create
- "{{ kata_lxc_id }}"
- "{{ kata_lxc_template }}"
- --hostname
- kata-lxc-lab
- --ostype
- ubuntu
- --rootfs
- local-lvm:12
- --cores
- "4"
- --memory
- "8192"
- --swap
- "0"
- --net0
- name=eth0,bridge=vmbr0,ip=dhcp
- --features
- nesting=1,keyctl=1,fuse=1,mknod=1,force_rw_sys=1
- --unprivileged
- "0"
- --onboot
- "0"
- --ssh-public-keys
- /root/.ssh/authorized_keys
- --dev0
- path=/dev/kvm,mode=0660
- --dev1
- path=/dev/vhost-net,mode=0660
- --dev2
- path=/dev/vhost-vsock,mode=0660
- --dev3
- path=/dev/kmsg,mode=0660
- --dev4
- path=/dev/net/tun,mode=0666
- --tags
- disposable;kata;lxc;ansible
when: not kata_lxc_config.stat.exists
- name: Read LXC status
ansible.builtin.command:
cmd: pct status {{ kata_lxc_id }}
register: kata_lxc_status
changed_when: false
- name: Read current LXC configuration
ansible.builtin.command:
cmd: pct config {{ kata_lxc_id }}
register: kata_lxc_current_config
changed_when: false
- name: Ensure kubelet kernel log device is present
ansible.builtin.command:
cmd: pct set {{ kata_lxc_id }} --dev3 path=/dev/kmsg,mode=0660
register: kata_lxc_kmsg
when: "'/dev/kmsg' not in kata_lxc_current_config.stdout"
- name: Enable writable sysctls required by kubelet
ansible.builtin.command:
cmd: >-
pct set {{ kata_lxc_id }} --features
nesting=1,keyctl=1,fuse=1,mknod=1,force_rw_sys=1
register: kata_lxc_rw_sys
when: "'force_rw_sys=1' not in kata_lxc_current_config.stdout"
- name: Ensure TUN device required by Kata networking is present
ansible.builtin.command:
cmd: pct set {{ kata_lxc_id }} --dev4 path=/dev/net/tun,mode=0666
register: kata_lxc_tun
when: "'/dev/net/tun' not in kata_lxc_current_config.stdout"
- name: Configure privileged nested-runtime LXC directives
ansible.builtin.lineinfile:
path: /etc/pve/lxc/{{ kata_lxc_id }}.conf
regexp: "^{{ item.key | regex_escape }}:"
line: "{{ item.key }}: {{ item.value }}"
loop:
- key: lxc.apparmor.profile
value: unconfined
- key: lxc.cgroup2.devices.allow
value: a
- key: lxc.cap.drop
value: ""
- key: lxc.mount.auto
value: proc:rw sys:rw
register: kata_lxc_raw_config
- name: Restart LXC after device configuration change
ansible.builtin.command:
cmd: pct reboot {{ kata_lxc_id }}
when: >-
kata_lxc_kmsg.changed or kata_lxc_rw_sys.changed or kata_lxc_tun.changed or
kata_lxc_raw_config.changed
- name: Start LXC
ansible.builtin.command:
cmd: pct start {{ kata_lxc_id }}
when: "'status: stopped' in kata_lxc_status.stdout"
- name: Wait for systemd in LXC
ansible.builtin.command:
cmd: pct exec {{ kata_lxc_id }} -- systemctl is-system-running --wait
register: kata_lxc_systemd
retries: 30
delay: 2
until: kata_lxc_systemd.rc in [0, 1]
changed_when: false
failed_when: kata_lxc_systemd.rc not in [0, 1]
- name: Show LXC address and Kata devices
ansible.builtin.command:
cmd: >-
pct exec {{ kata_lxc_id }} -- sh -c
'hostname -I; ls -l /dev/kvm /dev/vhost-net /dev/vhost-vsock /dev/net/tun'
register: kata_lxc_facts
changed_when: false
- name: Report LXC address and devices
ansible.builtin.debug:
var: kata_lxc_facts.stdout_lines
@@ -1,9 +0,0 @@
---
all:
hosts:
pve2:
ansible_host: 192.168.10.7
ansible_user: root
kata-lab:
ansible_host: 192.168.10.128
ansible_user: root
@@ -1,3 +0,0 @@
---
# Reuse the proven Kata 4.1.0 installation playbook against the LXC inventory.
- import_playbook: ../../kata-lab/ansible/kata.yml
@@ -1,73 +0,0 @@
---
- name: Verify fuse-overlayfs for Docker inside Kata
hosts: kata-lab
gather_facts: false
tasks:
- name: Remove an earlier fuse-overlayfs smoke Pod
ansible.builtin.command:
cmd: k3s kubectl delete pod kata-fuse-smoke --ignore-not-found --wait=true
changed_when: false
- name: Create Kata Docker Pod using fuse-overlayfs
ansible.builtin.shell:
cmd: |
set -o pipefail
k3s kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: kata-fuse-smoke
spec:
runtimeClassName: kata
restartPolicy: Never
containers:
- name: dockerd
image: docker.io/library/docker:27-dind
securityContext:
privileged: true
env:
- name: DOCKER_TLS_CERTDIR
value: ""
command:
- sh
- -c
- |
apk add --no-cache fuse-overlayfs
test -e /dev/fuse || mknod /dev/fuse c 10 229
chmod 0666 /dev/fuse
exec dockerd-entrypoint.sh --storage-driver=fuse-overlayfs
- name: client
image: docker.io/library/docker:27-cli
env:
- name: DOCKER_HOST
value: tcp://127.0.0.1:2375
command:
- sh
- -c
- |
until docker info >/dev/null 2>&1; do sleep 1; done
docker info --format 'storage-driver={{ "{{" }}.Driver{{ "}}" }}'
docker run --rm docker.io/library/busybox:1.37 echo fuse-nested-docker-ok
EOF
executable: /bin/bash
changed_when: true
- name: Wait for the Docker client to finish
ansible.builtin.command:
cmd: >-
k3s kubectl wait --for=jsonpath='{.status.containerStatuses[?(@.name=="client")].state.terminated.exitCode}'=0
pod/kata-fuse-smoke --timeout=10m
changed_when: false
- name: Read Docker client result
ansible.builtin.command:
cmd: k3s kubectl logs kata-fuse-smoke -c client
register: kata_fuse_smoke_log
changed_when: false
- name: Require fuse-overlayfs and nested Docker
ansible.builtin.assert:
that:
- "'storage-driver=fuse-overlayfs' in kata_fuse_smoke_log.stdout"
- "'fuse-nested-docker-ok' in kata_fuse_smoke_log.stdout"
success_msg: Docker used fuse-overlayfs and completed a nested container inside Kata
@@ -1,3 +0,0 @@
---
# Reuse the guest-kernel and Docker sidecar smoke tests against the LXC worker.
- import_playbook: ../../kata-lab/ansible/verify.yml
@@ -1,35 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: gitea-actions
labels:
pod-security.kubernetes.io/enforce: privileged
---
apiVersion: v1
kind: ConfigMap
metadata:
name: kata-ephemeral-runner-config
namespace: gitea-actions
data:
config.yaml: |
log:
level: info
runner:
file: /runner-state/.runner
capacity: 1
envs:
DOCKER_HOST: tcp://host.docker.internal:2375
timeout: 3h
shutdown_timeout: 5m
labels:
- kata-poc:docker://docker.io/library/alpine:3.22
cache:
enabled: false
container:
network: ""
options: --add-host=host.docker.internal:host-gateway
docker_host: tcp://127.0.0.1:2375
require_docker: true
docker_timeout: 5m
force_pull: true
bind_workdir: false
@@ -1,111 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
generateName: kata-ephemeral-runner-
namespace: gitea-actions
labels:
app.kubernetes.io/name: kata-ephemeral-runner
spec:
runtimeClassName: kata
restartPolicy: Never
terminationGracePeriodSeconds: 330
automountServiceAccountToken: false
initContainers:
# Native sidecar: startupProbe gates the runner, and kubelet terminates this
# container after the ephemeral runner exits so the Kata sandbox can end.
- name: docker
image: zot.ad.ddupan.top/panxiao81/dind-fuse:29.7.1
imagePullPolicy: Never
restartPolicy: Always
env:
- name: DOCKER_TLS_CERTDIR
value: ""
- name: DIND_LOOPBACK_SIZE
value: 6G
- name: DIND_LOOPBACK_FILE
value: /loopback/docker.img
args:
- --host=tcp://0.0.0.0:2375
- --tls=false
- --mtu=1450
- --default-network-opt=bridge=com.docker.network.driver.mtu=1450
securityContext:
privileged: true
resources:
requests:
cpu: 250m
memory: 512Mi
ephemeral-storage: 1Gi
limits:
cpu: "4"
memory: 6Gi
ephemeral-storage: 8Gi
volumeMounts:
- name: docker-data
mountPath: /var/lib/docker
- name: loopback-data
mountPath: /loopback
startupProbe:
tcpSocket:
port: 2375
periodSeconds: 2
failureThreshold: 150
readinessProbe:
tcpSocket:
port: 2375
periodSeconds: 5
containers:
- name: runner
image: docker.io/gitea/runner:2.3.0
imagePullPolicy: Never
env:
- name: CONFIG_FILE
value: /config/config.yaml
- name: DOCKER_HOST
value: tcp://127.0.0.1:2375
- name: GITEA_INSTANCE_URL
value: https://git.ddupan.top
- name: GITEA_RUNNER_EPHEMERAL
value: "1"
- name: GITEA_RUNNER_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: GITEA_RUNNER_LABELS
value: kata-poc:docker://docker.io/library/alpine:3.22
- name: GITEA_RUNNER_REGISTRATION_TOKEN_FILE
value: /registration/token
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: registration
mountPath: /registration
readOnly: true
- name: runner-state
mountPath: /runner-state
volumes:
- name: config
configMap:
name: kata-ephemeral-runner-config
- name: registration
secret:
secretName: gitea-runner-registration
- name: runner-state
emptyDir:
medium: Memory
sizeLimit: 16Mi
- name: docker-data
emptyDir:
sizeLimit: 8Gi
- name: loopback-data
emptyDir:
medium: Memory
sizeLimit: 6Gi
@@ -1,24 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/bpg/proxmox" {
version = "0.111.1"
constraints = "0.111.1"
hashes = [
"h1:ML2D3UUZTM99yrll/EBXj7wBYMb8xmQgomqFNybEoxY=",
"zh:18fb7c31a08dde6bffa1a4d4a211e604d6d17eec7092fd59331b3db3c6f3742c",
"zh:1cd60761538289d4dd2a1086b3ae62a7b0bdd4b1a2f824e9a44e243413168dba",
"zh:2eb76f6fc8299b6820ff678c8252332cc3366e226b5ae2e61748fd2449c1ed92",
"zh:45e6f7ebd0bf48911d37060359a4f359b5743b3092e985295733990e406d0416",
"zh:4aa8ba912eae37975d2e983394d173e595ca34fc76b5bf220b37d0e99d76e98c",
"zh:58e0789923103a77d502a0a9fc3eb920625e8eb935ec2d4ac0d006aebd1d186c",
"zh:6df8aa85fb8865915537e946c19b02538ad188018a629759c213c6f03730f642",
"zh:6ed47bc00d0913a1d0880618fa1376115e9edab6b4a658c081061a7f0e4ca360",
"zh:c5b10ff4f33df7e4c29e8f1127d49845b561b37b57517e844fb0954d7923d65e",
"zh:d016510e14b738499f0db9d9b3aafe82fc6877fb4ab4e9f831fb68a8d70a1385",
"zh:d941f394069bbf24351b363da1c64383f487067aaee0a84f9b96476d4912e212",
"zh:ddf271dbc2632ae8ffa8de3972f243ee47d260cb2ac90aa784f2746d98e21a0f",
"zh:ed0caa3501c42f611b7e9622c9b1df69fd85dc25a3cd88d3076381829688cd62",
"zh:f26e0763dbe6a6b2195c94b44696f2110f7f55433dc142839be16b9697fa5597",
]
}
@@ -1,95 +0,0 @@
locals {
ssh_public_key = trimspace(file(pathexpand(var.ssh_public_key_file)))
}
data "proxmox_file" "ubuntu_noble" {
content_type = "vztmpl"
datastore_id = var.template_datastore
node_name = var.proxmox_node
file_name = var.template_file_name
}
resource "proxmox_virtual_environment_container" "kata_lxc_lab" {
node_name = var.proxmox_node
vm_id = var.container_id
description = "Disposable privileged LXC for validating k3s and Kata with direct host KVM access."
unprivileged = false
started = true
start_on_boot = false
tags = ["disposable", "kata", "lxc", "terraform"]
cpu {
cores = 4
}
memory {
dedicated = 8192
swap = 0
}
disk {
datastore_id = var.root_datastore
size = 16
}
features {
fuse = true
keyctl = true
mknod = true
nesting = true
}
device_passthrough {
path = "/dev/kvm"
mode = "0660"
}
device_passthrough {
path = "/dev/vhost-net"
mode = "0660"
}
device_passthrough {
path = "/dev/vhost-vsock"
mode = "0660"
}
device_passthrough {
path = "/dev/kmsg"
mode = "0660"
}
device_passthrough {
path = "/dev/net/tun"
mode = "0666"
}
initialization {
hostname = var.container_name
ip_config {
ipv4 {
address = "dhcp"
}
}
user_account {
keys = [local.ssh_public_key]
}
}
network_interface {
name = "eth0"
bridge = "vmbr0"
}
operating_system {
template_file_id = data.proxmox_file.ubuntu_noble.id
type = "ubuntu"
}
wait_for_ip {
ipv4 = true
}
}
@@ -1,7 +0,0 @@
output "container_id" {
value = proxmox_virtual_environment_container.kata_lxc_lab.vm_id
}
output "ipv4" {
value = proxmox_virtual_environment_container.kata_lxc_lab.ipv4
}
@@ -1,15 +0,0 @@
provider "proxmox" {
endpoint = var.proxmox_endpoint
api_token = var.proxmox_api_token
ssh {
agent = false
username = "root"
private_key = file(pathexpand(var.proxmox_ssh_private_key_file))
node {
name = var.proxmox_node
address = var.proxmox_node_address
}
}
}
@@ -1,54 +0,0 @@
variable "proxmox_endpoint" {
type = string
default = "https://pve1.ad.ddupan.top:8006"
}
variable "proxmox_api_token" {
type = string
sensitive = true
}
variable "proxmox_node" {
type = string
default = "pve2"
}
variable "proxmox_node_address" {
type = string
default = "192.168.10.7"
}
variable "proxmox_ssh_private_key_file" {
type = string
default = "~/.ssh/id_ed25519"
}
variable "ssh_public_key_file" {
type = string
default = "~/.ssh/id_ed25519.pub"
}
variable "container_id" {
type = number
default = 147
}
variable "container_name" {
type = string
default = "kata-lxc-lab"
}
variable "template_datastore" {
type = string
default = "laptop"
}
variable "template_file_name" {
type = string
default = "ubuntu-24.04-standard_24.04-2_amd64.tar.zst"
}
variable "root_datastore" {
type = string
default = "local-lvm"
}
@@ -1,10 +0,0 @@
terraform {
required_version = ">= 1.10"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "0.111.1"
}
}
}
-53
View File
@@ -1,53 +0,0 @@
# Gitea kind microVM runner
`kind-microvm` 是专门运行 kind / nested Kubernetes CI 的 runner label。每个 job
独占一台 Cloud Hypervisor VM;guest 内直接运行 Docker 与 ephemeral Gitea Runner,
不经过 Kata。VM 完成一个 job 后关机,临时 COW 磁盘随即删除。
集群内的 `controller.py` 消费 Gitea `workflow_job` webhook。仅当
`action=queued` 且 `workflow_job.labels` 包含 `kind-microvm` 时,向 NATS JetStream
的 `ci.runner.kind-microvm` subject 发布消息。`workflow_job.id` 写入
`Nats-Msg-Id`,重复 webhook 不会生成第二个任务。
pve2 LXC 内的 `worker.py` 使用 durable pull consumer 领取消息。领取后启动一台
microVM,运行期间每分钟发送 `InProgress`;VM/ephemeral runner 正常退出才 ACK,
启动失败则 NAK 并延迟重试。当前 `RUNNER_CAPACITY=1` 只是 pve2 的资源配置;以后可
提高单 worker capacity,或在其他宿主机增加共享同一 durable consumer 的 worker,
而无需修改 webhook/controller。相同 runner 类型必须共享 durable;WorkQueue stream
不允许多个 consumer 使用重叠的 subject filter。
worker 的凭据位于:
```text
/etc/microvm-runner/registration-token
/etc/microvm-runner/nats-password
```
二者必须为 root-only 文件,不能写入镜像、cloud-init seed 或仓库。worker 为每次
启动生成一次性 nonce;guest 只能从 TAP 网段请求一次 registration token,请求后
立即从 worker 内存删除。runner 注册成功后删除 guest 内的 token 文件,再启动
daemon。这样 job 无法从 seed disk 或 Docker inspect 取回可复用 registration token。
NATS stream 使用 `WorkQueuePolicy`,ACK 后立即删除消息;同时限制为 24 小时、
10000 条和 256 MiB,异常积压也不会无限增长。NATS 平台配置见
`../../platform/nats/`。
2026-09-16 PoC 已验证:Cloud Hypervisor v52.0、Ubuntu 24.04、4 vCPU、3 GiB RAM、
10 GiB COW disk 中,Docker 29.1.3 成功创建 kind v0.27.0 / Kubernetes v1.32.2
集群,并运行 `busybox:1.37` smoke Pod。冷启动约 20 秒。pve2 资源只允许一台:
VMM cgroup(guest RAM 与 page cache)峰值约 6.2 GiB。
正式启用前还需要安装 launcher、制作不含凭据的基础镜像、部署 controller,并在
Gitea 配置只发送 `workflow_job` 的 instance/organization webhook。workflow 使用:
```yaml
runs-on: kind-microvm
```
当前 pve2 配置:
```ini
RUNNER_CAPACITY=1
NATS_DURABLE=kind-microvm
RUNNER_MAX_INFLIGHT=64
```
-113
View File
@@ -1,113 +0,0 @@
#!/usr/bin/env python3
"""Gitea workflow_job webhook to NATS JetStream producer."""
import hashlib
import hmac
import json
import os
import ssl
from pathlib import Path
import nats
from aiohttp import web
from nats.js.api import DiscardPolicy, RetentionPolicy, StorageType, StreamConfig
from nats.js.errors import NotFoundError
LABEL = os.environ.get("RUNNER_LABEL", "kind-microvm")
SUBJECT = os.environ.get("NATS_SUBJECT", f"ci.runner.{LABEL}")
STREAM = os.environ.get("NATS_STREAM", "CI_RUNNER")
NATS_URL = os.environ.get("NATS_URL", "tls://nats.nats.svc.cluster.local:4222")
NATS_USER = os.environ.get("NATS_USER", "ci-producer")
NATS_PASSWORD_FILE = Path(os.environ.get("NATS_PASSWORD_FILE", "/run/secrets/nats/password"))
NATS_CA_FILE = os.environ.get("NATS_CA_FILE", "/etc/ssl/certs/ca-certificates.crt")
WEBHOOK_SECRET_FILE = Path(os.environ.get("WEBHOOK_SECRET_FILE", "/run/secrets/gitea/webhook-secret"))
def accepts(payload: object) -> tuple[bool, str | None]:
if not isinstance(payload, dict) or payload.get("action") != "queued":
return False, None
job = payload.get("workflow_job")
if not isinstance(job, dict) or LABEL not in job.get("labels", []):
return False, None
job_id = job.get("id")
if not isinstance(job_id, int):
return False, None
return True, str(job_id)
def valid_signature(body: bytes, signature: str) -> bool:
expected = hmac.new(WEBHOOK_SECRET_FILE.read_bytes().strip(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
async def ensure_stream(js: object) -> None:
config = StreamConfig(
name=STREAM,
subjects=["ci.runner.*"],
retention=RetentionPolicy.WORK_QUEUE,
storage=StorageType.FILE,
discard=DiscardPolicy.OLD,
max_age=24 * 60 * 60,
max_msgs=10_000,
max_bytes=256 * 1024 * 1024,
duplicate_window=24 * 60 * 60,
)
try:
await js.stream_info(STREAM)
except NotFoundError:
await js.add_stream(config=config)
else:
await js.update_stream(config=config)
async def webhook(request: web.Request) -> web.Response:
body = await request.read()
if not valid_signature(body, request.headers.get("X-Gitea-Signature", "")):
raise web.HTTPUnauthorized()
try:
payload = json.loads(body)
except json.JSONDecodeError as error:
raise web.HTTPBadRequest(text="invalid JSON\n") from error
accepted, job_id = accepts(payload)
if not accepted:
return web.Response(status=204)
await request.app["js"].publish(
SUBJECT,
body,
headers={"Nats-Msg-Id": f"gitea-workflow-job-{job_id}"},
)
return web.Response(status=202, text="queued\n")
async def health(request: web.Request) -> web.Response:
return web.Response(text="ok\n" if request.app["nc"].is_connected else "disconnected\n",
status=200 if request.app["nc"].is_connected else 503)
async def nats_context(app: web.Application):
tls = ssl.create_default_context(cafile=NATS_CA_FILE)
nc = await nats.connect(
NATS_URL,
user=NATS_USER,
password=NATS_PASSWORD_FILE.read_text().strip(),
tls=tls,
name="microvm-runner-controller",
)
app["nc"] = nc
app["js"] = nc.jetstream()
await ensure_stream(app["js"])
yield
await nc.drain()
def create_app() -> web.Application:
app = web.Application(client_max_size=1024 * 1024)
app.cleanup_ctx.append(nats_context)
app.router.add_post("/webhook", webhook)
app.router.add_get("/healthz", health)
return app
if __name__ == "__main__":
web.run_app(create_app(), host=os.environ.get("LISTEN", "0.0.0.0"),
port=int(os.environ.get("PORT", "8787")))
@@ -1,19 +0,0 @@
[Unit]
Description=Gitea kind microVM runner worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=-/etc/microvm-runner/worker.env
ExecStart=/opt/microvm-runner/venv/bin/python /opt/microvm-runner/worker.py
Restart=on-failure
RestartSec=5s
SupplementaryGroups=kvm
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/microvm-runner /run/microvm-runner
[Install]
WantedBy=multi-user.target
@@ -1,2 +0,0 @@
aiohttp==3.12.15
nats-py==2.11.0
-138
View File
@@ -1,138 +0,0 @@
#!/usr/bin/env python3
"""Capacity-bounded JetStream consumer that launches one ephemeral VM per job."""
import asyncio
import os
import secrets
import ssl
import uuid
from pathlib import Path
import nats
from aiohttp import web
from nats.errors import TimeoutError
from nats.js.api import AckPolicy, ConsumerConfig
CAPACITY = int(os.environ.get("RUNNER_CAPACITY", "1"))
SUBJECT = os.environ.get("NATS_SUBJECT", "ci.runner.kind-microvm")
STREAM = os.environ.get("NATS_STREAM", "CI_RUNNER")
DURABLE = os.environ.get("NATS_DURABLE", "kind-microvm")
MAX_INFLIGHT = int(os.environ.get("RUNNER_MAX_INFLIGHT", "64"))
NATS_URL = os.environ.get("NATS_URL", "tls://nats.ad.ddupan.top:4222")
NATS_USER = os.environ.get("NATS_USER", "ci-worker")
NATS_PASSWORD_FILE = Path(os.environ.get("NATS_PASSWORD_FILE", "/etc/microvm-runner/nats-password"))
NATS_CA_FILE = os.environ.get("NATS_CA_FILE", "/etc/ssl/certs/ca-certificates.crt")
REGISTRATION_TOKEN_FILE = Path(os.environ.get("REGISTRATION_TOKEN_FILE", "/etc/microvm-runner/registration-token"))
LAUNCHER = os.environ.get("LAUNCHER", "/usr/local/libexec/microvm-runner-launch")
TOKEN_LISTEN = os.environ.get("TOKEN_LISTEN", "172.30.0.1")
TOKEN_PORT = int(os.environ.get("TOKEN_PORT", "8787"))
tokens: dict[str, bytes] = {}
token_lock = asyncio.Lock()
async def token(request: web.Request) -> web.Response:
nonce = request.match_info["nonce"]
async with token_lock:
value = tokens.pop(nonce, None)
if value is None:
raise web.HTTPNotFound()
return web.Response(body=value, headers={"Cache-Control": "no-store"})
async def heartbeat(message: object, stop: asyncio.Event) -> None:
while True:
try:
await asyncio.wait_for(stop.wait(), timeout=60)
return
except asyncio.TimeoutError:
await message.in_progress()
async def run_one(message: object) -> None:
instance_id = str(uuid.uuid4())
nonce = secrets.token_urlsafe(32)
async with token_lock:
tokens[nonce] = REGISTRATION_TOKEN_FILE.read_bytes().strip()
stop = asyncio.Event()
pulse = asyncio.create_task(heartbeat(message, stop))
try:
process = await asyncio.create_subprocess_exec(LAUNCHER, instance_id, nonce)
return_code = await process.wait()
if return_code == 0:
await message.ack()
else:
await message.nak(delay=30)
except Exception:
await message.nak(delay=30)
raise
finally:
stop.set()
await pulse
async with token_lock:
tokens.pop(nonce, None)
async def consume() -> None:
if CAPACITY < 1:
raise ValueError("RUNNER_CAPACITY must be at least 1")
tls = ssl.create_default_context(cafile=NATS_CA_FILE)
nc = await nats.connect(
NATS_URL,
user=NATS_USER,
password=NATS_PASSWORD_FILE.read_text().strip(),
tls=tls,
name=DURABLE,
)
js = nc.jetstream()
subscription = await js.pull_subscribe(
SUBJECT,
durable=DURABLE,
stream=STREAM,
config=ConsumerConfig(
durable_name=DURABLE,
filter_subject=SUBJECT,
ack_policy=AckPolicy.EXPLICIT,
ack_wait=5 * 60,
# This durable consumer is shared by every host of this runner type.
# Local CAPACITY controls each host; this is only a global safety cap.
max_ack_pending=MAX_INFLIGHT,
max_deliver=5,
),
)
active: set[asyncio.Task[None]] = set()
try:
while True:
active = {task for task in active if not task.done()}
free = CAPACITY - len(active)
if free == 0:
await asyncio.wait(active, return_when=asyncio.FIRST_COMPLETED)
continue
try:
messages = await subscription.fetch(batch=free, timeout=5)
except TimeoutError:
continue
for message in messages:
task = asyncio.create_task(run_one(message))
task.add_done_callback(lambda done: done.exception())
active.add(task)
finally:
if active:
await asyncio.gather(*active, return_exceptions=True)
await nc.drain()
async def main() -> None:
app = web.Application()
app.router.add_get("/token/{nonce}", token)
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, TOKEN_LISTEN, TOKEN_PORT).start()
try:
await consume()
finally:
await runner.cleanup()
if __name__ == "__main__":
asyncio.run(main())
-57
View File
@@ -1,57 +0,0 @@
# OpenBao 监控运维
受鉴权 `/v1/sys/metrics` 通过 vmagent 内的 Bao Agent 使用 Kubernetes 身份采集。
`metrics` policy 仅允许读取该路径;policy 和 role 由 `terraform/monitoring.tf` 管理。
Bao VM 的 Ubuntu `prometheus-node-exporter` 软件包监听 `192.168.10.8:9100`,
由 `ansible/roles/openbao_monitoring` 管理。仅供内网监控,不配置公共入口;
现场 UFW 未启用,故当前边界是 LAN 地址绑定,并非逐来源访问控制。
标准主机指标复用 `job=node-exporter` 的主机告警,额外标记 `node=bao1`。
首次部署前需要 bootstrap 已创建 `/etc/openbao/snapshot.token` 和快照目录。
只更新监控与快照运行文件(不会重启 Bao 或轮换 token):
```bash
cd ansible
ansible-playbook monitor-openbao.yml --check
ansible-playbook monitor-openbao.yml
# 在 bao1 上执行一次真实快照,建立成功基线:
sudo systemctl start openbao-snapshot.service
```
快照脚本在 `/var/lib/prometheus/node-exporter/` 原子更新两个 `.prom` 文件:
- `openbao_snapshot_result.prom`:最近一次运行结果(0/1)及完成时间。
- `openbao_snapshot_success.prom`:最后一次成功生成完整本地快照的时间。
失败保留上次成功时间,先完成快照再删除超出保留数量的文件。
指标文件 root 写、exporter 只读;不包含 token、路径标签或快照内容。
首次成功前成功指标缺失,不用既有文件 mtime 冒充已验证成功。
Bao sealed 或 token 失效时,独立 exporter 仍能报告失败及超时。
`PrometheusRule/openbao` 包含:采集不可用(3 分钟)、内部健康异常(3 分钟)、
健康 gauge 缺失(5 分钟)、快照失败(5 分钟)、快照超过 36 小时或无成功记录
(持续 15 分钟)、快照监控缺失/损坏(5 分钟)。内部健康规则按当前**单节点**设计,
以后增加 standby 必须先调整 active 判定。快照指标仅证明本地任务成功,不能证明异地备份或可恢复性。
检查与恢复入口:
```bash
sudo systemctl status openbao-snapshot.timer openbao-snapshot.service prometheus-node-exporter
sudo journalctl -u openbao-snapshot.service -n 50 --no-pager
curl -fsS http://192.168.10.8:9100/metrics | grep '^openbao_snapshot_'
```
token 续期失败按[维护 runbook](https://git.ddupan.top/panxiao81/homelab-wiki/src/branch/main/guides/openbao-monitoring-maintenance.md)
受控轮换;不要通过反复重启 Bao 排障。回滚 exporter 或快照脚本无需重启 Bao。
回滚时同步撤回相应 ServiceMonitor/规则,否则缺指标告警会继续触发。
本地验证:
```bash
python3 -m unittest discover -s infrastructure/openbao/ansible/tests -v
# 需 PyYAML、Jinja2 和 promtool;已用 promtool 3.5.0 验证。
PATH=/path/to/promtool:$PATH bash platform/observability/metrics/tests/check-rules.sh
```
textfile 的原子发布方式遵循 [node_exporter 文档](https://github.com/prometheus/node_exporter#textfile-collector)。
@@ -1,13 +0,0 @@
---
# 只部署 exporter、快照脚本和 timer,不改 Bao 配置、不重启 Bao、不创建 token。
# 前提:bootstrap 已安装有效的 /etc/openbao/snapshot.token。
- name: 补齐 OpenBao 主机和快照监控
hosts: openbao
become: true
roles:
- openbao_monitoring
tasks:
- name: 更新快照运行文件
ansible.builtin.include_role:
name: openbao_bootstrap
tasks_from: snapshot-runtime
@@ -47,6 +47,3 @@ openbao_transit_mount_path: "transit/"
openbao_manage_firewall: false
openbao_allowed_cidrs:
- "100.64.0.0/10"
# 30 秒采集间隔下保留五分钟,指标端点继续要求鉴权。
openbao_prometheus_retention_time: "5m"
@@ -25,9 +25,3 @@ seal "transit" {
mount_path = "{{ openbao_transit_mount_path }}"
}
{% endif %}
# 显式固定 Prometheus 行为;应用此配置会经 handler 重启,须人工解封。
telemetry {
prometheus_retention_time = "{{ openbao_prometheus_retention_time }}"
disable_hostname = true
}
@@ -96,8 +96,3 @@ openbao_snapshot_oncalendar: "*-*-* 02:00:00"
# 3. terraform apply # mounts, roles, policies
# 4. ansible-playbook bootstrap-openbao.yml # CA material, OIDC secret, snapshots
openbao_config_managed_by_terraform: true
# 仅在维护窗口显式启用,用于替换已失效的快照 token;不读取/打印 token。
openbao_snapshot_rotate_token: false
openbao_snapshot_metrics_dir: /var/lib/prometheus/node-exporter
@@ -1,34 +0,0 @@
---
- name: Ensure the snapshot output directory exists
ansible.builtin.file:
path: "{{ openbao_snapshot_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Install the snapshot script
ansible.builtin.template:
src: bao-snapshot.sh.j2
dest: /usr/local/bin/bao-snapshot.sh
owner: root
group: root
mode: "0755"
- name: Install the snapshot systemd service + timer
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- openbao-snapshot.service
- openbao-snapshot.timer
- name: Enable and start the snapshot timer
ansible.builtin.systemd:
name: openbao-snapshot.timer
state: started
enabled: true
daemon_reload: true
@@ -17,11 +17,11 @@
path: /etc/openbao/snapshot.token
register: snap_tok_stat
- name: Create or explicitly rotate the periodic snapshot token
- name: Create a periodic snapshot token (once)
ansible.builtin.command: "bao token create -policy=snapshot -period=768h -orphan -field=token"
environment: "{{ openbao_cli_env }}"
register: snap_tok_new
when: (not snap_tok_stat.stat.exists) or (openbao_snapshot_rotate_token | bool)
when: not snap_tok_stat.stat.exists
changed_when: snap_tok_new.rc == 0
no_log: true
@@ -32,12 +32,39 @@
owner: root
group: root
mode: "0600"
when: (not snap_tok_stat.stat.exists) or (openbao_snapshot_rotate_token | bool)
when: not snap_tok_stat.stat.exists
no_log: true
- name: 安装独立的主机与快照指标 exporter
ansible.builtin.include_role:
name: openbao_monitoring
- name: Ensure the snapshot output directory exists
ansible.builtin.file:
path: "{{ openbao_snapshot_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: 部署快照脚本和定时器
ansible.builtin.import_tasks: snapshot-runtime.yml
- name: Install the snapshot script
ansible.builtin.template:
src: bao-snapshot.sh.j2
dest: /usr/local/bin/bao-snapshot.sh
owner: root
group: root
mode: "0755"
- name: Install the snapshot systemd service + timer
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- openbao-snapshot.service
- openbao-snapshot.timer
- name: Enable and start the snapshot timer
ansible.builtin.systemd:
name: openbao-snapshot.timer
state: started
enabled: true
daemon_reload: true
@@ -1,58 +1,20 @@
#!/usr/bin/env bash
# {{ ansible_managed }}
# 本机快照不等于异地备份。结果通过独立 node_exporter 上报。
# Take a Raft snapshot and prune old ones. Ship {{ openbao_snapshot_dir }} off-box
# separately (rsync/restic/scp) — a snapshot on the same host is not a backup.
set -euo pipefail
umask 077
dir="{{ openbao_snapshot_dir }}"
metrics_dir="{{ openbao_snapshot_metrics_dir }}"
# systemd oneshot 不会并发;flock 同时阻止手动执行与 timer 竞争。
exec 9>"${dir}/.snapshot.lock"
flock -n 9 || exit 0
partial=""
metrics_tmp=""
finish() {
rc=$?
trap - EXIT
[ -z "$partial" ] || rm -f -- "$partial"
# success 文件只在实际快照完成后更新;失败不能抹掉上次成功时间。
metrics_tmp="$(mktemp "${metrics_dir}/.openbao-result.XXXXXX")" || exit 1
{
echo '# HELP openbao_snapshot_last_run_success Whether the last snapshot run succeeded.'
echo '# TYPE openbao_snapshot_last_run_success gauge'
if [ "$rc" -eq 0 ]; then echo 'openbao_snapshot_last_run_success 1'; else echo 'openbao_snapshot_last_run_success 0'; fi
echo '# HELP openbao_snapshot_last_run_timestamp_seconds Completion time of the last snapshot attempt.'
echo '# TYPE openbao_snapshot_last_run_timestamp_seconds gauge'
echo "openbao_snapshot_last_run_timestamp_seconds $(date +%s)"
} > "$metrics_tmp"
chmod 644 "$metrics_tmp"
mv -f -- "$metrics_tmp" "${metrics_dir}/openbao_snapshot_result.prom"
exit "$rc"
}
trap finish EXIT
export BAO_ADDR="{{ openbao_addr }}"
export BAO_CACERT="{{ openbao_tls_dir }}/cert.pem"
BAO_TOKEN="$(cat /etc/openbao/snapshot.token)"
export BAO_TOKEN
bao token renew >/dev/null
dir="{{ openbao_snapshot_dir }}"
stamp="$(date +%Y%m%d-%H%M%S)"
out="${dir}/openbao-${stamp}.snap"
partial="${out}.partial"
bao operator raft snapshot save "$partial"
chmod 600 "$partial"
mv -- "$partial" "$out"
partial=""
metrics_tmp="$(mktemp "${metrics_dir}/.openbao-success.XXXXXX")"
{
echo '# HELP openbao_snapshot_last_success_timestamp_seconds Completion time of the last successful local Raft snapshot.'
echo '# TYPE openbao_snapshot_last_success_timestamp_seconds gauge'
echo "openbao_snapshot_last_success_timestamp_seconds $(date +%s)"
} > "$metrics_tmp"
chmod 644 "$metrics_tmp"
mv -f -- "$metrics_tmp" "${metrics_dir}/openbao_snapshot_success.prom"
bao operator raft snapshot save "${out}"
chmod 600 "${out}"
# 只有产生完整快照后才做保留清理。
# Retention: keep the newest {{ openbao_snapshot_keep }}.
ls -1t "${dir}"/openbao-*.snap 2>/dev/null | tail -n +{{ openbao_snapshot_keep + 1 }} | xargs -r rm -f
@@ -1,3 +0,0 @@
---
openbao_metrics_listen_address: "{{ ansible_host }}:9100"
openbao_snapshot_metrics_dir: /var/lib/prometheus/node-exporter
@@ -1,6 +0,0 @@
---
- name: restart openbao node exporter
ansible.builtin.systemd:
name: prometheus-node-exporter
state: restarted
when: not ansible_check_mode
@@ -1,35 +0,0 @@
---
- name: 安装 Ubuntu node_exporter 软件包
ansible.builtin.apt:
name: prometheus-node-exporter
state: present
update_cache: true
cache_valid_time: 3600
install_recommends: false
policy_rc_d: 101
- name: 创建只允许 root 写入的 textfile 目录
ansible.builtin.file:
path: "{{ openbao_snapshot_metrics_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: 将 exporter 绑定到内网地址并启用 textfile
ansible.builtin.copy:
dest: /etc/default/prometheus-node-exporter
owner: root
group: root
mode: "0644"
content: |
# Ansible managed; no Bao credentials needed.
ARGS="--web.listen-address={{ openbao_metrics_listen_address }} --collector.textfile.directory={{ openbao_snapshot_metrics_dir }}"
notify: restart openbao node exporter
- name: 启用主机指标服务
ansible.builtin.systemd:
name: prometheus-node-exporter
enabled: true
state: started
when: not ansible_check_mode
@@ -1,81 +0,0 @@
"""用假 bao 验证失败不会推进成功时间或删除已有快照。"""
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from jinja2 import Template
TEMPLATE = Path(__file__).resolve().parents[1] / 'roles/openbao_bootstrap/templates/bao-snapshot.sh.j2'
class SnapshotTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.snap = self.root / 'snapshots'
self.metrics = self.root / 'metrics'
self.snap.mkdir()
self.metrics.mkdir()
self.token = self.root / 'token'
self.token.write_text('test-only')
self.script = self.root / 'snapshot.sh'
self.script.write_text(Template(TEMPLATE.read_text()).render(
ansible_managed='test', openbao_snapshot_dir=str(self.snap),
openbao_snapshot_metrics_dir=str(self.metrics), openbao_addr='https://invalid',
openbao_tls_dir='/unused', openbao_snapshot_keep=2,
).replace('/etc/openbao/snapshot.token', str(self.token)))
bao = self.root / 'bao'
bao.write_text('''#!/usr/bin/env bash
if [ "$1" = token ]; then
[ "${FAIL_AT:-}" != renew ]; exit $?
fi
printf snapshot > "$5"
[ "${FAIL_AT:-}" != save ]
''')
bao.chmod(0o755)
for n in range(3):
p = self.snap / f'openbao-old{n}.snap'
p.write_text('old snapshot')
os.utime(p, (100+n, 100+n))
self.success = self.metrics / 'openbao_snapshot_success.prom'
self.success.write_text('openbao_snapshot_last_success_timestamp_seconds 123\n')
def run_snapshot(self, failure=''):
return subprocess.run(['bash', str(self.script)], env=dict(os.environ,
PATH=str(self.root)+':'+os.environ['PATH'], FAIL_AT=failure), capture_output=True)
def test_renew_failure_preserves_success_and_snapshots(self):
self.assertNotEqual(self.run_snapshot('renew').returncode, 0)
self.check_failure()
def test_partial_snapshot_is_removed(self):
self.assertNotEqual(self.run_snapshot('save').returncode, 0)
self.check_failure()
self.assertEqual(list(self.snap.glob('*.partial')), [])
def test_missing_token_is_reported(self):
self.token.unlink()
self.assertNotEqual(self.run_snapshot().returncode, 0)
self.check_failure()
def check_failure(self):
self.assertEqual(self.success.read_text(), 'openbao_snapshot_last_success_timestamp_seconds 123\n')
self.assertIn('openbao_snapshot_last_run_success 0', (self.metrics/'openbao_snapshot_result.prom').read_text())
self.assertEqual(len(list(self.snap.glob('*.snap'))), 3)
def test_success_retention_and_permissions(self):
p = self.run_snapshot()
self.assertEqual(p.returncode, 0, p.stderr)
self.assertNotIn('seconds 123\n', self.success.read_text())
self.assertIn('openbao_snapshot_last_run_success 1', (self.metrics/'openbao_snapshot_result.prom').read_text())
snapshots = list(self.snap.glob('*.snap'))
self.assertEqual(len(snapshots), 2)
new = next(p for p in snapshots if 'old' not in p.name)
self.assertEqual(new.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.success.stat().st_mode & 0o777, 0o644)
if __name__ == '__main__':
unittest.main()
@@ -68,14 +68,3 @@ import {
to = vault_pki_secret_backend_config_urls.this
id = "pki/config/urls"
}
# 本次维护预检先通过同名 API 配置验证,后续 Terraform 纳入既有主 state。
import {
to = vault_policy.metrics
id = "metrics"
}
import {
to = vault_kubernetes_auth_backend_role.metrics
id = "auth/kubernetes/role/metrics"
}
@@ -1,15 +0,0 @@
# 指标只读身份;vmagent 的 Bao Agent sidecar 使用 Pod SA 自动登录与续期。
resource "vault_policy" "metrics" {
name = "metrics"
policy = file("${path.module}/policies/metrics.hcl")
}
resource "vault_kubernetes_auth_backend_role" "metrics" {
backend = "kubernetes"
role_name = "metrics"
bound_service_account_names = ["vmagent-main"]
bound_service_account_namespaces = ["monitoring"]
token_policies = [vault_policy.metrics.name]
token_ttl = 900
token_max_ttl = 3600
}
@@ -1,4 +0,0 @@
# 不含秘密读取或管理权限;默认 policy 只用于自身 token 续期等通用能力。
path "sys/metrics" {
capabilities = ["read"]
}
@@ -1 +0,0 @@
__pycache__/
@@ -1,79 +0,0 @@
# 共享数据服务维护监控
为 laptop、etcd-pve1(10.60.0.20)、etcd-pve2(10.60.0.21)补齐备份、磁盘证书、
续签检查与 dev SQL 的指标。复用现有 VictoriaMetrics/Alertmanager;不部署新监控栈。
实现由 [PR #166](https://git.ddupan.top/panxiao81/homelab-infra/pulls/166) 发布。
2026-09-27:三个主机端已部署并验证,Ansible 重跑均 `changed=0`;vmagent Pod 能访问三个
内网 `:9109/metrics`。PR #166 已合并,Flux observability Ready,应用版本 `589c34e`;
PrometheusRule 与 VMStaticScrape 已进入 inventory,converter 生成的 VMRule 为 operational。
三个抓取目标均 up=1,八条规则均 health=ok、inactive,告警已启用。
## 采集和阈值
每分钟执行 `homelab-data-collect.service`,读取明确清单并原子替换
`/var/lib/homelab-data-monitoring/health.prom`。每项检查失败输出零值及 `check_success=0`,
不沿用旧成功值;完整采集时间用于发现任务停止或文件缺失。端点不暴露密码、token、私钥。
| 项目 | 证据 | 告警 |
|---|---|---|
| etcd 快照 | 每成员已完成 `.db` 文件 mtime,排除 `.partial` | 超过 36 小时 warning |
| PG full 备份 | 仓库本地 pgBackRest JSON 中无错误 full 的完成时间 | 超过 36 小时 warning |
| 证书 | 显式清单包含 server/peer/admin/gateway/PG etcd client/CA;取完整 PEM 链最早到期时间 | 不足 10 天 warning;不足 3 天或无法读取 critical |
| 续签检查 | laptop 两个 renew service 的成功退出时间及 timer active | 失败、停用或超过 36 小时 warning |
| dev | 本地 socket、pgdev peer 身份,只读 SQL 检查非 recovery 且非只读 | 连续异常 3 分钟 warning |
| 采集链路 | exporter up、单项检查、textfile 错误、完整采集时间 | 失败或心跳超过 3 分钟后持续 3 分钟 warning |
证书在不足 14 天时由原自动续签任务处理,10 天阈值给重试留出余量;critical 不重复触发 warning。
36 小时适用于现有每日快照/full/续签检查计划。dev 未新增备份,异地备份仍后置。
node_exporter 当前版本为 1.9.1;下载包与官方 `sha256sums.txt` 均由
`data_monitor_exporter_version` 生成,Ansible 按包名匹配摘要,升级只改版本变量;保留上游 LICENSE。
仅启用 textfile,使用 DynamicUser,绑定各主机内网地址的 9109,与现有 9100 不冲突。
采集程序以 root 运行,以便读取快照元数据和切换为 pgdev/pgbackup;systemd 限制文件系统写入
到 textfile 目录,限制执行时间与内存。无需 Bao 登录;数据库和 etcd 不因部署监控重启。
采集并非常驻进程;laptop 上 exporter 当前 MemoryCurrent 约 2.6 MiB,仅作现场开销参考。
## 部署和验证
在 PR 分支或合并后的完整 checkout 中执行:
```bash
cd infrastructure/shared-data-monitoring/ansible
ansible-playbook site.yml
```
本机需要免交互 sudo,远程使用已有 root SSH;配置不包含秘密。当前实现仅支持 Linux amd64,
依赖现有 Python 3、OpenSSL、systemd、PG 客户端和 pgBackRest。下载 exporter 需要访问 GitHub。
只读查看结果:
```bash
curl --fail http://192.168.10.127:9109/metrics
sudo systemctl status homelab-data-collect.timer homelab-data-exporter.service
sudo journalctl -u homelab-data-collect.service --since '2 hours ago'
```
主机端 `check_success` 应全为 1、`dev_sql_ready=1`,`node_textfile_scrape_error=0`。
日常检查 `up{job="shared-data-maintenance"}` 三个目标及 `shared-data-maintenance` 规则组。
告警声明使用 `monitoring.coreos.com/v1` 的 `PrometheusRule`,由现有 converter 生成 VMRule;
静态主机抓取使用 VMStaticScrape。Kubernetes 资源由现有 observability Kustomization 纳管;
主机端由此 Ansible 独立维护。
2026-09-27 验证:6 项采集测试、12 个新增 promtool 场景及既有规则回归通过;Ansible lint
零失败零告警;Kustomize 渲染通过;三主机实际采集和 vmagent 网络路径通过。未停止业务来测试告警。
## 故障入口与边界
- 采集停止:先查 exporter 和 collect timer/service,再看 `.prom` 的 collection timestamp。
- 备份陈旧:etcd 查 `homelab-etcd-snapshot`;PG 仓库查 `homelab-postgresql-backup`。
不以 touch 文件消除告警。快照年龄依赖完成文件 mtime,手工复制会改变证据,不能冒充新备份。
- 证书或续签异常:查 laptop 的 `homelab-etcd-renew` / `homelab-postgresql-renew`;
修复 Bao/SPIRE/网络或受限身份后重跑原续签入口,不删除现有证书或重置数据库。
- dev SQL 失败:查 `homelab-postgresql-dev`、磁盘与 socket,再核对本地 peer 认证。
备份年龄不能证明恢复能力或 WAL 连续性;定期隔离恢复演练仍必要。证书指标读取磁盘文件,
不证明运行进程已重载该证书。dev 检查不验证外部 DNS/TLS 路径、不执行写入。
SQL 级指标见 [共享 PG](../shared-postgresql/README.md#sql-级指标2026-10-01-主机端上线);异地备份及应用迁移不在本次范围。
跨服务状态见 [共享 PG wiki](https://git.ddupan.top/panxiao81/homelab-wiki/src/branch/main/services/shared-postgresql.md)。
@@ -1,5 +0,0 @@
[defaults]
inventory = inventory/hosts.yml
host_key_checking = True
retry_files_enabled = False
interpreter_python = auto_silent
@@ -1,20 +0,0 @@
---
all:
children:
shared_data_monitoring:
hosts:
laptop:
ansible_connection: local
ansible_user: panxiao81
ansible_host: 192.168.10.127
data_monitor_dev: true
data_monitor_pg: true
data_monitor_renewals: [homelab-etcd-renew, homelab-postgresql-renew]
etcd-pve1:
ansible_host: 10.60.0.20
ansible_user: root
data_monitor_pg: true
etcd-pve2:
ansible_host: 10.60.0.21
ansible_user: root
data_monitor_repository: true
@@ -1,146 +0,0 @@
---
- name: 采集共享数据服务维护指标
hosts: shared_data_monitoring
become: true
gather_facts: false
vars:
data_monitor_root: /opt/homelab-data-monitoring
data_monitor_exporter_version: 1.9.1
data_monitor_download_base: https://github.com/prometheus/node_exporter/releases/download
data_monitor_archive: "node_exporter-{{ data_monitor_exporter_version }}.linux-amd64"
tasks:
- name: 检查仅支持 amd64
ansible.builtin.command: uname -m
register: data_monitor_arch
changed_when: false
- name: 拒绝错误架构
ansible.builtin.assert:
that: data_monitor_arch.stdout == 'x86_64'
- name: 创建 root 管理目录
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: '0755'
loop:
- "{{ data_monitor_root }}"
- /etc/homelab-data-monitoring
- /var/lib/homelab-data-monitoring
- name: 下载 exporter 并校验同版本官方摘要
ansible.builtin.get_url:
url: >-
{{ data_monitor_download_base }}/v{{ data_monitor_exporter_version }}/{{ data_monitor_archive }}.tar.gz
dest: "{{ data_monitor_root }}/node_exporter.tar.gz"
checksum: >-
sha256:{{ data_monitor_download_base }}/v{{ data_monitor_exporter_version }}/sha256sums.txt
owner: root
group: root
mode: '0644'
- name: 解压 exporter(保留上游许可证)
ansible.builtin.unarchive:
src: "{{ data_monitor_root }}/node_exporter.tar.gz"
dest: "{{ data_monitor_root }}"
remote_src: true
creates: "{{ data_monitor_root }}/node_exporter-{{ data_monitor_exporter_version }}.linux-amd64/node_exporter"
- name: 安装只读采集程序
ansible.builtin.copy:
src: ../files/collect.py
dest: "{{ data_monitor_root }}/collect.py"
owner: root
group: root
mode: '0755'
- name: 配置明确的证书与检查清单
ansible.builtin.template:
src: config.json.j2
dest: /etc/homelab-data-monitoring/config.json
owner: root
group: root
mode: '0644'
- name: 安装采集 service
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-data-collect.service
mode: '0644'
content: |
[Unit]
Description=Read-only shared data maintenance checks
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 {{ data_monitor_root }}/collect.py \
--config /etc/homelab-data-monitoring/config.json \
--output /var/lib/homelab-data-monitoring/health.prom
Environment=LC_ALL=C
Environment=PGCONNECT_TIMEOUT=5
Environment="PGOPTIONS=-c statement_timeout=5000"
TimeoutStartSec=50
UMask=0022
ProtectSystem=strict
ReadWritePaths=/var/lib/homelab-data-monitoring
PrivateTmp=true
MemoryMax=96M
Nice=10
- name: 安装每分钟采集 timer
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-data-collect.timer
mode: '0644'
content: |
[Unit]
Description=Collect shared data maintenance metrics every minute
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=5s
[Install]
WantedBy=timers.target
- name: 安装仅内网 textfile exporter
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-data-exporter.service
mode: '0644'
content: |
[Unit]
Description=Shared data textfile metrics
After=network-online.target
Wants=network-online.target
[Service]
DynamicUser=true
ExecStart={{ data_monitor_root }}/{{ data_monitor_archive }}/node_exporter \
--web.listen-address={{ ansible_host }}:9109 \
--collector.disable-defaults --collector.textfile \
--collector.textfile.directory=/var/lib/homelab-data-monitoring --web.disable-exporter-metrics
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
MemoryMax=64M
[Install]
WantedBy=multi-user.target
notify: Restart shared data exporter
- name: 重载 unit 声明
ansible.builtin.systemd_service:
daemon_reload: true
- name: 首次只读采集并检查程序能够完成
ansible.builtin.command:
argv:
- /usr/bin/python3
- "{{ data_monitor_root }}/collect.py"
- --config
- /etc/homelab-data-monitoring/config.json
- --output
- /var/lib/homelab-data-monitoring/health.prom
environment:
LC_ALL: C
PGCONNECT_TIMEOUT: '5'
PGOPTIONS: '-c statement_timeout=5000'
changed_when: false
- name: 启用监控服务
ansible.builtin.systemd_service:
name: "{{ item }}"
enabled: true
state: started
loop: [homelab-data-collect.timer, homelab-data-exporter.service]
handlers:
- name: Restart shared data exporter
ansible.builtin.systemd_service:
name: homelab-data-exporter.service
state: restarted

Some files were not shown because too many files have changed in this diff Show More