# 实战：L2 部署验收

LLMS 索引： [llms.txt](/llms.txt)

---

这次实战不是模拟输出。本书在四台本地 Ubuntu VM 上用 exact Pigsty
v4.5.0 部署 PostgreSQL 18，并执行了正式的只读验收。读者可以复跑同一
合同，但不能把硬件与 secret 路径照抄。

## 19.7.1 核对版本、拓扑、资源、端点和安全入口 {#item-19-7-1}

### 风险分级

正常动作：

```text
project / capture / verify / review / all
risk = L0 read-only against deployed service
local writes = selected evidence directory only
```

它们不会：

```text
deploy packages
render remote config
restart/fail over service
run DDL/DML
take/restore backup
remove cluster
export secret values
```

`reset:cluster` 是独立 destructive action，不属于正常实验。

### 正式目标

```text
target               pg36-l2-vagrant
platform             local Vagrant Linux sandbox
Pigsty               exact v4.5.0 tag
PostgreSQL           18.6 observed
hosts                4
PostgreSQL clusters  2
members              4
```

| address | hostname after convergence | service unit | observed role |
|---|---|---|---|
| `10.10.10.10` | `pg-meta-1` | `pg-meta` | primary |
| `10.10.10.11` | `pg-test-1` | `pg-test` | primary |
| `10.10.10.12` | `pg-test-2` | `pg-test` | streaming replica |
| `10.10.10.13` | `pg-test-3` | `pg-test` | streaming replica/offline |

### 先读实验合同

入口：

- [`lab-contract.md`](/labs/ch19/lab-contract.md)
- [`requirements.json`](/labs/ch19/requirements.json)
- [`baseline-v2.0-sandbox.json`](/labs/ch19/baseline-v2.0-sandbox.json)
- [`deployment-adr.md`](/labs/ch19/deployment-adr.md)
- [`deployment-run.json`](/labs/ch19/deployment-run.json)

先确认：

```text
你控制的是 disposable local sandbox
没有生产数据/流量
inventory 是 private mode-0600 文件
四个地址可以 direct SSH
当前只执行 read-only acceptance
```

生产环境不能因命令“只读”就跳过访问授权；主机和数据库 fact 本身也可能是
内部信息。

### 准备私有输入与 evidence 路径

```bash
export PG36_CH19_INVENTORY=/absolute/private/path/pg36.yml
export PG36_EVIDENCE_DIR=/absolute/private/path/evidence/ch19-run
export PG36_SSH_USER=vagrant
```

要求：

```text
inventory file mode = 0600
evidence path outside source control
inventory contains no production credential
SSH user has intended read/sudo ability on this sandbox
```

本章不提供正式 live inventory。公开的
[`inventory.example.yml`](/labs/ch19/inventory.example.yml) 只有结构和
sentinel。

### 先做 secret-free projection

```bash
static/labs/ch19/task.sh project
```

预期：

```text
status=projection-ok
secrets=redacted
```

检查：

```bash
python3 -m json.tool \
  "$PG36_EVIDENCE_DIR/inventory-projection.json"
```

只检查字段，不把输出粘到公共日志。关键值：

```text
status=secret-free-projection
source.mode_octal=0600
source.secret_values_exported=0
version=v4.5.0
pg_version=18
pg_locale=C.UTF-8
host_count=4
```

### direct SSH 避免 alias/forwarding 冒充目标

采集器固定：

```bash
ssh -F /dev/null \
    -o BatchMode=yes \
    -o UserKnownHostsFile=/dev/null \
    -o StrictHostKeyChecking=no \
    vagrant@10.10.10.10 ...
```

禁用本机 config 是本实验对已知 alias 陷阱的防御。`StrictHostKeyChecking=no`
只适用于这个 disposable、隔离 sandbox；生产必须维护可信 host key，不应
复制这个选择。

正式 evidence 保存 machine ID 的 SHA-256，不保存原值：

```text
four 64-hex hashes
all distinct
address/hostname exact match
```

hash 只是避免直接扩散标识，不是强匿名化；仍应把 evidence 当内部资料。

### host capture 内容

每台生成：

```text
hosts/<address>.json
```

包含：

```text
OS/kernel/architecture/virtualization
CPU/model/NUMA count
memory/swap/THP/overcommit
root and data backing filesystem/free bytes
timezone/NTP
addresses/DNS/firewall service state/listening ports
selected systemd services
selected package versions
```

采集器最初尝试跟随 `/pg` symlink，目标是 PostgreSQL-owned mode-0700
目录，非特权用户得到 `PermissionError`。修正后采集 `/data` backing
filesystem，不跨越 PGDATA 权限边界。好的 audit 不应为了“读事实”扩大
权限或放松数据目录。

### host acceptance 与真实例外

四台均：

```text
Linux / Ubuntu 24.04.x / aarch64
Etc/UTC + NTP synchronized
swap = 0
THP = never
root free >= 8 GiB
required services active
```

资源实际值：

```text
pg-meta-1   2 vCPU, about 3.8 GiB RAM
pg-test-*   1 vCPU, about 1.9 GiB RAM each
```

原设计推荐 2 vCPU/2 GiB。没有把阈值悄悄改成“全部合格”，而是：

```text
hard disposable-sandbox acceptance floor = 1 vCPU / 1.8 GiB
recommended teaching floor                = 2 vCPU / 2 GiB
EX19-LAB-RESOURCE-FLOOR                   = accepted exception
```

这允许验证部署关系，禁止容量推断。

### PostgreSQL capture 内容

采集器通过 direct SSH，在每个 node：

```bash
sudo -n -iu postgres \
  psql -X -qAt --dbname=postgres --set=ON_ERROR_STOP=1
```

输入固定
[`postgresql-facts.sql`](/labs/ch19/postgresql-facts.sql)，不拼接 live secret，
不读取业务 row。

生成：

```text
postgres/<address>.json
```

验收：

```text
PG 18
checksums on
block 8192
WAL segment 16777216
UTF8 / builtin / C.UTF-8
Etc/UTC
SCRAM verifier setting
SSL on
SQL recovery role
system identifier relation
```

### Patroni 与 endpoint capture

`patronictl list --format=json` 生成：

```text
patroni/pg-meta.json
patroni/pg-test.json
```

Patroni 表中的 healthy state 并不全叫 `running`：

```text
leader    state=running
replica   state=streaming
```

REST 根端点对 replica 可能返回非 2xx，同时带有效 Patroni JSON。采集器会
保存 HTTP status，并在 body 符合身份协议时标记 reachable，而不是把所有
HTTPError 都误判为网络失败。

endpoint 检查：

```text
5432 direct postgres
5433 primary service
5434 replica service
5436 primary direct service
5438 offline service
8008 Patroni REST
```

只证明 TCP/identity 可观察，不登录业务 service，也不证明路由语义。

### 执行完整正常实验

```bash
static/labs/ch19/task.sh all
```

正式输出：

```text
status=captured
target=pg36-l2-vagrant
hosts=4
secrets=redacted
production_approval=false

status=ok
target=pg36-l2-vagrant
deployment=pigsty-v4.5.0-postgresql-18
hosts=4-distinct
topology=pg-meta-1-primary+pg-test-1-primary-2-replicas
counterexamples=9-rejected
sandbox_l2=accepted-with-exceptions
production_ch19_gate=pending
mutation=none
```

任何缺行都不应靠人工补成通过。

## 19.7.2 从 SQL、主机与 Pigsty 三侧验证同一事实 {#item-19-7-2}

### 实际是四个证据面

本节标题说“三侧”，但严谨验收还包括 declaration：

```text
inventory  declared intent
host       machine and service substrate
SQL        PostgreSQL internal fact
Pigsty     Patroni/service implementation observation
```

四面回答同一问题：

| 问题 | inventory | host | SQL | Pigsty/endpoint |
|---|---|---|---|---|
| target 是谁 | address/group | hostname/machine hash | cluster name/system ID | member/host |
| 版本 | `pg_version` | package | server version | Patroni REST server version |
| 当前 role | bootstrap `pg_role` | process/service | `pg_is_in_recovery` | Leader/Replica |
| replica healthy | member declared | processes/ports | recovery identity | `streaming` |
| locale/checksum | desired vars/defaults | OS context | database/settings | n/a |
| service exists | definitions | listeners | n/a | TCP/REST |

某一面冲突就停止，不做多数表决。

### declaration 不能替代 observation

inventory 写：

```yaml
pg_version: 18
```

可能出现：

```text
package install failed
old server still running
wrong inventory targeted
host variable override
partial rerun
```

所以 SQL 必须返回 major 18。

同理，`pg_checksum: true` 或 role default 只表达意图，`SHOW data_checksums`
才表达运行 cluster 事实。

### process/service active 不能替代 SQL

`systemctl is-active patroni` 返回 active，仍可能：

```text
PostgreSQL startup failed and Patroni loops
member in catchup
wrong data directory
wrong cluster
timeline divergence
port occupied by another process
```

因此 service state 与 SQL/Patroni identity 同时要求。

### SQL recovery role 不能替代 global coordination

两台各自执行：

```sql
SELECT pg_is_in_recovery();
```

如果都返回 false，SQL 只能说明两台都不是 standby；不能告诉你哪一台应当
获得流量，也不能自动 fence。Patroni membership/leader、DCS 和 service
health 共同揭露冲突。

反例：

```text
declare-two-live-leaders -> E_TOPOLOGY
```

validator 要求每个 cluster 恰好一个 leader，并与每台 SQL recovery 状态
一致。

### system identifier 连接 SQL 与 topology

假设：

```text
三个 Patroni member 名和 address 都对
但 pg-test-3 是错误初始化的新 data directory
```

role/status 可能短暂看似正常。system ID 检查会拒绝：

```text
members of one cluster have different system identifiers
```

另一个反例是 `pg-meta` 与 `pg-test` 共用同一 system ID，也会拒绝。

### endpoint open 不等于正确 service

本章所有声明端口 reachable。这是必要条件，不是充分条件。

第 22 章还要验证：

```text
5433 write reaches current primary
5434 is read-only and uses intended replicas
5438 selects offline members
PgBouncer pooling mode/session state
TLS/auth/database/role
drain/reconnect/failover behavior
```

把端口扫描写成“service verification passed”会过度声明。

### source checksum 防止验收脚本漂移

`capture-manifest.json` 保存 capture 时每个 lab source file 的 SHA-256。
`review.py` 再对当前 source 计算。

这样拒绝：

```text
先 capture
后改 requirements/validator
用旧 evidence 宣称新规则通过
```

修改 lab source 后必须重新 capture。

### positive test 与 negative test 是两类证据

正向：

```text
当前环境满足规则
```

反向：

```text
规则会拒绝我们关心的已知坏状态
```

九个反例：

| case | expected code |
|---|---|
| sandbox 声称 production SLO | `E_PRODUCTION_CLAIM` |
| 两地址复用 machine identity | `E_HOST_IDENTITY` |
| 混用 OS | `E_HOST_UNIFORMITY` |
| clock 未同步 | `E_CLOCK` |
| memory 低于 hard floor | `E_RESOURCE_FLOOR` |
| checksum off | `E_PG_INIT` |
| 两 leader | `E_TOPOLOGY` |
| inventory mode 0644 | `E_SECRET_FILE_MODE` |
| evidence 导出 secret value | `E_SECRET_EXPORT` |

[`negative-cases.json`](/labs/ch19/negative-cases.json) 只修改内存中的 evidence
副本，不破坏 live cluster。

### 单独复核已捕获 evidence

```bash
export PG36_EVIDENCE_DIR=/absolute/existing/evidence/ch19-run

static/labs/ch19/task.sh verify
static/labs/ch19/task.sh review
```

`verify` 重新运行 policy；`review` 检查：

```text
source checksums
positive report identity/counts/decision
negative code set
four distinct identities
resource exception exact hosts
endpoint evidence
sanitized deployment account
production boundary
reset_executed=false
```

### 人工 review 仍不可省

自动 validator 不知道：

- owner placeholder 是否已变成真实责任；
- laptop 是否处于受控物理环境；
- business classification 是否正确；
- exception 接受者是否有权；
- package supply chain 是否可信；
- future production SLO 是否合理；
- 当前证据是否用于允许的目的。

机器验证 consistency，人类承担 judgment。

## 19.7.3 产出基线清单、风险例外与 `reset:cluster` {#item-19-7-3}

### evidence bundle

一次 `all` 产出：

```text
inventory-projection.json
capture-manifest.json
hosts/
  10.10.10.10.json
  10.10.10.11.json
  10.10.10.12.json
  10.10.10.13.json
postgres/
  <four member facts>
patroni/
  pg-meta.json
  pg-test.json
endpoints.json
validation-report.json
negative-report.json
review.txt
```

不包含：

```text
live inventory
password/token/private key
customer rows
full deployment private log
raw machine ID
production approval
```

evidence directory 应按内部运维资料管理并设置 retention。

### sanitized deployment account

[`deployment-run.json`](/labs/ch19/deployment-run.json) 保存：

```text
exact source tag/commit
generator command
inventory mode and secret export count
bootstrap/Ansible version
deploy command and safe recap
observed acceptance summary
explicit non-claims
reset_executed=false
```

它方便读书，不替代 fresh capture。host role、package 和 endpoint 会漂移。

### 六个风险例外

| exception | 事实 | 阻止的生产结论 |
|---|---|---|
| shared hypervisor | 全部 VM 共用 laptop | 四个独立 failure domains |
| single etcd | DCS 单节点 | control-plane HA |
| single backup target | MinIO/control 共置 | DR independence |
| virtual storage | 未做 IOPS/latency/durability qualification | capacity/durability |
| inventory secrets | 临时 0600 inventory | production secret lifecycle |
| resource floor | 三节点低于推荐 2C/2G | sizing/concurrency |

exception 必须有：

```text
ID
scope
reason
impact
owner/acceptor
expiry/review
remediation
claim blocked
```

本章 JSON 记录前四类核心字段；真实生产审批系统还要补责任与到期。

### 验收决策

正式结果：

```json
{
  "sandbox_l2": "accepted-with-exceptions",
  "production_ch19_gate": "pending",
  "next_gate": "ch20-ha"
}
```

不是：

```text
production-ready
HA passed
RPO/RTO achieved
backup verified
capacity approved
security compliant
```

### `reset:cluster` 为什么存在

可重复实验必须说明如何回到起点。但删除 cluster：

```text
停止服务
删除 PostgreSQL data
可能删除 backup state
改变 DCS/service/monitor registration
不可由正常 validation 自动恢复
```

所以 reset 是 destructive exercise，不是 cleanup convenience。

本章提供
[`reset-cluster.sh`](/labs/ch19/reset-cluster.sh)，但正式运行没有执行：

```text
deployment-run.boundary.reset_executed=false
```

### reset 多重 guard

需要显式设置：

```bash
export PG36_RESET_TARGET='pg36-l2-vagrant/pg-meta+pg-test'
export PG36_CONFIRM_RESET='RESET_CH19_L2_SANDBOX'
export PG36_NO_PRODUCTION_DATA=yes
export PG36_CLIENTS_DRAINED=yes
export PG36_PIGSTY_HOME=/absolute/exact/pigsty-v4.5.0
export PG36_CH19_INVENTORY=/absolute/private/pg36.yml
export PG36_MACHINE_ID_ALLOWLIST=/absolute/private/machine-ids.json
export PG36_RESET_EVIDENCE_DIR=/absolute/new/pre-reset-evidence
```

然后才可能：

```bash
static/labs/ch19/task.sh reset:cluster
```

脚本还会：

1. 检查 exact v4.5.0 marker；
2. 检查 inventory mode 0600；
3. 要求新 evidence path；
4. 先执行 fresh read-only `all`；
5. 将四台当前 machine hash 与预先 review 的 allowlist 比较；
6. 要求 `/dev/tty` 再输入 exact token；
7. 先移除 `pg-test`，后移除 `pg-meta`。

任何 guard 缺失，exit 77，什么都不删除。

### machine allowlist 不能由 reset 当场自我批准

[`machine-identity-allowlist.example.json`](/labs/ch19/machine-identity-allowlist.example.json)
只有 sentinel。真实 allowlist 应：

```text
从一次已接受 evidence 提取
由 operator review address/hostname/asset
存放 source control 外
限制权限
在 VM rebuild 后重新批准
```

若 reset 先读取当前机器、再自动把当前值当 allowlist，identity guard 就没有
外部 authority。

### 不要在 production override safeguard

Pigsty removal playbook 支持 `pg_safeguard`。production inventory 应显式
启用保护。override 是 emergency/destructive authority，不应复制到普通
runbook、CI 或 alias。

本书脚本的 guard 也不能让 production reset 变安全：

> target classification、data ownership、recovery、change approval 与现场
> 判断仍然优先。发现任何可能是 production，立即停止。

### 本章结束时保留环境

为了第 20–22 章：

```text
do not reset
do not fail over yet
do not restore
do not benchmark to saturation
```

保留 accepted baseline，下一章才能比较 fault 前后。

handoff 包含：

```text
exact source/deployment account
fresh accepted evidence
six exception IDs
known bootstrap role
system-ID relation
no reset performed
pending kernel reboot observation
production gate pending
```

### 读者自检

完成本章后，应能回答：

1. 为什么 deployment `rc=0` 不等于 production ready？
2. 哪些初始化事实必须从 SQL 读取？
3. 为什么四个 IP 需要 machine identity？
4. 为什么 one leader + two replica 还不能给出 HA RTO？
5. 为什么 endpoint open 不证明 routing？
6. 如何在不泄露 inventory 的情况下 review topology？
7. 六个 exception 各阻止什么结论？
8. 为什么 reset 不属于 `all`？

若任何答案只能是“因为 Pigsty 默认这样”，还没有完成环境基线。

---

[上一节：用声明式清单交付两个服务单元](../06/) · [返回本章目录](../) · [下一章：狡兔三窟：高可用拓扑与容灾目标](/high-availability/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
