- Published on
部署本身不该做判断:从"门禁"到"破例"看一套自托管基础设施的设计取舍
- Authors

- Name
- Jack Qin
CI/CD 流水线里有一个很容易被写错的角色分配:让"部署"这一步自己去判断"现在该不该部署"。听起来很自然——部署脚本里加几个 if,检查测试过没过、构建好没好,过了就上。但这样写,部署步骤就同时承担了两个本该分开的职责:判断质量是否达标、执行上线动作。一旦合并,"测试挂了但部署脚本的判断逻辑有 bug,于是带病上线"就成了一个真实存在的失败模式。
正确的拆法是:部署本身不做任何判断,它只是一个在所有质量门通过后才被允许执行的纯动作。 判断的职责上移给一个编排器,部署只负责"被批准之后干活"。这一节是本文的引子,但它代表了整套基础设施反复出现的一种思路——把"判断"和"执行"分开、把"理想"和"现实的破例"分开、把"平时不用"和"用时救命"分开。
本文以某环境监测平台为样本,拆解这套自托管部署体系的几个关键取舍:拓扑怎么划网络、多阶段 Dockerfile 怎么榨干镜像、Alpine 统一为什么必须留破例口子、CI/CD 的门禁怎么编排、以及备份/回滚/告警这些"平时不显眼、出事决定生死"的设计。
约束如何塑造拓扑
这是一套小团队自托管系统,约束直接塑造选择:
- 团队 1–2 人,托管成本要可控,不能堆需要专人运维的云托管服务;
- 数据库是已有的单台 PostgreSQL 15.8,自托管,不迁移;
- 镜像要小——跑在云服务器上,Alpine 减小攻击面和体积;
- 部署不能"测试没过也能上"——必须有门禁。
部署拓扑分两半:静态前端走 CDN,动态后端跑在云服务器 A 的 Docker Compose 上。
flowchart TD
Net[Internet]
Net --> CDN[CDN 静态托管<br/>React SPA 静态文件<br/>CI 构建后自动部署]
Net --> SrvA[云服务器 A 自托管]
SrvA --> Nginx[Nginx 反向代理 :443]
Nginx --> ApiC[Docker API 容器 :8080]
SrvA --> Compose[Docker Compose]
Compose --> ApiC
Compose --> WorkerC[worker 容器]
Compose --> RMQ[rabbitmq :5672 / :15672]
Compose -.->|PostgreSQL 15 自托管,不在 compose 里| PG[(PostgreSQL)]
注意 PostgreSQL 不在 Compose 里——它是独立自托管的已有实例,Compose 只管 api / worker / rabbitmq 三个容器。
第一个取舍很朴素但正确:前端 CDN、后端容器——各用各的最优载体。 静态 SPA 没必要占服务器资源,扔 CDN 享受全球边缘缓存和自动 HTTPS;后端有状态会话和后台进程,跑容器。这背后的判据是:静态资源和动态服务的特性根本不同(一个不可变、可无限边缘复制,一个有状态、需进程常驻),所以不该硬塞进同一个载体。
多阶段构建:把"构建期需要"和"运行期需要"彻底切开
API 镜像是 4 阶段构建,核心思路一句话:构建期需要的一大堆工具,绝不进最终运行镜像。
flowchart TD
S1[Stage 1: docs-build<br/>Node Alpine → 文档静态产物]
S2[Stage 2: csproj<br/>dotnet SDK Alpine<br/>只抽取 .csproj/.sln/Directory.*props]
S3[Stage 3: build<br/>dotnet SDK Alpine<br/>restore + publish → /app/publish]
S4[Stage 4: final<br/>dotnet ASP.NET Alpine<br/>只复制 publish 产物 + 文档<br/>非 root 用户 appuser, ENTRYPOINT]
S1 --> S4
S2 --> S3 --> S4
几个值得学的细节:
- Stage 2 单独抽 csproj 是为了利用 Docker 层缓存——只要项目依赖没变,
dotnet restore这一层就命中缓存,不用每次重跑(restore 很慢)。这是把"不常变的(依赖清单)"和"常变的(业务代码)"分进不同层,让缓存能精确命中——又一次"把变化频率不同的东西分开"; - 最终镜像是 Alpine 基础(约 5 MB),最小攻击面;
- 跑非 root 用户
appuser,安全最佳实践; - 装了
tzdata——这个系统重度依赖时区计算(数据存 UTC、展示用站点本地时区),运行时必须有 IANA 时区库; - 文档静态文件打进 API 镜像,由后端在
/docs/鉴权后提供。
破例口子:理想让位现实
有一处务实破例:Worker 镜像的运行时阶段不用 Alpine 而用基于 glibc 的 ASP.NET 基础镜像。原因是 Worker 用到的某个浏览器自动化驱动(Node 实现)在 musl libc 上跑不起来。
这个破例值得单独说,因为它代表一种成熟的工程态度:"统一 Alpine"是理想,"依赖不兼容 musl"是现实,遇到冲突时,让理想给现实让路,而不是为了"统一"硬扛。 硬扛的代价(自己去修一个第三方驱动的 musl 兼容性)远大于破例的代价(一个镜像用 glibc)。能识别"这里该破例"本身就是判断力——一致性是手段不是目的,当维持一致性的成本超过它带来的收益时,破例才是对的。 把这个破例显式记录下来(而不是偷偷换基础镜像),让下一个人知道它是有意为之、不是疏忽。
双网络隔离:从网络层面让内部组件不可达
networks:
edge: # 对外(只有 API 容器)
backend: # 内部(API + Worker + RabbitMQ)
只有 API 容器在 edge 网络上对外,Worker 和 RabbitMQ 只在 backend 内部网络,外部够不到。端口映射全部绑 127.0.0.1,外部 TLS 由 Nginx 终结。
这是纵深防御,但它的关键在于防御发生的层次:内部组件(队列、Worker)不是"被防火墙规则挡在外面",而是从网络拓扑上就不可达。这两者有本质区别——防火墙规则是"加了一道可能配错、可能被绕过的闸",网络隔离是"这条路根本不存在"。最可靠的安全边界,是让攻击面在结构上不存在,而不是在它存在之后再去封堵。 这和鉴权篇里"HttpOnly Cookie 让 token 在机制上对 JS 不可见"是同一种思路:消除攻击面优于守卫攻击面。
CI/CD 门禁:判断与执行分离
平台是 GitHub Actions + 自托管 runner(跑在云服务器 B,4 台 runner)。工作流按职责拆分,路径过滤只触发相关的:
| 工作流 | 触发 | 职责 |
|---|---|---|
web-lint | apps/web/** | ESLint + 类型检查 |
web-test | apps/web/** | 单元测试 + 覆盖率 |
web-build | apps/web/** | 类型检查 + 构建 + 包体积追踪 |
web-contract | push | OpenAPI schema 漂移检测 |
secret-scan | push | 全仓密钥扫描 |
backend-test | apps/api/** | 4 层 .NET 测试套件(架构/契约/模块/宿主) |
backend-build | apps/api/** | .NET 构建 + Docker 镜像验证 |
main-deploy | push to main | 编排器:门禁所有测试+构建 |
semgrep | push | 安全扫描(SAST) |
部署流是个明确的"门禁"结构:
flowchart TD
Push[push to main]
Push --> WL[web-lint + web-test + web-build 并行]
Push --> BT[backend-test + backend-build 并行]
WL --> Gate[main-deploy 门禁: 以上全部必须通过]
BT --> Gate
Gate --> BD[backend-deploy 可复用工作流]
BD --> SSH[SSH 到云服务器 A → docker compose pull + up -d]
Gate --> CDNDeploy[CDN 从 main 自动部署]
回到开篇:main-deploy 是个纯编排器,它 gate 在 test + build 成功之上,部署本身不做判断、只在所有质量门通过后才被允许执行。 "测试挂了还硬部署"从流程上就不可能发生——因为做判断的是编排器(它的唯一职责就是 gate),执行部署的是另一个被编排器调用的可复用工作流(它假设自己被调用时一切已经就绪)。职责一分开,带病上线的失败模式就没有藏身处。
实现要点:那些"平时不用、用时救命"的设计
健康检查分两种
| 端点 | 用途 | 检查内容 |
|---|---|---|
GET /health/live | 存活探针——进程还活着 | 无(运行中就返 200) |
GET /health/ready | 就绪探针——能接流量了 | PostgreSQL + RabbitMQ |
{
"status": "Healthy",
"checks": { "database": "Healthy", "masstransit-bus": "Healthy" }
}
区分存活和就绪很重要:进程活着不代表能干活(可能连不上库)。Docker Compose 的 healthcheck 用 /health/ready 决定容器是否标记健康——依赖没就绪就不放流量进来。把"活着"和"能干活"当成两个独立信号,是为了不把流量打给一个起来了但还连不上数据库的容器。
配置全走环境变量,密钥绝不入库
配置分层:appsettings.json → appsettings.{Env}.json → 环境变量(嵌套 key 用双下划线)。所有密钥只从环境变量注入,绝不提交到源码,Docker Compose 通过 env_file + environment 块注入。仓库里还有 secret-scan 工作流全仓扫密钥泄漏——配置纪律(不入库)和自动检查(扫描)双保险,因为纪律会被偶尔违反,自动检查才是兜底。
备份与恢复:不演练的备份等于没有备份
PostgreSQL 备份:
- 云服务器 A 上 cron 每日
pg_dump; - 保留 7 份每日 + 4 份每周快照;
- 存到与应用数据分离的对象存储(避免"数据和备份同归于尽");
- 每月做一次恢复演练到 staging 实例;
- RTO < 4 小时,RPO < 24 小时。
两条值得拆开看。第一,备份必须和应用数据物理分离——如果备份和数据放同一块盘,一次磁盘故障两者同归于尽,备份的全部意义(应对数据丢失)当场归零。第二,也是最容易被忽视的:只 dump 不恢复演练,等于没有备份。 一个从没被恢复过的备份,你根本不知道它能不能恢复——它可能因为某个静默错误早就损坏了,而你要到真出事、急着恢复时才发现。每月恢复演练是把"备份可用"这个假设变成"备份已验证"的事实。这是"用时救命"类设施的通病:它们平时不被执行,所以缺陷不会暴露,必须主动定期演练才能保证它真的能用。
RabbitMQ 从业务数据角度是无状态的:崩溃时在途消息可能丢,但 MassTransit 事务性 Outbox 已把消息持久化到 PostgreSQL,未投递的可从 outbox 表重放。队列/交换机定义由 MassTransit 启动时重新声明,无需手动备份。
回滚按影响范围分级,破坏性迁移留人工闸
| 范围 | 操作 | 数据丢失? |
|---|---|---|
| 单容器 | 用上一个镜像 SHA docker compose up -d --no-deps api | 无 |
| 应用回滚 | 镜像 tag 改回上一个 git SHA,up -d | 无 |
| 迁移回滚 | 恢复 PostgreSQL 备份;EF Core 迁移默认只向前 | 可能——破坏性迁移需预审 |
一条硬规则:破坏性迁移(DROP COLUMN、表重命名)合并前必须 CI 里人工审批。
这条规则和前面"部署不该做判断、应当全自动"看似矛盾,其实是同一个判断框架的两面:自动化的边界,画在"可逆"和"不可逆"之间。 代码部署可逆(回滚换镜像,零数据损失),所以全自动;破坏性迁移不可逆(回滚要靠恢复备份、可能丢数据),所以在自动化流水线里唯一该保留人工卡点的地方就是它。不是所有人工闸都是低效——在不可逆操作前的那一道,是用一点人工延迟换"不可逆错误不会自动发生"。
告警阈值
| 信号 | 阈值 | 动作 |
|---|---|---|
/health/ready 失败 | 连续 3 次 | 叫 on-call;Docker 靠 restart: unless-stopped 自动重启 |
RabbitMQ _error 队列深度 | > 10 条 | 叫 on-call,查消费者失败 |
| 磁盘使用率 | > 80% | 告警,归档旧报表 |
| PostgreSQL 活跃连接 | > 80 | 告警,查连接泄漏 |
| Worker 最后心跳 | > 10 分钟无更新 | 告警,容器可能静默崩了 |
最后一条专门针对 Worker 的静默崩溃。这暴露了一个监控盲区:HTTP 服务崩了立刻有人发现(请求开始失败),后台进程崩了没人会立刻注意到——它不响应请求,所以没有"请求失败"这个信号。后台 Worker 的死亡是无声的。所以要给它一个主动的"我还活着"心跳,用"心跳停了"来检测一个本来不会发出任何动静的故障。没有自然故障信号的组件,必须人为制造一个心跳信号来监控。
适用边界:自托管单机的甜区与天花板
换来的东西很清楚:载体各得其所、镜像精简且安全、部署有门禁、救命设施齐备。但这套自托管 + CDN + Compose 有明确的天花板:
- 需要高可用/多副本:当前是单台服务器单实例,要 HA 得引入编排(Kubernetes)和多副本,复杂度跳一个量级;
- 流量大到单机扛不住:自托管单机上限明确,超过就得上托管数据库 + 水平扩展;
- 合规要求异地多活:单云服务器的灾备只能靠备份恢复(RTO < 4h),强合规场景需要真正的多区域冗余。
甜区是:小团队、成本敏感、流量可控、能接受 RTO 数小时。 对这个监测平台恰到好处——没有为了"将来可能的规模"提前背上 K8s 的运维债。
可迁移的那一层:本文几乎每个决策都落在同一个判断框架的某条轴上——判断 vs 执行要分开(门禁)、理想 vs 现实要允许破例(Alpine/glibc)、可逆 vs 不可逆决定自动化边界(破坏性迁移留人工闸)、自然有信号 vs 无信号决定监控方式(Worker 心跳)、假设 vs 验证决定救命设施可不可信(备份演练)。 基础设施设计的大量"该不该自动、该不该统一、该不该信任"的问题,套进这几条轴,答案往往就清楚了。