Author SHA1 Message Date
flux-bot 69d3476a6b chore(image): update zot.ad.ddupan.top/panxiao81/backstage:latest@sha256:fadd1ac9eaec52eddf105a81f5750878ca1b6fe49e0c6a987c346e501c59b95d -> zot.ad.ddupan.top/panxiao81/backstage:latest@sha256:da55be5c2f5c8b33de2d87ee0ef02edeacf52a349c8b19a5fac9be4152b2723d
yaml / yaml (push) Successful in 41s
2026-10-01 16:51:52 +00:00
panxiao81 6e4deb2d42 Merge pull request '修正共享 PG 计数器告警在新序列上的误报' (#174) from fix/shared-pg-sql-counter-new-series into main
yaml / yaml (push) Successful in 41s
Reviewed-on: #174
2026-10-01 16:46:02 +00:00
panxiao81 7f64c5d2b9 Merge pull request 'Hydra 为 iam-login /console 开启 CORS' (#175) from feat/iam-console-cors into main
yaml / yaml (push) Successful in 23s
Reviewed-on: #175
2026-10-01 16:45:43 +00:00
panxiao81andClaude Opus 5.5 5fd00be136 Hydra 为 iam-login /console 开启 CORS 并记录管理客户端登记
yaml / yaml (pull_request) Successful in 21s
iam-login 的 /console 是 Hydra public 客户端,浏览器直接向 Hydra
换取 token,因此 public 端口开启 CORS,只放行开发实例 origin,不放行
cookie 凭据;Gitea 等服务端客户端不受影响。README 记录 iam-admin-ui
经 Admin 带外登记的方法。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:42:07 +00:00
panxiao81andClaude Opus 5.5 2b382e1579 修正共享 PG 计数器告警在新序列上的误报
yaml / yaml (pull_request) Successful in 31s
MetricsQL 的 increase() 把新序列首个样本视为从 0 增长:#173 合并后 exporter
首次被抓取,laptop 上 2026-09-25 搭建期累计的 9 次归档失败被当成刚发生,
触发 SharedPostgresWalArchiveFailing。归档本身正常(切换后 .partial 已归档)。

归档失败、死锁、checkpoint 三条改为与 offset 相减:新序列返回空,
计数器重置得负值也不告警。promtool 按 Prometheus 语义执行,测不出这一差异,
新表达式已在 VictoriaMetrics 现网数据上核对为空;补充新序列与重置回归场景。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:41:04 +00:00
panxiao81 2c7dd224cf Merge pull request '新增共享 PG 的 SQL 级指标与 dev 告警分流' (#173) from feat/shared-pg-sql-metrics into main
yaml / yaml (push) Successful in 2m44s
ansible / collection-test (push) Successful in 4m51s
ansible / lint (push) Successful in 4m53s
Reviewed-on: #173
2026-10-01 16:33:05 +00:00
panxiao81andClaude Opus 5.5 943d31bf08 新增主库落在 pve1(HDD)时的回切提示告警
yaml / yaml (pull_request) Successful in 2m25s
ansible / lint (pull_request) Successful in 4m3s
ansible / collection-test (pull_request) Successful in 4m12s
自动切换后主库安全落到 pve1 不会触发无主/多主规则;2026-09-30 的切换一天无人察觉。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:30:45 +00:00
panxiao81andClaude Opus 5.5 954bb39a23 docs: 记录共享 PG SQL 级指标的部署与验证
ansible / collection-test (pull_request) Successful in 9m52s
yaml / yaml (pull_request) Failing after 10m40s
ansible / lint (pull_request) Successful in 12m38s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:20:16 +00:00
panxiao81andClaude Opus 5.5 549f82810e 修正 Patroni 已接收配置的判断:重启前预加载库行带 error 属预期
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:20:16 +00:00
panxiao81andClaude Opus 5.5 567b42ed13 修正 monitoring.yml 的 check 模式与 Patroni pending 判断
只读探测在 check 模式下照常执行;依赖真实执行结果的等待与验证在 check 模式跳过。
patronictl JSON 会省略空值字段,pending 判断先检查键是否存在。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:20:15 +00:00
panxiao81andClaude Opus 5.5 0cd4a67e12 新增共享 PG 的 SQL 级指标与 dev 告警分流
- 数据库主机上按实例运行 prometheus-community/postgres_exporter 0.20.1,
  以实例 OS 用户走本地 socket,经 peer + ident 映射到仅有 pg_monitor 的
  homelab_monitor 角色;无密码、不经 Bao。版本与官方 sha256sums.txt 同源。
- prod/dev 启用 pg_stat_statements;monitoring.yml 仅在未生效时 reload/重启
  (prod 走 Patroni pending restart),重复执行 changed=0。
- 告警以上游 postgres_mixin 为底改写为 PrometheusRule,阈值对齐 Patroni
  failover lag 与 max_slot_wal_keep_size;抓取目标带 env 标签。
- Alertmanager 新增 env="dev" 路由到独立 dev 频道,dev SQL 检查告警补 env。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:20:15 +00:00
flux-bot cd9d461950 chore(image): update zot.ad.ddupan.top/panxiao81/backstage:latest@sha256:bbc8bacf373ae9009047d3bd00b885995efdd6d365452206d4ed5cf364912668 -> zot.ad.ddupan.top/panxiao81/backstage:latest@sha256:fadd1ac9eaec52eddf105a81f5750878ca1b6fe49e0c6a987c346e501c59b95d
yaml / yaml (push) Successful in 18s
2026-10-01 16:16:46 +00:00
flux-bot a26e5bf214 chore(image): update zot.ad.ddupan.top/panxiao81/backstage:latest@sha256:008a3ccf832c9ee061ef03766022789a7539bd9d001a658af2e9f3ff59a9a5cc -> zot.ad.ddupan.top/panxiao81/backstage:latest@sha256:bbc8bacf373ae9009047d3bd00b885995efdd6d365452206d4ed5cf364912668
yaml / yaml (push) Successful in 28s
2026-10-01 16:07:46 +00:00
panxiao81 186a0cb582 Merge pull request 'Backstage 通过 Flux image automation 自动部署 latest' (#172) from feat/backstage-image-automation into main
yaml / yaml (push) Successful in 30s
2026-10-01 16:06:35 +00:00
panxiao81andClaude Opus 5.5 7f5bef829f feat: deploy backstage latest via Flux image automation
yaml / yaml (pull_request) Successful in 23s
Install image-reflector and image-automation controllers, track the digest
behind backstage:latest, and let flux-bot commit digest updates to main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:05:13 +00:00
panxiao81 53e2854993 Merge pull request '将 Hydra 实验登录接至 Spring IAM 开发实例' (#168) from feat/iam-login-hydra-acceptance into main
yaml / yaml (push) Successful in 20s
Reviewed-on: #168
2026-10-01 14:40:20 +00:00
panxiao81 61674df541 将 Hydra 实验登录回调接至 Spring IAM 开发实例
yaml / yaml (pull_request) Successful in 26s
2026-09-29 15:45:21 +00:00
48 changed files with 2164 additions and 841 deletions
-30
View File
@@ -1,30 +0,0 @@
# 独立增量声明:全量 values.yaml 尚未覆盖所有线上客户端,不能用它覆盖 release。
authorization_policies:
incus_admin:
default_policy: deny
rules:
- policy: two_factor
subject: user:panxiao81
clients:
- client_id: incus
client_name: Incus
public: true
authorization_policy: incus_admin
require_pkce: true
pkce_challenge_method: S256
redirect_uris:
- https://incus.ad.ddupan.top/oidc/callback
audience:
- https://incus.ad.ddupan.top
scopes:
- openid
- offline_access
response_types:
- code
grant_types:
- authorization_code
- refresh_token
- urn:ietf:params:oauth:grant-type:device_code
access_token_signed_response_alg: RS256
userinfo_signed_response_alg: none
token_endpoint_auth_method: none
+10
View File
@@ -18,3 +18,13 @@ 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@sha256:008a3ccf832c9ee061ef03766022789a7539bd9d001a658af2e9f3ff59a9a5cc
image: zot.ad.ddupan.top/panxiao81/backstage:latest@sha256:da55be5c2f5c8b33de2d87ee0ef02edeacf52a349c8b19a5fac9be4152b2723d # {"$imagepolicy": "flux-system:backstage"}
imagePullPolicy: IfNotPresent
env:
- name: BACKSTAGE_BASE_URL
+61 -6
View File
@@ -1,14 +1,69 @@
# Hydra 与 OIDC Login/Consent PoC
# Hydra 与独立 IAM 登录接入
本目录提供独立 Hydra 签发服务,以及一个薄的 **OIDC 上游适配器**。当前上游配置为
Authelia;适配器不连接 LDAP,也不管理用户目录。Samba AD、密码和 MFA 继续由现有
Authelia 链路负责。第一轮只接入人类和 Gitea,不实现 agent 动态授权。
本目录提供独立 Hydra 签发服务。当前分支将实验性 Hydra 登录入口接至 Spring `iam-login`
开发实例,由其执行 AD 密码、WebAuthn 和授权确认;不改变 issuer、Gitea 登录源或数据库。
旧 Go OIDC 适配器继续部署以便回退,Gitea 的直接 Authelia 登录源也保留。
该切换已于 2026-10-01 经 PR #168 合并并由 Flux 应用;下面原有 Go/Authelia 说明保留为回退路径资料。
```text
Gitea → Hydra → OIDC Login/Consent → Authelia → Samba AD
← OIDC ← 经验证的上游身份 ← OIDC callback
Gitea → Hydra → iam-login(Tailscale 开发实例)→ Samba AD + WebAuthn
```
## 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。
+18 -2
View File
@@ -1,6 +1,20 @@
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:
@@ -12,8 +26,10 @@ urls:
self:
issuer: https://hydra.ad.ddupan.top
public: https://hydra.ad.ddupan.top
login: https://hydra-login.ad.ddupan.top/login
consent: https://hydra-login.ad.ddupan.top/consent
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
ttl:
access_token: 15m
id_token: 15m
@@ -0,0 +1,16 @@
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,6 +8,7 @@ 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
-5
View File
@@ -6,11 +6,6 @@ homelab_dns:
# Samba remains authoritative for the AD zone. Only these explicitly listed
# RRsets are reconciled; Samba-generated AD/Kerberos records are untouched.
records:
- { zone: ad.ddupan.top, name: incus, type: A, values: [192.168.10.127] }
# Static, outside the NEC IX pool (.128-.250); infrastructure/incus/terraform reads
# these same values into each container's cloud-init network-config.
- { zone: ad.ddupan.top, name: ayatori-dev, type: A, values: [192.168.10.11] }
- { zone: ad.ddupan.top, name: ayatori-prod, type: A, values: [192.168.10.12] }
- { zone: ad.ddupan.top, name: bao, type: A, values: [192.168.10.8] }
- { zone: ad.ddupan.top, name: pve1, type: A, values: [192.168.10.4] }
- { zone: ad.ddupan.top, name: pve2, type: A, values: [192.168.10.7] }
-147
View File
@@ -1,147 +0,0 @@
# laptop Incus
Ansible 管理 Ubuntu 24.04 amd64 上的 Zabbly stable APT 源、公钥、固定包版本和
Incus 本地服务。当前版本为 `7.5.1`,完整 Debian 版本见
`ansible/group_vars/incus_hosts.yml`。使用系统包,不使用 snap。
```bash
ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus ansible-playbook -i infrastructure/incus/ansible/inventory/hosts.yml infrastructure/incus/ansible/site.yml --syntax-check
ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus ansible-playbook -i infrastructure/incus/ansible/inventory/hosts.yml infrastructure/incus/ansible/site.yml --check --diff
ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus ansible-playbook -i infrastructure/incus/ansible/inventory/hosts.yml infrastructure/incus/ansible/site.yml
```
执行者需有免密 sudo。首次机器没有包索引时,check 模式不能验证待安装包的可用性;
实际执行会刷新该源并校验包签名。重跑应 `changed=0`。
## 管理边界
- `site.yml` 管安装;`access.yml` 管 Web UI/OIDC 入口。不执行 `incus admin init`,不创建存储池、profile、实例或受管网络。
- 保留 libvirt VM、k3s、现有 bridge、路由和防火墙配置;不引入 OVN/SDN/LB。
- HTTPS listener 为 `192.168.10.127:8443`,域名入口经现有 Envoy;不新增 `incus-admin` 成员。本地 `sudo incus` 保留为恢复入口。
- etcd 和共享 PostgreSQL 由各自目录管理;此目录不迁移它们。
- 不自动删除旧实例或存储;历史 `data/incus` 已由维护者另行授权删除,非本 playbook 行为。
## 版本与公钥维护
公钥来自 Zabbly,主指纹为 `4EFC590696CB15B87C73A3AD82CC8797C838DCFD`,
与[上游安装说明](https://github.com/zabbly/incus)核对后随配置保存。
更新密钥时先核对上游指纹。版本由 APT preferences 固定;升级需修改变量,
审阅 `--check --diff` 后执行,不自动降级。
上游 stable 源未承诺永久保留旧构建,长期离线重建需另行保存包及依赖。
## 验证与故障入口
```bash
sudo incus version
sudo incus list local:
sudo incus storage list local:
sudo incus network list local:
systemctl status incus.service incus.socket
sudo journalctl -u incus.service -n 80 --no-pager
```
网络列表可能展示宿主已有的非受管接口,不代表 Incus 创建了网络。
安装验收不等于实例运行验收;存储、实例备份恢复方案留待资源初始化时确定。
共享知识入口:`homelab-wiki/services/incus.md`(随本次变更同步)。
## Web UI 与 Authelia
入口:`https://incus.ad.ddupan.top`,选择 **Login with SSO**,使用 `panxiao81`
并完成 MFA。Authelia 专属 policy 默认 deny,仅此账号可取得 Incus token。
该客户端登录者具有 Incus 完整管理权限,不根据当前 AD 组放权。
CLI 可使用 `incus remote add laptop https://incus.ad.ddupan.top --auth-type=oidc`,
按设备码流程在浏览器中完成同一登录;token 由客户端保存在本机,不写入 Git。
声明分工:
- `apps/authelia/clients/incus.yaml`:单用户 MFA 准入、public client、PKCE S256、
签名 access token、唯一 audience、浏览器回调和 device/refresh grants。
- `ansible/group_vars/incus_hosts.yml`:Incus OIDC issuer/client/audience/scopes 和 listener。
- `ansible/templates/gateway.yaml.j2`:独立 namespace、Service/EndpointSlice、HTTPRoute、
BackendTLSPolicy。入口复用现有通配符证书,后端以 Incus 的公共 server.crt 验证
`laptop` SAN,不跳过 TLS 验证。公共证书由 playbook 读取,不复制私钥。
- `infrastructure/dns/records.yml`:唯一 DNS 声明;`ansible/dns.yml` 只协调 Incus 与两个 Ayatori 容器的记录。
入口资源由此 Ansible playbook 管理,尚未交由 Flux。共享 Gateway 的后端 TLS
兼容配置由 Flux 管理:EnvoyProxy `eg` 允许 TLS 1.2–1.3,以连接要求 TLS 1.3
的 Incus;见 [PR #164](https://git.ddupan.top/panxiao81/homelab-infra/pulls/164)。
此补丁不升级网关、不改变前端证书或其他路由。
Authelia 当前不是 Flux 受管 release。协调脚本先读取 live Helm values,
仅合并 Incus client/policy,固定 chart 0.11.6 渲染后部署;保留 Hydra/Backstage 等
现有配置和秘密,禁止以滞后的 `apps/authelia/values.yaml` 全量覆盖线上 release。
临时 values 存在 0700 目录、0600 文件中并自动清理,部署前检查 release revision,
避免覆盖准备期间的并发 Helm 变更;仍应避免同时升级该 release。
```bash
# 先 site.yml 安装 UI,再接入;均先 --check --diff 再移除这两个参数执行。
ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus ansible-playbook -i infrastructure/incus/ansible/inventory/hosts.yml infrastructure/incus/ansible/access.yml --check --diff
# DNS 使用既有 Samba inventory/collection,仅修改 Incus 的 RRset。
cd infrastructure/samba-ad/ansible
ANSIBLE_CONFIG=ansible.cfg ANSIBLE_COLLECTIONS_PATH=collections ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus-dns ansible-playbook -i inventory/hosts.yml ../../incus/ansible/dns.yml --check --diff
```
执行接入需要本机 Python3/PyYAML、Helm、kubectl 及现有集群管理凭据;DNS 另需
既有 Samba Ansible vault 与 SSH 权限。首次入口 namespace 不存在时 check 使用
客户端 dry-run;存在后使用 kubectl diff。后续实际重跑要求 `changed=0`。
### 故障与迁移
- Web 入口和 OIDC 依赖 k3s/Envoy/Authelia;它们不可用时使用宿主 `sudo incus`,
Incus daemon 与实例生命周期不依赖网页登录。
- `8443` 直连仍由 Incus 原生认证保护,但只注册域名 443 的 OIDC callback,
浏览器登录应从正式域名进入。网关不透传客户端 TLS 证书;远程主要使用 OIDC。
- 更换宿主 Incus 证书后重跑 `access.yml`,同步网关公共信任证书。
- 未来切换 IdM:更新 issuer/client/audience/scopes 和新 provider 的准入策略,
验证浏览器及 CLI 后再停用 Authelia client;本次不引入永久组模型。
- 移除用户准入不会立即撤销已签发的 JWT;紧急撤权需同时处理现有 token/会话。
- 回退入口时先撤 HTTPRoute,再关闭 `core.https_address`;保留本地管理和现有实例数据。
验证包括 DNS、TLS、route conditions、匿名 API 拒绝、PKCE/回调和设备码授权发起。
2026-09-25 维护者确认 `panxiao81` 完成 MFA 并成功返回 Incus 控制台。
安装、接入、DNS playbook 重跑均 `changed=0`;后续基础容器验收见下节。
未验证 Web 实例控制台或 Ayatori 应用数据路径。
## Ayatori 基础容器
`terraform/` 只管理 `ayatori-dev`、`ayatori-prod` 两个 Ubuntu 24.04 非特权 LXC,
以及专属 ZFS 存储池 `ayatori`(`data/incus-ayatori`)。每个容器上限 2 CPU、
2 GiB 内存、20 GiB rootfs,禁用容器 swap,自动启动。没有安装 Ayatori、k0s、
kube-apiserver、controller 或数据库;准备 Prod 空容器不代表生产服务已上线。
网络接入既有 `br0`,固定 MAC 分别为 `02:16:3e:aa:00:01`、`02:16:3e:aa:00:02`。
Terraform 通过 `cloud-init.network-config` 配置静态地址:Dev `192.168.10.11`、
Prod `192.168.10.12`(`ayatori-{dev,prod}.ad.ddupan.top`)。地址只在
`infrastructure/dns/records.yml` 声明一次,Terraform 与 AD DNS 均从此读取;须位于
NEC IX DHCP 池(`.128–.250`)之外。⚠ 镜像模板只在创建/复制时渲染 cloud-init seed,
对已有实例改地址不会生效(重启、`cloud-init clean` 都不行),只能重建实例。
SSH 全部由 cloud-init 完成:安装 openssh-server,创建 `panxiao81`(`ansible/files/panxiao81.pub`
公钥、免密 sudo),装包后再写 `/etc/ssh/sshd_config.d/60-homelab.conf` 关闭密码与 root 登录。
不使用 `ssh_pwauth`:它在装包前写出残缺的 `sshd_config`,包自带的 `UsePAM yes` 落不下来,
锁定密码的账号会被拒(2026-09-25 曾发生)。宿主也可使用 `incus exec local:ayatori-dev -- bash`。
provider 通过本地 Unix socket 操作,执行账号须有 socket 权限。
```bash
terraform -chdir=infrastructure/incus/terraform init
terraform -chdir=infrastructure/incus/terraform plan -out=containers.tfplan
terraform -chdir=infrastructure/incus/terraform apply -parallelism=1 containers.tfplan
terraform -chdir=infrastructure/incus/terraform output containers
terraform -chdir=infrastructure/incus/terraform plan -detailed-exitcode
```
provider 固定 1.2.0 并保留 lockfile。镜像跟随 `images:ubuntu/24.04/cloud` 最新构建,
只在创建时使用(`ignore_changes`);不固定指纹,因为上游会下架旧构建,2026-10-01
重建时固定指纹已无法获取。
cloud-init 用户设置主要在首次启动执行,修改声明不能替代后续用户/密钥轮换流程。
当前 state 位于 `terraform/terraform.tfstate`(Git 忽略),是本地 backend;
现有 Bao 会话无法读取远端 tfstate 凭据,因此尚未启用远端 backend。
后续迁移须使用 `terraform init -migrate-state` 保留资源归属,不创建第二份独立 state。
实例和池启用 `prevent_destroy`;删除需显式审阅,不能通过移走整个资源块绕过保护。
当前为空系统,丢失后可重建;承载持久数据前须补离机备份与恢复验收。
首次从同一远端并行创建实例时,Incus 7.5.1 曾出现 simplestreams 缓存目录
`mkdir ... file exists` 竞争;按上面的串行 apply 执行。失败后先重新 plan,
保留已成功创建的实例,不清理或销毁其资源。
-49
View File
@@ -1,49 +0,0 @@
---
- name: 接入 Incus Web UI 与 Authelia
hosts: incus_hosts
become: true
gather_facts: false
tasks:
- name: 增量协调 Authelia 的 Incus 客户端
become: false
ansible.builtin.command:
argv: "{{ ['python3', playbook_dir ~ '/../scripts/reconcile_authelia.py', '--desired', playbook_dir ~ '/../../../apps/authelia/clients/incus.yaml'] + (['--check'] if ansible_check_mode else []) }}"
register: incus_authelia
changed_when: "'changed=true' in incus_authelia.stdout"
check_mode: false
- name: 查询当前 Incus 服务配置
ansible.builtin.command: incus query /1.0
register: incus_server
changed_when: false
check_mode: false
no_log: true
- name: 协调 OIDC 与 LAN HTTPS listener
ansible.builtin.command:
argv: [incus, config, set, "{{ item.key }}={{ item.value }}"]
loop: "{{ incus_server_config | dict2items }}"
when: (incus_server.stdout | from_json).config.get(item.key, '') != item.value
changed_when: true
- name: 读取 Incus 公共证书用于网关后端验证
ansible.builtin.slurp:
src: /var/lib/incus/server.crt
register: incus_backend_certificate
- name: 检查入口 namespace 是否已存在
become: false
ansible.builtin.command: kubectl get namespace incus --ignore-not-found -o name
register: incus_namespace
changed_when: false
check_mode: false
- name: 渲染并预览或应用专属入口资源
become: false
ansible.builtin.command:
argv: "{{ (['kubectl', 'apply', '--dry-run=client', '-f', '-'] if incus_namespace.stdout == '' else ['kubectl', 'diff', '-f', '-']) if ansible_check_mode else ['kubectl', 'apply', '-f', '-'] }}"
stdin: "{{ lookup('template', 'gateway.yaml.j2') }}"
register: incus_gateway
check_mode: false
changed_when: "(incus_gateway.rc == 1 or incus_namespace.stdout == '') if ansible_check_mode else ('created' in incus_gateway.stdout or 'configured' in incus_gateway.stdout)"
failed_when: "incus_gateway.rc not in ([0, 1] if ansible_check_mode else [0])"
-29
View File
@@ -1,29 +0,0 @@
---
- name: 仅协调 Incus 与 Ayatori 容器的 AD DNS 记录
hosts: samba_dc
become: true
gather_facts: false
vars:
incus_dns_names: [incus, ayatori-dev, ayatori-prod]
vars_files:
- ../../dns/records.yml
- ../../samba-ad/ansible/group_vars/all/vars.yml
tasks:
- name: 从共享清单选择 Incus 相关 RRset
ansible.builtin.set_fact:
incus_dns_records: "{{ homelab_dns.samba.records | selectattr('name', 'in', incus_dns_names) | selectattr('zone', 'equalto', 'ad.ddupan.top') | list }}"
- name: 确认每个名称恰有一个受管 A 记录
ansible.builtin.assert:
that:
- incus_dns_records | length == incus_dns_names | length
- incus_dns_records | map(attribute='type') | unique == ['A']
- name: 协调 A 记录
ddupan.homelab.samba_dns_record:
server: "{{ samba_ad_dc_ip }}"
zone: "{{ item.zone }}"
name: "{{ item.name }}"
type: "{{ item.type }}"
values: "{{ item['values'] }}"
state: present
exact: true
loop: "{{ incus_dns_records }}"
@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOLvzIxZhVRd9wEFWR/uCOx7b4HQEdPDiZd8LCN7Hics panxiao81@laptop
@@ -1,41 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQGNBGTlYcIBDACYQoVXVyQ6Y3Of14GwEaiv/RstQ8jWnH441OtvDbD/VVT8yF0P
pUfypWjQS8aq0g32Qgb9H9+b8UAAKojA2W0szjJFlmmSq19YDMMmNC4AnfeZlKYM
61Zonna7fPaXmlsTlSiUeo/PGvmAXrkFURC9S8FbhZdWEcUpf9vcKAoEzV8qGA4J
xbKlj8EOjSkdq3OQ1hHjP8gynbbzMhZQwjbnWqoiPj35ed9EMn+0QcX+GmynGq6T
hBXdRdeQjZC6rmXzNF2opCyxqx3BJ0C7hUtpHegmeoH34wnJHCqGYkEKFAjlRLoW
tOzHY9J7OFvB6U7ENtnquj7lg2VQK+hti3uiHW+oide06QgjVw2irucCblQzphgo
iX5QJs7tgFFDsA9Ee0DZP6cu83hNFdDcXEZBc9MT5Iu0Ijvj7Oeym3DJpkCuIWgk
SeP56sp7333zrg73Ua7YZsZHRayAe/4YdNUua+90P4GD12TpTtJa4iRWRd7bis6m
tSkKRj7kxyTsxpEAEQEAAbQmWmFiYmx5IEtlcm5lbCBCdWlsZHMgPGluZm9AemFi
Ymx5LmNvbT6JAdQEEwEKAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQRO
/FkGlssVuHxzo62CzIeXyDjc/QUCaKN/OgUJDSQe+AAKCRCCzIeXyDjc/dSYC/47
EJPEuRtZCdRFsYVeecQ9CFYcD01DQdS1pfYaK7mgW582aluc1TWAE4J6P8FcCweC
tWLC1bY7613ZGCVmoRTHWEOaKYG+NGaR5YRXVkZXcLCmV1KbJ/tkWQD4qIkvuVah
Q5J42itFXZ0kz6bs6Wkd6+C2RHL6VtvtVXfVlQtdBni72TgseM01U8WHW6tnweJf
XKDXAws8UEc6wQeD4Ik0OCTWbrwQMyDTBn+NTx4Apc2t5QGFi5ehmPbnq0jhF1FB
b1gaEmFZLXz/zkDFkj52k/qEPj8099+0sAxld8oQPKWacmGzhBjYzKKHuEQO4Z8t
XVlgzCnNlNmWCnkm4AKgTzmKAIgMoA6tUfWBzDy20VZ2J+8dcL52vIJJa30knnLN
g3qmqtFTRFQBMl9hC11JOI7qvPmQlt38m6YBEOHBq4QUsuqqVJkQPAtJeROcDbNF
aqobwhP5bSsIDMYygTn50LBZtl9LGmLRY4YyZAiVRviXNh5r6lEqDBtjsdnI/Z65
AY0EZOVhwgEMAMIztf6WlRsweysb0tzktYE5E/GxIK1lwcD10Jzq3ovJJPa2Tg2t
J6ZBmMQfwU4OYO8lJxlgm7t6MYh41ZZaRhySCtbJiAXqK08LP9Gc1iWLRvKuMzli
NFSiFDFGT1D6kwucVfL/THxvZlQ559kK+LB4iXEKXz37r+MCX1K9uiv0wn63Vm0K
gD3HDgfXWYJcNyXXfJBe3/T5AhuSBOQcpa7Ow5n8zJ+OYg3FFKWHDBTSSZHpbJFr
ArMIGARz5/f+EVj9XGY4W/+ZJlxNh8FzrTLeRArmCWqKLPRG/KF36dTY7MDpOzlw
vu7frv+cgiXHZ2NfPrkH8oOl4L+ufze5KBGcN0QwFDcuwCkv/7Ft9Ta7gVaIBsK7
12oHInUJ6EkBovxpuaLlHlP8IfmZLZbbHzR2gR0e6IhLtrzd7urB+gXUtp6+wCL+
kWD14TTJhSQ+SFU8ajvUah7/1m2bxdjZNp9pzOPGkr/jEjCM0CpZiCY62SeIJqVc
4/ID9NYLAGmSIwARAQABiQG8BBgBCgAmAhsMFiEETvxZBpbLFbh8c6OtgsyHl8g4
3P0FAmijf0cFCQ0kHwUACgkQgsyHl8g43P00BgwAhdg/Vh0zJOCvee9hyf+Wd68F
oWz5LUlNGrCsbyNrk27RCR6hM4Td25kLCU03C/aq8a/qiWWgUHho6LpA1t9OsBde
59i1wR5Ca6XZAkjBIftlEzuHhg67Dm4mTVSRdTNT/WIhyv5T7Y/ba+TOq7VW8M3D
fqwuJSKQ//MUzOcE0pjfH1WI9uFJH+arQBGXD+425lPA/6symWpHm9PHmHwIcd6N
Bdc7fjNVRFUjat/auXfcvrDn36PP9w84seBtyeLS20pQtpnL06al6GKOY3rrWPMx
4h7fpyURuhQH6nygS/Cxkpf38Zo+EIMajf+19vLhTr+x8HyMfe42GVpEVP5WL43f
UcSxG6+cdTm7Yr+PICs4idy62E2y1AGOS5ePHsX4FOAsUquZD5dqhqV/A7Mb+ypk
fIqxG8sZAXYIaMrYcDA4ZS7CbuKcSmy0nUws+o7gwSeYLyApBLea/F/ywctODhxh
ZBqN6R8SuRc5NWWPDcSdr1myXY2YpB0AVEV8zGtF
=tHYp
-----END PGP PUBLIC KEY BLOCK-----
@@ -1,16 +0,0 @@
---
# 显式升级版本,避免例行重跑意外升级虚拟化服务。
incus_package_version: '1:7.5.1-ubuntu24.04-202609250207'
incus_packages:
- incus
- incus-base
- incus-client
- incus-ui-canonical
# 先完成 IdP 准入限制,再配置 OIDC,最后开放 listener。
incus_server_config:
oidc.issuer: https://auth.ddupan.top
oidc.client.id: incus
oidc.audience: https://incus.ad.ddupan.top
oidc.scopes: openid,offline_access
core.https_address: 192.168.10.127:8443
@@ -1,7 +0,0 @@
all:
children:
incus_hosts:
hosts:
laptop:
ansible_connection: local
ansible_python_interpreter: /usr/bin/python3
-114
View File
@@ -1,114 +0,0 @@
---
- name: 安装 laptop 的 Incus 基础服务
hosts: incus_hosts
become: true
gather_facts: true
tasks:
- name: 限定已验证的平台
ansible.builtin.assert:
that:
- ansible_facts['distribution'] == 'Ubuntu'
- ansible_facts['distribution_release'] == 'noble'
- ansible_facts['architecture'] == 'x86_64'
fail_msg: 当前包版本只针对 Ubuntu 24.04 amd64 验证。
- name: 创建 APT 公钥目录
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
owner: root
group: root
mode: '0755'
- name: 安装已核对指纹的 Zabbly 公钥
ansible.builtin.copy:
src: zabbly.asc
dest: /etc/apt/keyrings/zabbly.asc
owner: root
group: root
mode: '0644'
register: incus_key
- name: 声明 Zabbly stable 软件源
ansible.builtin.copy:
content: |
Enabled: yes
Types: deb
URIs: https://pkgs.zabbly.com/incus/stable
Suites: noble
Components: main
Architectures: amd64
Signed-By: /etc/apt/keyrings/zabbly.asc
dest: /etc/apt/sources.list.d/zabbly-incus-stable.sources
owner: root
group: root
mode: '0644'
register: incus_source
# 防止系统自动更新绕开版本声明;显式改版本后重跑才升级。
- name: 固定 Incus 包版本
ansible.builtin.copy:
content: |
Package: {{ incus_packages | join(' ') }}
Pin: version {{ incus_package_version }}
Pin-Priority: 1000
dest: /etc/apt/preferences.d/incus
owner: root
group: root
mode: '0644'
# 只刷新本组件源,避免其他服务仓库故障阻塞安装。
- name: 刷新 Incus 包索引
ansible.builtin.command:
argv:
- apt-get
- update
- -o
- Dir::Etc::sourcelist=sources.list.d/zabbly-incus-stable.sources
- -o
- Dir::Etc::sourceparts=-
- -o
- APT::Get::List-Cleanup=0
- -o
- APT::Update::Error-Mode=any
changed_when: false
register: incus_refresh
retries: 3
delay: 5
until: incus_refresh.rc == 0
when: not ansible_check_mode
- name: 安装固定版本且禁止移除既有包
ansible.builtin.apt:
name: "{{ incus_packages | map('regex_replace', '$', '=' ~ incus_package_version) | list }}"
state: present
install_recommends: false
fail_on_autoremove: true
lock_timeout: 120
environment:
# 不让 needrestart 顺带重启 k3s、libvirt 等无关服务。
NEEDRESTART_MODE: l
register: incus_install
retries: 3
delay: 5
until: incus_install is succeeded
# check 模式不会创建新源,APT 无法解析只在新源存在的版本。
when: not (ansible_check_mode and (incus_source.changed or incus_key.changed))
- name: 提示首次 check 的包验证边界
ansible.builtin.debug:
msg: 软件源或公钥尚待写入,本次仅预览仓库配置;安装后须重跑 check 和幂等验证。
when: ansible_check_mode and (incus_source.changed or incus_key.changed)
- name: 启用 Incus 本地 socket
ansible.builtin.systemd_service:
name: incus.socket
enabled: true
state: started
when: not ansible_check_mode
- name: 启动 Incus 服务
ansible.builtin.systemd_service:
name: incus.service
state: started
when: not ansible_check_mode
@@ -1,109 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: incus
---
apiVersion: v1
kind: Service
metadata:
name: incus
namespace: incus
spec:
ports:
- name: https
port: 8443
targetPort: 8443
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: incus-laptop
namespace: incus
labels:
kubernetes.io/service-name: incus
endpointslice.kubernetes.io/managed-by: homelab-ansible
addressType: IPv4
ports:
- name: https
protocol: TCP
port: 8443
endpoints:
- addresses: [192.168.10.127]
conditions:
ready: true
---
apiVersion: v1
kind: ConfigMap
metadata:
name: incus-backend-ca
namespace: incus
data:
ca.crt: |
{{ incus_backend_certificate.content | b64decode | indent(4, true) }}
---
apiVersion: gateway.networking.k8s.io/v1alpha3
kind: BackendTLSPolicy
metadata:
name: incus
namespace: incus
spec:
targetRefs:
- group: ''
kind: Service
name: incus
validation:
hostname: laptop
caCertificateRefs:
- group: ''
kind: ConfigMap
name: incus-backend-ca
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: incus
namespace: incus
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: eg
namespace: envoy-gateway-system
sectionName: https
hostnames: [incus.ad.ddupan.top]
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- group: ''
kind: Service
name: incus
port: 8443
weight: 1
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: incus-http
namespace: incus
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: eg
namespace: envoy-gateway-system
sectionName: http
hostnames: [incus.ad.ddupan.top]
rules:
- matches:
- path:
type: PathPrefix
value: /
filters:
- type: RequestRedirect
requestRedirect:
scheme: https
port: 443
statusCode: 301
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""只协调 Incus client/policy,保留线上其他客户端、秘密和 claims 配置。"""
import argparse
import copy
import json
import subprocess
import tempfile
from pathlib import Path
import yaml
def merge(values, desired):
result = copy.deepcopy(values)
oidc = result['configMap']['identity_providers']['oidc']
clients = oidc.setdefault('clients', [])
for client in desired['clients']:
matches = [i for i, old in enumerate(clients) if old['client_id'] == client['client_id']]
if len(matches) > 1:
raise ValueError('发现重复 client_id,拒绝自动覆盖')
if matches:
clients[matches[0]] = copy.deepcopy(client)
else:
clients.append(copy.deepcopy(client))
oidc.setdefault('authorization_policies', {}).update(desired['authorization_policies'])
return result
def run(args):
# Helm 输出可能含秘密:只在内存处理,错误不回显正文。
result = subprocess.run(args, capture_output=True, text=True)
if result.returncode:
raise RuntimeError(f'{args[0]} {args[1]} 失败(退出码 {result.returncode});未输出可能含秘密的响应')
return result.stdout
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--desired', required=True)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
release = json.loads(run(['helm', 'list', '-n', 'authelia', '-o', 'json']))
current = next(x for x in release if x['name'] == 'authelia')
if current['chart'] != 'authelia-0.11.6' or current['status'] != 'deployed':
raise RuntimeError('仅验证 authelia-0.11.6 且 release 必须 deployed;请先审阅版本变化')
before = json.loads(run(['helm', 'get', 'values', 'authelia', '-n', 'authelia', '-o', 'json']))
desired = yaml.safe_load(Path(args.desired).read_text())
after = merge(before, desired)
if before == after:
print('changed=false: Incus client/policy 已收敛')
return
# 目录 0700,文件 0600,退出即清理;既有秘密不写入仓库或日志。
with tempfile.TemporaryDirectory(prefix='incus-authelia-') as directory:
root = Path(directory)
path = root / 'values.json'
path.touch(mode=0o600)
path.write_text(json.dumps(after))
run(['helm', 'pull', 'authelia/authelia', '--version', '0.11.6', '--destination', directory])
chart = str(root / 'authelia-0.11.6.tgz')
run(['helm', 'template', 'authelia', chart, '-n', 'authelia', '-f', str(path)])
if args.check:
print('changed=true: 将只更新 Incus client/policy;固定 chart 渲染通过')
return
latest = next(x for x in json.loads(run(['helm', 'list', '-n', 'authelia', '-o', 'json'])) if x['name'] == 'authelia')
if latest['revision'] != current['revision'] or latest['status'] != 'deployed':
raise RuntimeError('Authelia release 在准备期间变化,请重试,避免覆盖并发修改')
run(['helm', 'upgrade', 'authelia', chart, '-n', 'authelia', '--reuse-values',
'-f', str(path), '--atomic', '--timeout', '5m', '--history-max', '10'])
print('changed=true: Incus client/policy 已部署,其他值保留')
if __name__ == '__main__':
main()
@@ -1,32 +0,0 @@
import unittest
from reconcile_authelia import merge
class MergeTests(unittest.TestCase):
def test_preserves_unrelated_state_and_converges(self):
before = {'configMap': {'identity_providers': {'oidc': {
'clients': [{'client_id': 'hydra', 'client_secret': 'test-only'}],
'claims_policies': {'existing': {'id_token': ['email']}},
'authorization_policies': {'existing': {'default_policy': 'two_factor'}},
}}}}
desired = {'clients': [{'client_id': 'incus', 'public': True}],
'authorization_policies': {'incus_admin': {'default_policy': 'deny'}}}
after = merge(before, desired)
oidc = after['configMap']['identity_providers']['oidc']
self.assertEqual(oidc['clients'][0], before['configMap']['identity_providers']['oidc']['clients'][0])
self.assertIn('existing', oidc['claims_policies'])
self.assertIn('existing', oidc['authorization_policies'])
self.assertEqual(len(before['configMap']['identity_providers']['oidc']['clients']), 1)
self.assertEqual(merge(after, desired), after)
desired['clients'][0]['public'] = False
self.assertFalse(merge(after, desired)['configMap']['identity_providers']['oidc']['clients'][1]['public'])
def test_rejects_duplicate_identity(self):
before = {'configMap': {'identity_providers': {'oidc': {'clients': [{'client_id': 'incus'}, {'client_id': 'incus'}]}}}}
with self.assertRaises(ValueError):
merge(before, {'clients': [{'client_id': 'incus'}], 'authorization_policies': {}})
if __name__ == '__main__':
unittest.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/lxc/incus" {
version = "1.2.0"
constraints = "1.2.0"
hashes = [
"h1:9G5MaYQY9mKIpO341aG6f0Bt46DFD/bssrtNB+r861U=",
"zh:3be797962ed009eedcd6badb2cce1f707345032d48814f050f2103f00eba0177",
"zh:53fd1bf8685ef140372ab3282267fba38219dd2351efa723888a80aa41aa9367",
"zh:59a9f9bd027380346b8ebc09da7c98c7ef5aed0783d33aa3d3c4f51ca2fafd0f",
"zh:65ca786dd942c068b5953e7bd92c6813b1aeeb8a381da7cc96c45a846f3a5965",
"zh:6dd3262124c41f8a416cbd1c289dbf1ab9bf9116fc3f3be4714971272811926a",
"zh:8cce2da3db95f1088e02bf7ee68d9cacd55bf7b12dd0dd1e61165d5e5432e38b",
"zh:a637c5299aeed3984a0b39b45f4bac026d701a6cc203dff05a82a4f43027f37c",
"zh:a9017e39f3e8d2dbbfbbc1c6d663d22465b783d1c0e9a6e3ab324b497d0b14ed",
"zh:c3954bf1f4796a529dd76f5617f63f570dee76e9b7c17b12416e3afb059314ba",
"zh:dd5a081a9778794d89fa54ccddf4287550e9522d6e69b5e777859857809c9d20",
"zh:fa95ea158d045386be416cf9146c74a5e4b3fe5fd85850e1b8f6d2977c45bc9b",
"zh:fbe8006406da07ba0c40282050cd34343a584d028890c8601592414044abab7e",
]
}
-147
View File
@@ -1,147 +0,0 @@
terraform {
required_version = ">= 1.10.0"
required_providers {
incus = {
source = "lxc/incus"
version = "1.2.0"
}
}
}
provider "incus" {
default_remote = "local"
remote {
name = "local"
address = "unix://"
}
remote {
name = "images"
address = "https://images.linuxcontainers.org"
protocol = "simplestreams"
public = true
}
}
# 专用子 dataset,不接管整个宿主 data 池。
resource "incus_storage_pool" "ayatori" {
name = "ayatori"
driver = "zfs"
config = {
source = "data/incus-ayatori"
}
lifecycle {
prevent_destroy = true
}
}
# 地址只在 records.yml 声明一次,AD DNS(ansible/dns.yml)与此处同源读取;
# 须位于 NEC IX DHCP 池(.128–.250)之外,池由路由器手工维护,无法声明 reservation。
locals {
ayatori_ipv4 = {
for r in yamldecode(file("${path.module}/../../dns/records.yml")).homelab_dns.samba.records :
trimprefix(r.name, "ayatori-") => r.values[0]
if r.zone == "ad.ddupan.top" && startswith(r.name, "ayatori-")
}
}
# 镜像不含 openssh-server。不设 ssh_pwauth:它会在装包前写出残缺的 sshd_config,
# 使包自带的默认配置(UsePAM yes、Include sshd_config.d)无法落地,锁定密码的账号随即被拒。
# 改为装包后(defer)再写 drop-in,只覆盖需要收紧的项。
locals {
ayatori_cloud_config = {
manage_etc_hosts = true
disable_root = true
users = [{
name = "panxiao81"
groups = ["sudo"]
shell = "/bin/bash"
sudo = ["ALL=(ALL) NOPASSWD:ALL"]
lock_passwd = true
ssh_authorized_keys = [trimspace(file("${path.module}/../ansible/files/panxiao81.pub"))]
}]
# WAN 随机掉线,装包须重试。
apt = { conf = "Acquire::Retries \"5\";" }
package_update = true
packages = ["openssh-server"]
write_files = [{
path = "/etc/ssh/sshd_config.d/60-homelab.conf"
defer = true
content = <<-EOT
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
EOT
}]
}
}
resource "incus_instance" "ayatori" {
for_each = toset(["dev", "prod"])
name = "ayatori-${each.key}"
description = "Ayatori ${each.key} 基础容器;应用由独立部署流程管理"
# 跟随 24.04 cloud 最新构建:同一发行版的构建只差安全更新,固定指纹会随上游下架而无法重建。
image = "images:ubuntu/24.04/cloud"
type = "container"
profiles = []
running = true
config = {
"boot.autostart" = "true"
"security.privileged" = "false"
"security.nesting" = "false"
"limits.cpu" = "2"
"limits.memory" = "2GiB"
"limits.memory.swap" = "false"
# ⚠ 镜像模板只在 create/copy 时渲染 cloud-init seed(when: [create, copy]),
# 对已有实例修改此键不会生效,重启或 cloud-init clean 也不会;改地址须重建实例。
# 网关与 resolver 沿用 LAN DHCP 下发值:.1 网关;Blocky .127 优先、路由器 .1 兜底。
"cloud-init.network-config" = yamlencode({
version = 2
ethernets = {
eth0 = {
addresses = ["${local.ayatori_ipv4[each.key]}/24"]
routes = [{ to = "default", via = "192.168.10.1" }]
nameservers = { addresses = ["192.168.10.127", "192.168.10.1"] }
}
}
})
"cloud-init.user-data" = "#cloud-config\n${yamlencode(merge(local.ayatori_cloud_config, {
hostname = "ayatori-${each.key}"
}))}"
}
device {
name = "root"
type = "disk"
properties = {
path = "/"
pool = incus_storage_pool.ayatori.name
size = "20GiB"
}
}
device {
name = "eth0"
type = "nic"
properties = {
name = "eth0"
nictype = "bridged"
parent = "br0"
hwaddr = each.key == "dev" ? "02:16:3e:aa:00:01" : "02:16:3e:aa:00:02"
}
}
wait_for {
type = "ipv4"
nic = "eth0"
}
lifecycle {
# 镜像只在创建时使用;provider 按字符串比较,改写它不应触发重建现有实例。
ignore_changes = [image]
prevent_destroy = true
}
}
output "containers" {
value = { for env, instance in incus_instance.ayatori : env => {
name = instance.name
ipv4 = instance.ipv4_address
mac = instance.mac_address
} }
}
@@ -74,6 +74,6 @@ sudo journalctl -u homelab-data-collect.service --since '2 hours ago'
备份年龄不能证明恢复能力或 WAL 连续性;定期隔离恢复演练仍必要。证书指标读取磁盘文件,
不证明运行进程已重载该证书。dev 检查不验证外部 DNS/TLS 路径、不执行写入。
SQL 级 exporter、异地备份及应用迁移不在本次范围。
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)。
+25 -2
View File
@@ -69,7 +69,10 @@ Ayatori 单独使用 `kv/infra/postgresql/ayatori/{prod,dev}`,仅有 username/
6. `bootstrap-backup.yml` 验证 WAL 与首备成功后启用每日 timer;`proxy.yml` 要求唯一 primary 才发布入口。
7. DNS 由 `infrastructure/dns/records.yml` 与 Samba `provision-dc.yml --tags dns` 管理。
8. `controller.yml` 安装 PG 证书每日续签调度,预检通过才启用。
9. Ayatori 独立控制面就绪后按 [接入说明](ayatori/README.md) 注册;CNPG 迁移另行按应用验收。
9. `monitoring.yml` 激活 SQL 指标:先由 `site.yml` 写入模板,再执行本 play。它只在配置未生效时
向 Patroni 发 HUP、对 pending 成员 `patronictl restart --pending`(primary 原位重启,不 failover,
写入短暂中断)或重启 dev;随后在可写实例创建监控角色与扩展并安装 exporter。不需要 Bao。
10. Ayatori 独立控制面就绪后按 [接入说明](ayatori/README.md) 注册;CNPG 迁移另行按应用验收。
普通 `site.yml` 不代表所有配置已在线激活;尚未实现通用参数变更的 rolling restart。
专用 `renew-certificates.yml` 只处理证书,60 天有效期、剩余不足 14 天续签;逐主机检查服务,
@@ -97,10 +100,30 @@ Ayatori 角色,随后停库清理;不覆盖生产 PGDATA。全站恢复及
现有 VictoriaMetrics/Alertmanager 已采集生产两节点 Patroni 原生指标,规则位于
`platform/observability/metrics/{scrapes,rules}/shared-postgresql.yaml`。覆盖成员不可采集、无可见主库、
多主、replica 未流复制及 PG 进程停止。没有部署另一套监控栈;数据库 SQL 级 exporter 仍后置。dev SQL、备份年龄、证书和续签检查的主机采集已于 2026-09-27 部署,
多主、replica 未流复制及 PG 进程停止。没有部署另一套监控栈。dev SQL、备份年龄、证书和续签检查的主机采集已于 2026-09-27 部署,
新增规则已随 [PR #166](https://git.ddupan.top/panxiao81/homelab-infra/pulls/166) 合并并由 Flux 接管;
阈值、验证边界和排障见 [维护监控](../shared-data-monitoring/README.md)。
### SQL 级指标(2026-10-01 主机端上线)
每个实例在自身主机上运行 [prometheus-community/postgres_exporter](https://github.com/prometheus-community/postgres_exporter)
(`pg_exporter_version`,安装包与官方 `sha256sums.txt` 同源校验):prod 为 laptop/pve1 的 `:9187`,
dev 为 laptop `:9188`,仅监听实例内网地址。exporter 以实例 OS 用户走本地 socket,由
`local all homelab_monitor peer map=monitor` 与 ident 映射登录到 `homelab_monitor`
(仅 `pg_monitor`、无密码、`CONNECTION LIMIT 3`)。这只约束 exporter 自己的查询,**不是隔离边界**:
同一 OS 用户仍可经 peer 以 superuser 登录,exporter 被攻破等同实例属主;systemd
`InaccessiblePaths` 只让它读不到数据目录、私钥与 pgpass。prod/dev 启用 `pg_stat_statements`。
规则 `platform/observability/metrics/rules/shared-postgresql-sql.yaml`(PrometheusRule)以上游
`postgres_mixin` 为底改写:采集/SQL 不通、复制回放落后 > 1 MiB(与 failover 阈值一致)、主库复制槽
保留 > 1 GiB、主库落在 pve1(HDD)超过 15 分钟、WAL 归档失败、XID/MXID 年龄、客户端连接 > 80%、空闲事务 > 10 分钟、事务 > 1 小时、
死锁、频繁请求 checkpoint。目标带 `env` 标签,Alertmanager 把 `env="dev"` 送到独立 dev 频道。
2026-10-01 验证:隔离集成测试覆盖 peer+ident 登录(主/备/dev)、读取 `pg_stat_statements`、
监控角色不可写;线上激活时两个 prod 成员按 pending 重启,时间线未变(未切换);三个端点 `pg_up 1`、
无采集失败,exporter 各约 8 MiB;重跑 `changed=0`;vmagent Pod 可达三个端点。规则 promtool 场景通过。
`monitoring.yml --check` 不会显示 HUP、restart 与建角色(均为 command 任务),只能作参考。
2026-09-25 已验证:
- 真实 prod 主从、dev 初始化;Ayatori 管理账号 verify-full TLS/权限校验;生产稳定入口 SQL 写入。
@@ -19,6 +19,7 @@ pg_profiles:
user: pgprod
port: 5432
api_port: 8008
exporter_port: 9187
dns: pg-prod.ad.ddupan.top
memory_high: 768M
memory_max: 1G
@@ -28,6 +29,7 @@ pg_profiles:
user: pgdev
port: 5433
dns: pg-dev.ad.ddupan.top
exporter_port: 9188
memory_high: 256M
memory_max: 384M
shared_buffers: 32MB
@@ -38,3 +40,8 @@ pg_replication_addresses: ['192.168.10.127/32', '10.60.0.20/32']
pg_repo_host: 10.60.0.21
pg_repo_user: pgbackup
pg_repo_path: /var/lib/homelab-pgbackrest
# SQL 指标:exporter 以实例 OS 用户经本地 socket 连接,peer + ident 映射到只读监控角色,
# 因此无需密码或 Bao;角色仅有 pg_monitor,不复用实例管理账号。
pg_monitor_user: homelab_monitor
# 升级只改版本;安装包与同版本官方 sha256sums.txt 一起下载校验,不写死摘要。
pg_exporter_version: 0.20.1
@@ -0,0 +1,81 @@
---
# SQL 指标:激活监控所需的 HBA/ident 与 pg_stat_statements,创建只读监控角色并安装 exporter。
# 不需要 Bao:模板先由 site.yml 写入;本 play 只做激活与验证。会重启实例(仅在预加载库未生效时),
# 因此不放进 site.yml。重复执行应为 changed=0。
- name: 激活实例监控配置
hosts: pg_hosts
become: true
gather_facts: false
serial: 1
any_errors_fatal: true
tasks:
- name: 逐实例激活
ansible.builtin.include_tasks: tasks/monitoring-activate.yml
loop: "{{ pg_instances }}"
loop_control:
loop_var: pg_instance
- name: 重启 pending 的生产成员以加载 pg_stat_statements
hosts: pg-laptop
become: true
become_user: "{{ pg_profiles.prod.user }}"
gather_facts: false
vars:
pg_patronictl: [/opt/homelab-patroni/bin/patronictl, -c, "{{ pg_config_root }}/prod/patroni.yml"]
tasks:
# JSON 输出会省略空值字段:没有 pending 的成员不带 "Pending restart" 键。
- name: 读取集群成员状态
ansible.builtin.command:
argv: "{{ pg_patronictl + ['list', '-f', 'json'] }}"
register: pg_members
changed_when: false
check_mode: false
# Patroni 在原位重启 primary,不触发 failover;写入会短暂中断,客户端需重试。
- name: 仅重启 pending restart 的成员
ansible.builtin.command:
argv: "{{ pg_patronictl + ['restart', 'pg-prod', '--pending', '--force'] }}"
when: pg_members.stdout | from_json | selectattr('Pending restart', 'defined') | selectattr('Pending restart', 'equalto', '*') | list | length > 0
changed_when: true
- name: 等待两个成员恢复且无 pending
ansible.builtin.command:
argv: "{{ pg_patronictl + ['list', '-f', 'json'] }}"
register: pg_members_after
changed_when: false
when: not ansible_check_mode
retries: 30
delay: 4
until: >-
(pg_members_after.stdout | from_json | length) == 2
and (pg_members_after.stdout | from_json | selectattr('Pending restart', 'defined') | selectattr('Pending restart', 'equalto', '*') | list | length) == 0
and (pg_members_after.stdout | from_json | selectattr('State', 'in', ['running', 'streaming']) | list | length) == 2
- name: 创建监控角色并安装 exporter
hosts: pg_hosts
become: true
gather_facts: false
tasks:
- name: 下载 postgres_exporter 并校验同版本官方摘要
ansible.builtin.get_url:
url: "{{ pg_exporter_base }}/postgres_exporter-{{ pg_exporter_version }}.linux-amd64.tar.gz"
dest: /opt/homelab-postgres-exporter.tar.gz
checksum: "sha256:{{ pg_exporter_base }}/sha256sums.txt"
mode: '0644'
vars:
pg_exporter_base: https://github.com/prometheus-community/postgres_exporter/releases/download/v{{ pg_exporter_version }}
register: pg_exporter_download
retries: 5
delay: 10
until: pg_exporter_download is succeeded
- name: 解压 exporter(保留上游许可证)
ansible.builtin.unarchive:
src: /opt/homelab-postgres-exporter.tar.gz
dest: /opt
remote_src: true
creates: /opt/postgres_exporter-{{ pg_exporter_version }}.linux-amd64/postgres_exporter
# check 模式不下载,首次时没有可解压的文件。
when: not (ansible_check_mode and pg_exporter_download is changed)
- name: 逐实例配置
ansible.builtin.include_tasks: tasks/monitoring-exporter.yml
loop: "{{ pg_instances }}"
loop_control:
loop_var: pg_instance
@@ -71,7 +71,7 @@
owner: root
group: "{{ pg_profile.user }}"
mode: '0640'
loop: "{{ ['patroni.yml', 'pgbackrest.conf'] if pg_instance == 'prod' else ['postgresql.conf', 'pg_hba.conf'] }}"
loop: "{{ ['patroni.yml', 'pgbackrest.conf'] if pg_instance == 'prod' else ['postgresql.conf', 'pg_hba.conf', 'pg_ident.conf'] }}"
no_log: true
- name: 安装实例专用服务(不自动启动或重启)
ansible.builtin.template:
@@ -66,6 +66,8 @@ postgresql:
unix_socket_directories: {{ pg_socket_dir }}
unix_socket_permissions: '0700'
password_encryption: scram-sha-256
# postmaster 级参数:变更后需由 monitoring.yml 执行 Patroni pending restart。
shared_preload_libraries: pg_stat_statements
shared_buffers: {{ pg_profile.shared_buffers }}
work_mem: 4MB
maintenance_work_mem: 64MB
@@ -76,6 +78,7 @@ postgresql:
log_statement: none
log_min_error_statement: panic
pg_hba:
- local all {{ pg_monitor_user }} peer map=monitor
- local all {{ pg_profile.user }} peer
- local replication {{ pg_profile.user }} peer
- local replication replicator scram-sha-256
@@ -88,6 +91,9 @@ postgresql:
{% endfor %}
- host all all 0.0.0.0/0 reject
- host all all ::/0 reject
# 只有实例 OS 用户(exporter 运行身份)可映射为监控角色。
pg_ident:
- monitor {{ pg_profile.user }} {{ pg_monitor_user }}
remove_data_directory_on_rewind_failure: false
remove_data_directory_on_diverged_timelines: false
watchdog:
@@ -1,3 +1,4 @@
local all {{ pg_monitor_user }} peer map=monitor
local all {{ pg_profile.user }} peer
{% for cidr in pg_client_cidrs %}
hostssl all all {{ cidr }} scram-sha-256
@@ -0,0 +1,2 @@
# 只有实例 OS 用户(exporter 运行身份)可映射为监控角色。
monitor {{ pg_profile.user }} {{ pg_monitor_user }}
@@ -3,6 +3,7 @@ port = {{ pg_profile.port }}
unix_socket_directories = '{{ pg_socket_dir }}'
unix_socket_permissions = 0700
hba_file = '{{ pg_config_dir }}/pg_hba.conf'
ident_file = '{{ pg_config_dir }}/pg_ident.conf'
password_encryption = 'scram-sha-256'
shared_buffers = '{{ pg_profile.shared_buffers }}'
max_connections = {{ pg_profile.max_connections }}
@@ -16,3 +17,5 @@ ssl_ca_file = '{{ pg_config_dir }}/ca.crt'
archive_mode = off
log_statement = none
log_min_error_statement = panic
# postmaster 级参数:变更后需由 monitoring.yml 重启 dev 实例。
shared_preload_libraries = 'pg_stat_statements'
@@ -0,0 +1,73 @@
---
- name: 设置现有实例路径
ansible.builtin.set_fact:
pg_profile: "{{ pg_profiles[pg_instance] }}"
pg_config_dir: "{{ pg_config_root }}/{{ pg_instance }}"
pg_socket_dir: "/run/homelab-postgresql-{{ pg_instance }}"
- name: 激活前必须存在运行中的受管服务
ansible.builtin.command:
argv: [systemctl, is-active, "homelab-postgresql-{{ pg_instance }}.service"]
changed_when: false
check_mode: false
# 生产:Patroni 只在自身 reload 时把本地配置写成数据目录里的 postgresql.conf/pg_ident.conf,
# 所以文件视图能说明 Patroni 是否已接收新配置;预加载库是否已生效另看 current_setting。
# 重启前 pg_file_settings 对该行报 "setting could not be applied",属预期,不能按 error 过滤。
# dev:ident_file 是 postmaster 级参数,视图读取的是“当前生效”的那个文件,
# 所以启动时仍指向旧路径就会读不到映射;两列都为真才算已加载。
- name: 读取监控配置的激活状态
ansible.builtin.command:
argv:
- "{{ pg_bin_dir }}/psql"
- -X
- -At
- -h
- "{{ pg_socket_dir }}"
- -p
- "{{ pg_profile.port | string }}"
- -d
- postgres
- -c
- >-
SELECT
EXISTS (SELECT 1 FROM pg_ident_file_mappings WHERE map_name = 'monitor' AND error IS NULL)
AND EXISTS (SELECT 1 FROM pg_file_settings WHERE name = 'shared_preload_libraries'
AND setting ~ 'pg_stat_statements'),
current_setting('shared_preload_libraries') ~ 'pg_stat_statements'
become: true
become_user: "{{ pg_profile.user }}"
changed_when: false
check_mode: false
register: pg_monitor_state
- name: 让 Patroni 接收新的本地配置(reload,不重启)
ansible.builtin.command:
argv: [systemctl, kill, --kill-whom=main, --signal=HUP, homelab-postgresql-prod.service]
when: pg_instance == 'prod' and pg_monitor_state.stdout.split('|')[0] != 't'
changed_when: true
- name: 等待 Patroni 写出新配置
ansible.builtin.command:
argv: "{{ pg_monitor_state.cmd }}"
become: true
become_user: "{{ pg_profile.user }}"
when: pg_instance == 'prod' and pg_monitor_state.stdout.split('|')[0] != 't' and not ansible_check_mode
changed_when: false
register: pg_monitor_reloaded
retries: 10
delay: 3
until: pg_monitor_reloaded.stdout.split('|')[0] == 't'
# dev 没有 Patroni;预加载库与 ident_file 都是 postmaster 级参数,只能重启。
- name: 重启 dev 以加载监控配置
ansible.builtin.systemd_service:
name: homelab-postgresql-dev.service
state: restarted
when: pg_instance == 'dev' and pg_monitor_state.stdout != 't|t'
- name: 检查 dev 重启后监控配置已生效
ansible.builtin.command:
argv: "{{ pg_monitor_state.cmd }}"
become: true
become_user: "{{ pg_profile.user }}"
when: pg_instance == 'dev' and not ansible_check_mode
changed_when: false
register: pg_monitor_dev
retries: 10
delay: 3
until: pg_monitor_dev.rc == 0 and pg_monitor_dev.stdout == 't|t'
@@ -0,0 +1,65 @@
---
- name: 设置现有实例路径
ansible.builtin.set_fact:
pg_profile: "{{ pg_profiles[pg_instance] }}"
pg_config_dir: "{{ pg_config_root }}/{{ pg_instance }}"
pg_parent_dir: "{{ pg_data_root }}/{{ pg_instance }}"
pg_socket_dir: "/run/homelab-postgresql-{{ pg_instance }}"
pg_psql: ["{{ pg_bin_dir }}/psql", -X, -At, -v, ON_ERROR_STOP=1, -h, "/run/homelab-postgresql-{{ pg_instance }}",
-p, "{{ pg_profiles[pg_instance].port | string }}", -d, postgres]
# 角色与扩展只在可写实例上创建,standby 经复制获得。
- name: 检查监控角色与扩展是否符合声明
ansible.builtin.command:
argv: "{{ pg_psql + ['-c', pg_monitor_check] }}"
vars:
pg_monitor_check: >-
SELECT pg_is_in_recovery(),
COALESCE((SELECT rolcanlogin AND NOT rolsuper AND NOT rolcreaterole AND NOT rolcreatedb
AND NOT rolreplication AND NOT rolbypassrls AND rolconnlimit = 3 AND rolpassword IS NULL
AND pg_has_role(oid, 'pg_monitor', 'MEMBER')
FROM pg_authid WHERE rolname = '{{ pg_monitor_user }}'), false)
AND EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements')
become: true
become_user: "{{ pg_profile.user }}"
changed_when: false
check_mode: false
register: pg_monitor_role
- name: 创建或修正监控角色(无密码,仅能经本地 ident 映射登录)
ansible.builtin.command:
argv: "{{ pg_psql }}"
stdin: |
SELECT 'CREATE ROLE {{ pg_monitor_user }}'
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{{ pg_monitor_user }}') \gexec
ALTER ROLE {{ pg_monitor_user }} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS
CONNECTION LIMIT 3 PASSWORD NULL;
GRANT pg_monitor TO {{ pg_monitor_user }};
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
become: true
become_user: "{{ pg_profile.user }}"
when: pg_monitor_role.stdout == 'f|f'
changed_when: true
- name: 安装实例 exporter 服务
ansible.builtin.template:
src: postgres-exporter.service.j2
dest: "/etc/systemd/system/homelab-postgresql-{{ pg_instance }}-exporter.service"
mode: '0644'
register: pg_exporter_unit
- name: 启用 exporter
ansible.builtin.systemd_service:
name: "homelab-postgresql-{{ pg_instance }}-exporter.service"
daemon_reload: "{{ pg_exporter_unit is changed }}"
enabled: true
state: "{{ 'restarted' if pg_exporter_unit is changed else 'started' }}"
# check 模式下 unit 文件尚未写入,systemd 找不到服务。
when: not (ansible_check_mode and pg_exporter_unit is changed)
# 同时证明 HBA/ident 已加载、角色可登录、exporter 能查询。
- name: 检查 exporter 已连上数据库
ansible.builtin.uri:
url: "http://{{ pg_address }}:{{ pg_profile.exporter_port }}/metrics"
return_content: true
register: pg_exporter_metrics
changed_when: false
when: not ansible_check_mode
retries: 10
delay: 3
until: pg_exporter_metrics.status == 200 and pg_exporter_metrics.content is search('(?m)^pg_up 1$')
@@ -0,0 +1,27 @@
[Unit]
Description=PostgreSQL {{ pg_instance }} SQL metrics
After=homelab-postgresql-{{ pg_instance }}.service
[Service]
# 本地 socket 上 TLS 无意义,sslmode=disable 只是避免驱动默认尝试 TLS。
# 以实例 OS 用户运行才能进入 0700 的 socket 目录;peer + ident 把它映射成只读监控角色。
# 监控角色只约束 exporter 自己发出的查询;这不是隔离边界——同一 OS 用户仍可经 peer 以
# superuser 登录,exporter 被攻破即等同实例属主。InaccessiblePaths 只是不让它直接读写
# 数据目录、私钥与 pgpass。暴露面因此仅限内网监听地址。
User={{ pg_profile.user }}
Group={{ pg_profile.user }}
Environment="DATA_SOURCE_NAME=host={{ pg_socket_dir }} port={{ pg_profile.port }} user={{ pg_monitor_user }} dbname=postgres sslmode=disable application_name=postgres_exporter"
ExecStart=/opt/postgres_exporter-{{ pg_exporter_version }}.linux-amd64/postgres_exporter \
--web.listen-address={{ pg_address }}:{{ pg_profile.exporter_port }} \
--collector.database_wraparound --collector.long_running_transactions \
--collector.stat_checkpointer --collector.stat_wal_receiver --collector.stat_statements
Restart=on-failure
RestartSec=5
InaccessiblePaths={{ pg_parent_dir }} {{ pg_config_dir }}
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
MemoryMax=64M
[Install]
WantedBy=multi-user.target
@@ -88,9 +88,9 @@ def main():
pg_etcd_endpoints=[f'{h["etcd_address"]}:23379' for h in hosts.values()],
pg_config_dir='/node/config',pg_parent_dir='/node',pg_data_dir='/node/data',pg_socket_dir='/node/socket',
pg_bin_dir='/usr/lib/postgresql/18/bin',pg_replication_addresses=['127.0.0.0/8'],pg_client_cidrs=['127.0.0.0/8'],
pg_repo_remote=False,pg_repo_path='/repo')
pg_repo_remote=False,pg_repo_path='/repo',pg_monitor_user='homelab_monitor')
values[name]=v
for template in (['postgresql.conf','pg_hba.conf'] if name=='dev' else ['patroni.yml','pgbackrest.conf']):
for template in (['postgresql.conf','pg_hba.conf','pg_ident.conf'] if name=='dev' else ['patroni.yml','pgbackrest.conf']):
(cfg/template).write_text(env.get_template(template+'.j2').render(**v))
for folder in ['socket','backup-lock','backup-spool']:(d/folder).mkdir()
(pg/'repo').mkdir();(pg/'restore').mkdir();(pg/'pitr').mkdir()
@@ -131,6 +131,20 @@ def main():
initial_backup=json.loads(backrest('p1','--output=json','info').stdout)[0]['backup'][-1]['label']
recovery_target=sql('p1','SELECT clock_timestamp()::text')
print('PASS: separate dev, TLS verify-full, non-superuser Ayatori native privileges, initial backup',flush=True)
# 与 monitoring.yml 相同的声明:无密码,只能由实例 OS 用户经 peer + ident 映射登录。
monitor_role="CREATE ROLE homelab_monitor LOGIN CONNECTION LIMIT 3 PASSWORD NULL; GRANT pg_monitor TO homelab_monitor; CREATE EXTENSION pg_stat_statements;"
sql('p1',monitor_role);sql('dev',monitor_role)
def monitor(name,query):
return run(['docker','exec','-i',prefix+'-'+name,'psql','-X','-v','ON_ERROR_STOP=1','-At','-h','/node/socket','-p','25432','-U','homelab_monitor','postgres'],input=query).stdout.strip()
for name in ['p1','p2','dev']:
wait_for(lambda: monitor(name,"SELECT current_setting('shared_preload_libraries')||(SELECT count(*)>=0 FROM pg_stat_statements)")=='pg_stat_statementstrue')
for name in ['p1','dev']:
try:
monitor(name,'CREATE TABLE public.monitor_write(id int)')
raise AssertionError(f'monitor role could write on {name}')
except subprocess.CalledProcessError:
pass
print('PASS: monitor role logs in via peer+ident on primary/standby/dev, reads pg_stat_statements, cannot write',flush=True)
proxycfg=w/'proxy.cfg'
# 文件父目录仍由调用者拥有,新增代理配置不触碰 PG 文件。
proxycfg.write_text(env.get_template('haproxy.cfg.j2').render(pg_proxy_bind='127.0.2.10:26432',pg_proxy_members=[dict(name=n,address=values[n]['pg_address'],port=25432,api_port=28008) for n in ['p1','p2']]))
@@ -0,0 +1,26 @@
# CI pushes only :latest from main; follow the digest behind it. Old digests
# lose their tag and are collected by zot GC, so the deployment must keep
# tracking the current one.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: backstage
namespace: flux-system
spec:
image: zot.ad.ddupan.top/panxiao81/backstage
interval: 1m
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: backstage
namespace: flux-system
spec:
imageRepositoryRef:
name: backstage
filterTags:
pattern: '^latest$'
policy:
alphabetical: {}
digestReflectionPolicy: Always
interval: 1m
@@ -0,0 +1,19 @@
# Gitea token of the private `flux-bot` user: collaborator with write on
# homelab-infra only, token scoped to write:repository. Stored in OpenBao as
# username/password because that is the basic-auth Secret shape Flux reads.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: homelab-infra-write
namespace: flux-system
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
creationPolicy: Owner
name: homelab-infra-write
dataFrom:
- extract:
key: k8s/flux-image-automation
@@ -0,0 +1,15 @@
# Write-capable source used only by ImageUpdateAutomation. The flux-system
# GitRepository stays anonymous and read-only.
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: homelab-infra-write
namespace: flux-system
spec:
interval: 10m
ref:
branch: main
secretRef:
name: homelab-infra-write
timeout: 60s
url: http://gitea-http.gitea.svc.cluster.local:3000/panxiao81/homelab-infra.git
@@ -0,0 +1,28 @@
# Commits digest changes straight to main (no PR): dev images are expected to
# deploy as soon as CI pushes them. Scoped to apps/backstage so a stray setter
# elsewhere cannot be rewritten.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: homelab-infra
namespace: flux-system
spec:
interval: 1m
sourceRef:
kind: GitRepository
name: homelab-infra-write
git:
checkout:
ref:
branch: main
commit:
author:
name: flux-bot
email: [email protected]
messageTemplate: |
chore(image): update {{ range .Changed.Changes }}{{ .OldValue }} -> {{ .NewValue }} {{ end }}
push:
branch: main
update:
path: ./apps/backstage
strategy: Setters
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- external-secret.yaml
- gitrepository.yaml
- imageupdateautomation.yaml
- backstage.yaml
@@ -25,6 +25,8 @@ resources:
- scrapes/shared-etcd.yaml
- rules/shared-postgresql.yaml
- scrapes/shared-postgresql.yaml
- rules/shared-postgresql-sql.yaml
- scrapes/shared-postgresql-sql.yaml
- scrapes/platform-controllers.yaml
- rules/platform-controllers.yaml
- scrapes/cnpg.yaml
@@ -58,6 +58,7 @@ spec:
- alert: SharedPostgresDevUnavailable
expr: homelab_data_dev_sql_ready{job="shared-data-maintenance"} == 0
for: 3m
labels: {severity: warning, service: shared-postgresql}
# env 供 Alertmanager 把 dev 分流到独立 channel。
labels: {severity: warning, service: shared-postgresql, env: dev}
annotations:
summary: 共享 PG 开发实例本地 SQL 不可用或处于只读状态
@@ -0,0 +1,127 @@
# 以 postgres_exporter 上游 postgres_mixin 为底按本环境改写:实例停止、多主、角色变化已由
# Patroni 规则(shared-postgresql.yaml)覆盖;QPS/缓存命中率/回滚数不是故障信号,未采用;
# 上游 PostgresHasReplicationSlotUsed 会被 Patroni 在 standby 保留的非活跃槽持续触发,未采用。
# 每条告警都带抓取目标的 env 标签,Alertmanager 按它把 dev 分流到独立 channel。
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: shared-postgresql-sql
namespace: monitoring
spec:
groups:
- name: shared-postgresql-sql
rules:
- alert: SharedPostgresSqlExporterDown
expr: up{job="shared-postgresql-sql"} == 0
for: 3m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} 的 SQL exporter 无法采集'
# 进程停止已有 Patroni critical;这里覆盖进程在但 SQL 不通(HBA、连接耗尽、dev 实例)。
- alert: SharedPostgresSqlUnreachable
expr: pg_up{job="shared-postgresql-sql"} == 0
for: 2m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} 无法以监控角色执行 SQL'
# 正常 primary 在 laptop ZFS SSD;pve1 的数据盘是 HDD。自动切换后主库安全落到 pve1
# 不会触发任何“无主/多主”规则(2026-09-30 的切换整整一天无人察觉),所以单独提示回切。
# 15m 留给计划内切换演练。
- alert: SharedPostgresPrimaryOnStandbyHost
expr: pg_replication_is_replica{job="shared-postgresql-sql", env="prod", member="pg-pve1"} == 0
for: 15m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: 共享 PG 生产主库运行在 pg-pve1(HDD),请确认切换原因并计划回切 laptop
- alert: SharedPostgresSqlCollectorFailing
expr: pg_scrape_collector_success{job="shared-postgresql-sql"} == 0 or pg_exporter_last_scrape_error{job="shared-postgresql-sql"} == 1
for: 10m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} 部分 SQL 指标采集失败 {{ $labels.collector }}'
# 与 Patroni maximum_lag_on_failover(1 MiB)一致:持续超过即该 standby 不再是合格的切换候选。
- alert: SharedPostgresReplicationLagging
expr: pg_stat_replication_pg_wal_lsn_diff{job="shared-postgresql-sql"} > 1048576
for: 5m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} 副本 {{ $labels.application_name }} 回放落后超过 1 MiB,已不满足自动切换条件'
# 只看可写成员:standby 上 Patroni 维护的槽副本不反映真实保留压力。
# 1 GiB 是 max_slot_wal_keep_size(2GB)的一半,留出处理时间。
- alert: SharedPostgresReplicationSlotRetention
expr: >-
pg_replication_slots_pg_wal_lsn_diff{job="shared-postgresql-sql"} > 1073741824
and on(instance) pg_replication_is_replica{job="shared-postgresql-sql"} == 0
for: 10m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} 复制槽 {{ $labels.slot_name }} 保留 WAL 超过 1 GiB'
# 计数器规则不用 increase():MetricsQL 把新序列首个样本视为从 0 增长,exporter 新上线或
# 角色变化时,历史累计值(如 2026-09-25 搭建期的 9 次归档失败)会被当成刚发生而误报。
# 与 offset 相减对新序列返回空;计数器重置(重启/stats reset)得负值,同样不告警。
- alert: SharedPostgresWalArchiveFailing
expr: >-
pg_stat_archiver_failed_count{job="shared-postgresql-sql"}
- pg_stat_archiver_failed_count{job="shared-postgresql-sql"} offset 15m > 0
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} WAL 归档失败,PITR 链可能中断'
# XID/MXID 上限约 2^31;超过一半说明 freeze 跟不上,超过 75% 已接近强制停写;critical 时不重复 warning。
- alert: SharedPostgresWraparoundRisk
expr: >-
(max without(datname) (pg_database_wraparound_age_datfrozenxid_seconds{job="shared-postgresql-sql"}) > 1e9
or max without(datname) (pg_database_wraparound_age_datminmxid_seconds{job="shared-postgresql-sql"}) > 1e9)
unless (max without(datname) (pg_database_wraparound_age_datfrozenxid_seconds{job="shared-postgresql-sql"}) > 1.6e9
or max without(datname) (pg_database_wraparound_age_datminmxid_seconds{job="shared-postgresql-sql"}) > 1.6e9)
for: 10m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} XID/MXID 年龄超过 10 亿'
- alert: SharedPostgresWraparoundImminent
expr: >-
max without(datname) (pg_database_wraparound_age_datfrozenxid_seconds{job="shared-postgresql-sql"}) > 1.6e9
or max without(datname) (pg_database_wraparound_age_datminmxid_seconds{job="shared-postgresql-sql"}) > 1.6e9
for: 5m
labels: {severity: critical, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} XID/MXID 接近回卷停写,需立即 VACUUM FREEZE'
- alert: SharedPostgresConnectionsHigh
expr: >-
sum without(datname, state, usename, application_name, backend_type, wait_event_type, wait_event)
(pg_stat_activity_count{job="shared-postgresql-sql", backend_type="client backend"})
/ pg_settings_max_connections{job="shared-postgresql-sql"} > 0.8
for: 5m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} 客户端连接超过 max_connections 的 80%'
# 长事务阻止 vacuum 回收并持有锁;idle in transaction 通常是应用忘记提交。
- alert: SharedPostgresIdleInTransaction
expr: >-
max without(datname, state, usename, application_name, backend_type, wait_event_type, wait_event)
(pg_stat_activity_max_tx_duration{job="shared-postgresql-sql", state=~"idle in transaction.*"}) > 600
for: 1m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} 有事务空闲未提交超过 10 分钟'
- alert: SharedPostgresLongTransaction
expr: pg_long_running_transactions_oldest_timestamp_seconds{job="shared-postgresql-sql"} > 3600
for: 1m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} 最老事务已运行超过 1 小时'
- alert: SharedPostgresDeadlocks
expr: >-
sum without(datname) (pg_stat_database_deadlocks{job="shared-postgresql-sql"}
- pg_stat_database_deadlocks{job="shared-postgresql-sql"} offset 15m) > 0
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} 15 分钟内出现死锁'
# 每日 pgBackRest full 会请求一次 checkpoint;每小时多于 4 次通常意味着 max_wal_size 偏小。
- alert: SharedPostgresCheckpointsRequested
expr: >-
pg_stat_checkpointer_num_requested_total{job="shared-postgresql-sql"}
- pg_stat_checkpointer_num_requested_total{job="shared-postgresql-sql"} offset 1h > 4
for: 15m
labels: {severity: warning, service: shared-postgresql}
annotations:
summary: '共享 PG {{ $labels.env }} {{ $labels.member }} 频繁请求 checkpoint,max_wal_size 可能偏小'
@@ -0,0 +1,18 @@
# postgres_exporter 跑在数据库主机上,按实例一个端口;env 标签供 Alertmanager 分流 dev。
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMStaticScrape
metadata:
name: shared-postgresql-sql
namespace: monitoring
spec:
jobName: shared-postgresql-sql
targetEndpoints:
- targets: ['192.168.10.127:9187']
labels: {cluster: homelab-pg-prod, member: pg-laptop, env: prod}
interval: 30s
- targets: ['10.60.0.20:9187']
labels: {cluster: homelab-pg-prod, member: pg-pve1, env: prod}
interval: 30s
- targets: ['192.168.10.127:9188']
labels: {cluster: homelab-pg-dev, member: pg-laptop, env: dev}
interval: 30s
@@ -10,7 +10,7 @@ import sys
import yaml
groups = []
for name in ("kubernetes-health", "host-health", "monitoring-delivery", "platform-controllers", "data-services", "openbao", "shared-data-maintenance"):
for name in ("kubernetes-health", "host-health", "monitoring-delivery", "platform-controllers", "data-services", "openbao", "shared-data-maintenance", "shared-postgresql-sql"):
path = pathlib.Path(sys.argv[1]) / f"{name}.yaml"
groups.extend(yaml.safe_load(path.read_text())["spec"]["groups"])
pathlib.Path(sys.argv[2]).write_text(yaml.safe_dump({"groups": groups}, allow_unicode=True))
@@ -125,6 +125,7 @@ tests:
instance: host:9109
severity: warning
service: shared-postgresql
env: dev
exp_annotations:
summary: 共享 PG 开发实例本地 SQL 不可用或处于只读状态
- name: dev 恢复后解除
@@ -0,0 +1,195 @@
rule_files:
- rules.yaml
evaluation_interval: 1m
tests:
- name: dev SQL 不通时告警带 env=dev 以便分流
interval: 1m
input_series:
- series: pg_up{job="shared-postgresql-sql",instance="h:9188",cluster="homelab-pg-dev",member="pg-laptop",env="dev"}
values: '0x5'
alert_rule_test:
- eval_time: 5m
alertname: SharedPostgresSqlUnreachable
exp_alerts:
- exp_labels: {job: shared-postgresql-sql, instance: 'h:9188', cluster: homelab-pg-dev, member: pg-laptop, env: dev, severity: warning, service: shared-postgresql}
exp_annotations:
summary: 共享 PG dev pg-laptop 无法以监控角色执行 SQL
- name: 回放落后持续超过 1 MiB 告警
interval: 1m
input_series:
- series: pg_stat_replication_pg_wal_lsn_diff{job="shared-postgresql-sql",instance="p:9187",member="pg-pve1",env="prod",application_name="pg-laptop"}
values: '2097152x10'
alert_rule_test:
- eval_time: 8m
alertname: SharedPostgresReplicationLagging
exp_alerts:
- exp_labels: {job: shared-postgresql-sql, instance: 'p:9187', member: pg-pve1, env: prod, application_name: pg-laptop, severity: warning, service: shared-postgresql}
exp_annotations:
summary: 共享 PG prod 副本 pg-laptop 回放落后超过 1 MiB,已不满足自动切换条件
- name: 短暂落后不告警
interval: 1m
input_series:
- series: pg_stat_replication_pg_wal_lsn_diff{job="shared-postgresql-sql",instance="p:9187",env="prod",application_name="pg-laptop"}
values: '2097152x2 0x8'
alert_rule_test:
- eval_time: 8m
alertname: SharedPostgresReplicationLagging
exp_alerts: []
- name: 复制槽积压只在主库告警
interval: 1m
input_series:
- series: pg_replication_slots_pg_wal_lsn_diff{job="shared-postgresql-sql",instance="p:9187",env="prod",slot_name="pg_laptop"}
values: '2147483648x15'
- series: pg_replication_is_replica{job="shared-postgresql-sql",instance="p:9187",env="prod"}
values: '0x15'
- series: pg_replication_slots_pg_wal_lsn_diff{job="shared-postgresql-sql",instance="r:9187",env="prod",slot_name="pg_pve1"}
values: '2147483648x15'
- series: pg_replication_is_replica{job="shared-postgresql-sql",instance="r:9187",env="prod"}
values: '1x15'
alert_rule_test:
- eval_time: 12m
alertname: SharedPostgresReplicationSlotRetention
exp_alerts:
- exp_labels: {job: shared-postgresql-sql, instance: 'p:9187', env: prod, slot_name: pg_laptop, severity: warning, service: shared-postgresql}
exp_annotations:
summary: 共享 PG prod 复制槽 pg_laptop 保留 WAL 超过 1 GiB
- name: 回卷 critical 时不重复 warning
interval: 1m
input_series:
- series: pg_database_wraparound_age_datfrozenxid_seconds{job="shared-postgresql-sql",instance="p:9187",member="pg-pve1",env="prod",datname="app"}
values: '1700000000x15'
- series: pg_database_wraparound_age_datminmxid_seconds{job="shared-postgresql-sql",instance="p:9187",member="pg-pve1",env="prod",datname="app"}
values: '1200000000x15'
alert_rule_test:
- eval_time: 12m
alertname: SharedPostgresWraparoundRisk
exp_alerts: []
- eval_time: 12m
alertname: SharedPostgresWraparoundImminent
exp_alerts:
- exp_labels: {job: shared-postgresql-sql, instance: 'p:9187', member: pg-pve1, env: prod, severity: critical, service: shared-postgresql}
exp_annotations:
summary: 共享 PG prod pg-pve1 XID/MXID 接近回卷停写,需立即 VACUUM FREEZE
- name: 回卷 warning
interval: 1m
input_series:
- series: pg_database_wraparound_age_datfrozenxid_seconds{job="shared-postgresql-sql",instance="p:9187",member="pg-pve1",env="prod",datname="app"}
values: '1200000000x15'
alert_rule_test:
- eval_time: 12m
alertname: SharedPostgresWraparoundRisk
exp_alerts:
- exp_labels: {job: shared-postgresql-sql, instance: 'p:9187', member: pg-pve1, env: prod, severity: warning, service: shared-postgresql}
exp_annotations:
summary: 共享 PG prod pg-pve1 XID/MXID 年龄超过 10 亿
- name: 只计客户端连接
interval: 1m
input_series:
- series: pg_stat_activity_count{job="shared-postgresql-sql",instance="p:9187",member="pg-pve1",env="prod",datname="app",state="active",usename="a",application_name="x",backend_type="client backend",wait_event_type="",wait_event=""}
values: '50x10'
- series: pg_stat_activity_count{job="shared-postgresql-sql",instance="p:9187",member="pg-pve1",env="prod",datname="app",state="idle",usename="b",application_name="y",backend_type="client backend",wait_event_type="",wait_event=""}
values: '20x10'
- series: pg_stat_activity_count{job="shared-postgresql-sql",instance="p:9187",member="pg-pve1",env="prod",datname="",state="",usename="",application_name="",backend_type="autovacuum worker",wait_event_type="",wait_event=""}
values: '30x10'
- series: pg_settings_max_connections{job="shared-postgresql-sql",instance="p:9187",member="pg-pve1",env="prod"}
values: '80x10'
alert_rule_test:
- eval_time: 8m
alertname: SharedPostgresConnectionsHigh
exp_alerts:
- exp_labels: {job: shared-postgresql-sql, instance: 'p:9187', member: pg-pve1, env: prod, severity: warning, service: shared-postgresql}
exp_annotations:
summary: 共享 PG prod pg-pve1 客户端连接超过 max_connections 的 80%
- name: 后台进程不计入连接使用率
interval: 1m
input_series:
- series: pg_stat_activity_count{job="shared-postgresql-sql",instance="p:9187",env="prod",datname="app",state="active",usename="a",application_name="x",backend_type="client backend",wait_event_type="",wait_event=""}
values: '40x10'
- series: pg_stat_activity_count{job="shared-postgresql-sql",instance="p:9187",env="prod",datname="",state="",usename="",application_name="",backend_type="autovacuum worker",wait_event_type="",wait_event=""}
values: '40x10'
- series: pg_settings_max_connections{job="shared-postgresql-sql",instance="p:9187",env="prod"}
values: '80x10'
alert_rule_test:
- eval_time: 8m
alertname: SharedPostgresConnectionsHigh
exp_alerts: []
- name: 死锁计数增长告警
interval: 1m
input_series:
- series: pg_stat_database_deadlocks{job="shared-postgresql-sql",instance="p:9187",member="pg-pve1",env="prod",datname="app"}
values: '0x20 1x5'
alert_rule_test:
- eval_time: 22m
alertname: SharedPostgresDeadlocks
exp_alerts:
- exp_labels: {job: shared-postgresql-sql, instance: 'p:9187', member: pg-pve1, env: prod, severity: warning, service: shared-postgresql}
exp_annotations:
summary: 共享 PG prod pg-pve1 15 分钟内出现死锁
- name: 空闲事务超过 10 分钟
interval: 1m
input_series:
- series: pg_stat_activity_max_tx_duration{job="shared-postgresql-sql",instance="p:9187",member="pg-pve1",env="prod",datname="app",state="idle in transaction",usename="a",application_name="x",backend_type="client backend",wait_event_type="Client",wait_event="ClientRead"}
values: '700x5'
alert_rule_test:
- eval_time: 3m
alertname: SharedPostgresIdleInTransaction
exp_alerts:
- exp_labels: {job: shared-postgresql-sql, instance: 'p:9187', member: pg-pve1, env: prod, severity: warning, service: shared-postgresql}
exp_annotations:
summary: 共享 PG prod pg-pve1 有事务空闲未提交超过 10 分钟
- name: 主库落在 pve1 持续 15 分钟提示回切
interval: 1m
input_series:
- series: pg_replication_is_replica{job="shared-postgresql-sql",instance="10.60.0.20:9187",cluster="homelab-pg-prod",member="pg-pve1",env="prod"}
values: '0x20'
- series: pg_replication_is_replica{job="shared-postgresql-sql",instance="192.168.10.127:9187",cluster="homelab-pg-prod",member="pg-laptop",env="prod"}
values: '1x20'
alert_rule_test:
- eval_time: 10m
alertname: SharedPostgresPrimaryOnStandbyHost
exp_alerts: []
- eval_time: 16m
alertname: SharedPostgresPrimaryOnStandbyHost
exp_alerts:
- exp_labels: {job: shared-postgresql-sql, instance: '10.60.0.20:9187', cluster: homelab-pg-prod, member: pg-pve1, env: prod, severity: warning, service: shared-postgresql}
exp_annotations:
summary: 共享 PG 生产主库运行在 pg-pve1(HDD),请确认切换原因并计划回切 laptop
- name: 主库在 laptop 不提示
interval: 1m
input_series:
- series: pg_replication_is_replica{job="shared-postgresql-sql",instance="10.60.0.20:9187",member="pg-pve1",env="prod"}
values: '1x20'
alert_rule_test:
- eval_time: 16m
alertname: SharedPostgresPrimaryOnStandbyHost
exp_alerts: []
- name: 新出现的计数器序列带历史累计值不误报归档失败
interval: 1m
input_series:
- series: pg_stat_archiver_failed_count{job="shared-postgresql-sql",instance="l:9187",member="pg-laptop",env="prod"}
values: '_x20 9x20'
alert_rule_test:
- eval_time: 25m
alertname: SharedPostgresWalArchiveFailing
exp_alerts: []
- name: 归档失败计数增长告警
interval: 1m
input_series:
- series: pg_stat_archiver_failed_count{job="shared-postgresql-sql",instance="l:9187",member="pg-laptop",env="prod"}
values: '9x20 10x5'
alert_rule_test:
- eval_time: 22m
alertname: SharedPostgresWalArchiveFailing
exp_alerts:
- exp_labels: {job: shared-postgresql-sql, instance: 'l:9187', member: pg-laptop, env: prod, severity: warning, service: shared-postgresql}
exp_annotations:
summary: 共享 PG prod pg-laptop WAL 归档失败,PITR 链可能中断
- name: 计数器重置不误报 checkpoint
interval: 1m
input_series:
- series: pg_stat_checkpointer_num_requested_total{job="shared-postgresql-sql",instance="l:9187",member="pg-laptop",env="prod"}
values: '16x60 0x30'
alert_rule_test:
- eval_time: 85m
alertname: SharedPostgresCheckpointsRequested
exp_alerts: []
@@ -16,6 +16,10 @@ spec:
group_interval: 5m
repeat_interval: 4h
routes:
# dev 必须排第一且不 continue:否则 dev 告警会被后面的 severity 路由送进生产频道。
- receiver: telegram-dev
matchers:
- env="dev"
- receiver: telegram
matchers:
- severity="critical"
@@ -37,6 +41,11 @@ spec:
- bot_token_file: /etc/vm/secrets/alertmanager-telegram/telegram_bot_token
chat_id: -1003956377923
send_resolved: true
- name: telegram-dev
telegram_configs:
- bot_token_file: /etc/vm/secrets/alertmanager-telegram/telegram_bot_token
chat_id: -1003651477106
send_resolved: true
resources:
requests:
cpu: 25m