One self-hosted console to run your entire business — commerce, ERP, HRM, CRM & manufacturing

高并发系统的安全与发布:加固的边缘层与零停机部署

从 CDN、WAF 到私有网络的安全分层,以及由滚动发布、blue/green 和 expand and contract 迁移组成的交付流水线,最后附上流量尖峰当天的上线清单。

Author

Anichur Rahaman

1 天前13 min read1 views
高并发系统的安全与发布:加固的边缘层与零停机部署

在第 4 篇里结账卡住的投诉出现前 17 分钟,也就是同一个开票日的早上 9:44,票务网站的值班工程师面对的是另一个麻烦。10:00 开售,大约 1,000 名买家会在同一分钟内涌入。一位开发同事想发一个一行代码的修复,改订单邮件里的一个错别字。9:52,监控面板显示少数几个地址每分钟发起 4,000 次登录尝试,像是黄牛脚本在预热。(这是虚构的场景,并非真实事件。)

那天早上的结局,取决于几周前做的两个决定:互联网能触及系统的多大一部分,以及发布时是否需要停站。如果答案是“全部”和“需要”,工程师只能拒绝这个修复,再祈祷脚本自己消停。

这最后一篇把两件事讲完:从互联网到数据的一条加固路径,以及一条永不停站的交付流水线。内容来自我自己的现场笔记,涉及为限时秒杀、开票、考试开考这类可预知的流量尖峰所做的准备。这是某一套环境的经验,不是放之四海而皆准的配方。

本文是“高并发系统工程”系列的第 5 篇,也是最后一篇。前几篇:第 1 篇,Web 层与自动伸缩;第 2 篇,队列与 Worker;第 3 篇,数据层;第 4 篇,可观测性。

安全是一排小墙

没有哪个单一产品能保护整套系统。防火墙治不了泄露的密码,再强的密码也挡不住对公网敞开的数据库。务实的做法是分层,每一层都假定前一层偶尔会失守。

OWASP Top 10 是个不错的现实参照。当前版本 OWASP Top 10:2025 把 Broken Access Control(访问控制失效)排第一,Security Misconfiguration(安全配置错误)排第二。两者说的都是系统怎么搭起来的,而不是什么稀奇的漏洞利用。这和我见到的一致:多数事故源于一个没关的端口、一个没改的默认值,或一项给得太宽的权限。

安全分层示意图:从公网经 CDN、WAF 和 load balancer,进入包含 Web 节点、Worker、数据库和缓存的私有网络
只有边缘层对公网开放。其后的一切都在私有网络里,只接受指定来源的流量。

边缘层:CDN、WAF、机器人和限流

在所有对外的入口前面放一层带 Web 应用防火墙(WAF)的 CDN。它能吸收大流量冲击,直接返回缓存页面而不碰你的服务器,还能在常见攻击模式到达代码之前拦下。对尖峰来说它同时保护了你的容量:边缘层应答的每个请求,Web 节点根本看不到。

有三项设置比其他的更重要:

  • 机器人管理。限时秒杀少不了黄牛和脚本。只在结账和登录路径上挑战可疑客户端,不要对全站如此,免得真实顾客被拖慢。
  • 边缘限流。对登录、密码重置、搜索和结账按客户端设限。阈值要来自真实流量:压测时回放上一次高峰的请求组合,记下单个客户端合理的最高速率。
  • 应用层限流。一旦有人找到源站地址,就能绕过边缘层。所以应用里还要有第二道限制,按账号、按操作计数,防止单个用户反复猛打一个昂贵的接口。

把源站锁死,只接受来自边缘网络的流量。如果你的服务器对公网任何人都直接应答,WAF 就只是个建议,而不是一道管控。

TLS,以及它在哪里终止

所有传输都要加密。客户端支持的地方用 TLS 1.3,并把 TLS 1.2 作为底线。PCI DSS 4.0 要求公网上的持卡人数据使用强加密,并排除 SSL 和早期 TLS 版本,所以只要收银行卡,就该关掉 TLS 1.0 和 1.1。

设计上的关键问题是 TLS 在哪里终止。很多部署在 CDN 终止一次,在宿主机反向代理再终止一次,之后以明文 HTTP 送进容器。最后这一跳在私有的宿主机网络里没问题,前提是你确定它真的是私有的。如果流量要穿过你控制不了的网络,这一跳也要加密。

另一个陷阱是各层限制对不上。我运维过的一个平台里,两层 nginx 前后串联:一层宿主机代理负责终止 TLS,一层容器代理挡在 PHP-FPM 前面。宿主机默认的请求体上限是 1 MB,于是上传一直悄悄失败,直到各层口径一致:两个代理里的 client_max_body_size,以及 PHP 里的 post_max_size 和 upload_max_filesize。这是可靠性问题,但它也会诱使人们盲目调大限制。上限只定一次,每一层都配上,并写下来。

私有网络:只有边缘层对外

最有价值的规则也最乏味:除了边缘层和 load balancer,其余一律不对外。Web 节点、Worker、数据库、缓存和搜索节点都放在私有网络。托管的数据库和缓存只接受来自该网络的连接,这样光凭一个泄露的密码,攻击者也进不来。

再加上防火墙规则,写明谁可以和谁通信,例如:

组件谁可以连接对外?
CDN / WAF互联网是
Load balancer仅限边缘网络的地址段是,受限
Web 与 SSR 节点仅限 load balancer否
Worker 与调度器外部无人可入否
数据库与缓存Web 节点和 Worker,经私有地址否
SSH 与管理访问跳板机或 VPN,仅限密钥否

拓扑一变就重看这张表。多数“我们敞开了一个月”的故事,都始于一次没人记录的临时改动。

共用别名事件:主机内部的隔离

网络隔离不只是对着互联网。我曾在一台主机上发现几个相邻项目共用同一个 Docker 网络,每个项目都把自己的 PHP 服务命名为 app。代理被配置为把请求发到端口 9000 的 app,而这个名字解析到了好几个容器。结果一半的请求落到了另一个项目的 PHP-FPM 上。

什么都没有崩溃。页面只是来自错误的代码、错误的配置,有时还连着错误的数据库。这既是可靠性问题,也是数据泄露问题。

解法很简单:每个项目一个独立网络,服务名保持唯一,只需要访问别处的服务就不要把它暴露到该网络里。审查任何一套部署时,直接问一句:这个容器能不能访问到它本不该访问的东西?

密钥、账号与补丁

三个习惯能挡住很大一部分损失。

  • 密钥不进镜像。镜像会被复制到镜像仓库、笔记本和构建缓存里。密钥在运行时从环境变量或密钥库注入,有人离职就轮换。
  • 每个账号最小权限。应用使用的数据库账号不该能删表或建用户。Worker、报表和迁移可以用不同账号、不同权限。员工按角色授权,不共用管理员登录,管理访问要加第二因素。
  • 按计划打补丁。OWASP 在 2025 版里新增“软件供应链失效”(Software Supply Chain Failures)不是没有原因:你依赖的东西也是你攻击面的一部分。定期重建镜像,扫描已知漏洞,把依赖清单收紧到真正用到的那些。服务器和容器的配置,可以对照 Docker 和主流 Linux 发行版的 CIS Benchmarks 检查。

最后要假定终究会出问题。备份要放在攻击者用同一套凭证碰不到的地方,并且练习恢复。这部分我单独写在 面向业务系统的勒索软件防护备份里,这里只补一句:没试过的恢复不是方案,只是侥幸。

不停站发布:构建一次,搬运同一个制品

让人害怕的发布会让大家不敢打补丁、不敢改动,所以目标是让发布变得无聊。第一条规则:只构建一次,发布完全相同的制品。在一台机器上构建镜像并推送,所有节点拉取同一个镜像。切换流量前,先核对各节点的 image ID。永远不要在正在服务的节点上更新源码并构建:相隔一小时构建的两个节点可能悄悄变得不一样,构建中途失败还可能弄坏线上服务器。

配置与镜像分开走,所以同一个制品从预发环境到生产环境原样不变。这样你才能说:测试过的就是发布出去的。

站点在线时做迁移:expand and contract

“零停机”通常栽在数据库变更上。滚动发布期间,新旧代码同时跑在同一个表结构上,所以迁移必须对两者都成立。

标准做法是 expand and contract(扩展再收缩)模式,也叫 parallel change:

  1. Expand。以旧代码会忽略的方式加入新列或新表,比如一个可空列。
  2. Migrate。发布同时写入新旧两种结构的代码,并在后台分小批回填已有数据。
  3. Switch。把读取切到新结构,并盯着错误。
  4. Contract。确认没有任何运行中的代码再用旧结构后,在之后的版本里删掉它。

举个带算术的例子,数字为虚构。有一张 240 万行的 customers 表,要把 phone 列改名为 contact_phone。Expand:新增可空列 contact_phone。Migrate:新代码每次保存都同时写两列,后台任务每批复制 5,000 行。一共 480 批,每批约两秒,总计大约 16 分钟,期间不会给任何用户锁表。切换读取前,核对 phone 有值而 contact_phone 为空的行数是否为零,然后才收缩。

删除是唯一不可逆的一步,所以要等。迁移在站点提供服务时执行,并校验结果:迁移前后的行数,以及确认没有残留转换到一半的数据。发布窗口前先取一份最新的数据库转储,代码也要保留回滚指针。

滚动发布后端、blue/green 前端、Worker 与调度器

不同层需要不同策略。无状态的 PHP 后端,我用滚动发布,一次一个节点:

带关卡的零停机发布流水线示意图:构建一次、核对 image ID、在线迁移、滚动后端、blue/green 前端、排空 Worker、最后启动调度器、观察期
每个阶段都有关卡。关卡失败,流水线就停下,旧版本继续对外服务。
  1. 在 load balancer 上排空一个节点,让它处理完在途请求,不再接新请求。
  2. 把新镜像发布到该节点并重启 PHP Worker,让 OPcache 加载新代码。
  3. 直接对该节点做 smoke test:登录、打开商品、加入购物车。
  4. 把节点加回 load balancer,观察几分钟,同时盯着错误和延迟。
  5. 对下一个节点重复以上步骤。
单个节点滚动发布的流程图,中间有一个判断:smoke test 通过则节点回归并观察,否则保持下线并恢复旧镜像
真正的决定发生在节点还没回到 load balancer 的时候:smoke test 失败,用户毫无损失。

有两个节点时,站点始终有一台健康的服务器。对幂等请求,代理上的重试规则可以把偶发故障对用户藏起来,但不要对会扣银行卡的请求启用。

服务端渲染的前端,我更喜欢 blue/green。在旧版本旁边启动新版本,先单独测试,再改一个代理配置文件并平滑重载来切换。回滚就是反方向做同一步,大约一秒。StoreConsole 自己的生产环境也是这样运行的。

Worker 和调度器需要单独照看。切换 Worker 之前先排空队列:停止接新任务,给在途任务充足的宽限时间跑完,再启动新版本。调度器最后启动,并且只在一个节点上,免得只发布了一半的系统把周期任务跑两遍,或者跑在错误的代码上。

验证、观察期与上线清单

命令返回并不代表发布结束,验证过才算。跑一遍端到端检查,覆盖一次真实的购买或提交,确认队列在流动,然后进入观察期:在宣布完成之前,持续盯一段约定的时间,看错误率、p95 延迟和队列等待时长。可以用本系列第 4 篇讲的面板,遇到吓人的告警,先对照真实进程核实再行动。

大流量活动之前,我用下面这份清单:

  1. 按目标速率做压测并设置中止阈值,保留结果。
  2. 活动之前就为 Web 和前端预先扩容,不要等响应式伸缩。
  3. 确认源站只接受边缘层的流量,并且登录和结账上的 WAF、机器人和限流规则已生效。
  4. 确认数据库、缓存和搜索节点没有公网地址。
  5. 检查 TLS 设置和证书到期日,并确认各层的上传上限一致。
  6. 取一份最新的数据库转储,验证能恢复,并记录回滚指针。
  7. 冻结除紧急修复之外的所有变更,并明确由谁来批准。
  8. 确认告警会送到醒着的人手里,并有书面的升级路径。
  9. 演练一遍回滚,包括只需一次重载的前端切换。
  10. 检查调度器只在一个节点上运行,队列里没有过期的任务。

回到 9:44。有了这些,工程师就能对错别字修复说可以。镜像一小时前就构建并测试过,节点逐个滚动,每分钟 4,000 次登录尝试只撞上登录路径上的挑战,买家照常浏览,不受打扰。数据库没有公网地址,脚本在边缘层之后找不到任何可以下手的东西。修复上线,观察期在 9:55 结束,比开售早五分钟。

下表列出各层如何出问题,以及怎样快速检查。

层级常见问题快速检查
边缘层源站可被直接访问从边缘网络之外请求源站地址
网络数据库对公网开放从外部主机做端口扫描
主机共用服务别名列出每个名称解析到哪些容器
密钥凭证被打进镜像搜索镜像各层和仓库历史
发布各节点运行不同的构建核对每个节点的 image ID
数据库高负载下的破坏性迁移按 expand and contract 审查;先在副本上演练

关键要点

  • 防御要分层:CDN 与 WAF、锁死的源站、私有网络、最小权限账号,以及不进镜像的密钥。
  • 只让边缘层和 load balancer 对外,拓扑一变就复查允许的访问路径。
  • 同一主机上的项目用各自的网络和唯一的服务名隔离,避免共用别名把流量送到错误的代码。
  • 构建一次,发布相同制品并核对 image ID;永远不要在正在服务的节点上构建。
  • 用 expand and contract 小步修改数据库,让新旧代码在整个发布期间都能工作。
  • 后端逐节点滚动,前端用 blue/green 切换,先排空 Worker,调度器最后启动,最后以观察期和演练过的回滚收尾。

Anichur Rahaman 是一名软件架构师,也是 StoreConsole 的创建者。他为成长型企业设计电商与 ERP 系统,专注于事件驱动架构、数据完整性和自托管部署。

About the Author

Anichur Rahaman

Continue Reading