你已经在本机上把订单服务跑通了。打开终端,发一次 POST /api/orders,立刻看到 201 Created。如果只看这一步,项目好像已经完成。但当第二个团队准备调用它,问题会一下子冒出来:请求体哪些字段必填?错误会返回什么?发布新版后,旧客户端还能不能用?
当测试团队介入,还会追问:支付已经入账、回执却丢失时,重试会不会扣两次钱?支付被拒绝后,预留库存有没有释放?运维团队的问题又不一样:镜像如何构建?密钥放在哪里?新版本只放 5% 流量时,什么指标超线就回滚?一次请求穿过四个服务,到底卡在哪一段?
所以,“接口在我电脑上能返回 JSON”只是起点。我们还要把它变成一件能被协作、被验证、被发布,也能在故障时被解释的工程产品。

这一章不再横向罗列工具,因为工具名字并不能告诉你先后关系和失败时该怎么做。我们会沿用前面的订单教学项目,把前九章压成一条可以真正执行的交付路径。
课程项目用一条订单链路串起四个进程。后面每一道交付门都会落到这条链路上。我们先看整体关系:
客户端
└── API 网关
└── 订单服务
├── 库存服务
└── 支付服务请求先进入网关,由网关处理认证、授权、限流、路由和熔断,然后再把创建订单的请求交给订单服务。订单服务先请求库存服务创建预留,再请求支付服务创建支付。只有两步都完成,订单才进入 COMPLETED;任何一步出错,都会把网络边界和状态不一致的代价暴露出来。
项目目录也围绕这条路径组织。每个文件都有可验证的职责,不只是为了把目录分得整齐。具体结构如下:
restful-api-microservices-demo/
├── README.md
├── package.json
├── openapi.yaml
├── src/
│ ├── config.js
│ ├── lib/
│ │ ├── client.js
│ │ └── http.js
│ └── services/
│ ├── gateway.js
│ ├── order.js
│ ├── inventory.js
│ └── payment.js
├── scripts/
│ ├── start.js
│ ├── cluster.js
│ ├── test.js
│ └── scenarios.js
└── artifacts/
├── test-output.txt
└── scenario-output.txt这个项目刻意只使用 Node.js 标准库,但这不是在建议生产系统都自己重写框架。它是想把框架通常会遮住的事情露出来:路由、JSON 读写、问题详情、超时、进程启停和请求日志都是交付面的一部分。看清这些基础责任后,我们才知道框架到底替团队承担了什么,又留下了哪些必须自己决策的问题。
项目中的数据放在进程内存里,令牌也是教学值。它没有持久化流程状态,没有真实签名令牌,也没有多实例共享的限流计数。后面的交付设计会以它为起点,但不会把它直接宣称为生产级系统。
如果订单服务先写代码,等前端来问时再口头解释请求格式,协作会变成猜谜。前端猜测 amount 是元还是分,测试猜测库存不足返回 409 还是 422,运维再从日志里猜测某个路径能不能做健康检查。等到这些猜测都被写成不同仓库里的代码,再想统一就很痛苦了。
API-first 的做法是先把 API 当成一个需要独立设计的产品面。我们先讨论资源、状态转换、请求、响应和失败语义,再决定实现。Contract-first 再往前一步:把这份共同理解写成机器可读的契约,让客户端、服务端、测试和文档围绕同一份输入协作。这两种做法的共同点,是先把协作边界说清楚,而不是用实现细节倒推对外承诺。

契约一旦写下并不是就不能改,否则它会变成阻止业务演进的工件。真正的做法是让变更变得可见,并按以下顺序让消费方和提供方一起判断影响:
这样一来,代码评审就不只是看函数怎么写,还会看对外行为怎么变。对订单 API 来说,一个小小的契约变更就可能带来很大差别:例如把 Idempotency-Key 从必填改成可选,看上去是放宽约束,实际上却可能让超时重试重复创建订单。所以契约评审不能只看格式是不是合法,还要看改动后的故障语义。
项目根目录的 openapi.yaml 是一份 OpenAPI 3.1 契约切片。它用 paths 描述订单集合与单个订单,用 components 复用幂等键、路径参数、错误响应和数据结构。我们不先背所有字段,而是从创建订单的关键片段看它怎样把协议行为写成契约:
openapi: 3.1.0
info:
title: 订单教学 API
version: 1.0.0
paths:
/api/orders:
post:
summary: 创建订单
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
这段契约把路径、请求、成功响应和失败响应放在一起。顺着它阅读,我们可以先还原客户端必须遵守的最小交互。它至少说清了以下五件事:
/api/orders。CreateOrder。201,并给出 Location 和 ETag。CreateOrder 继续把输入边界收紧,避免“路径对了,请求体随便传”。先知道这些约束,调用方才能在发出请求前阻止明显无效输入。具体的字段和数值约束如下:
components:
schemas:
CreateOrder:
type: object
additionalProperties: false
required: [productId, quantity, amount]
properties:
productId:
type: string
quantity:
type: integer
minimum: 1
amount:
type
additionalProperties: false 表明请求体不接受未声明字段,这个约束对协作很有用。如果客户端把 quantity 误写成 count,服务端应该尽早明确拒绝,不要静默忽略后继续执行,否则调用方会得到一个“请求成功但意图没被执行”的假象。OpenAPI 3.1 的 Schema Object 以 JSON Schema 2020-12 方言为基础,并增加了 OpenAPI 自己的语义。
这种对齐不只影响对象字段,也影响“有值或者为空”该怎么表达。精确的空值语义能避免生成客户端把“没有下一页”当成空字符串或缺失字段。项目中的分页结构因此可以把下一页游标表达成字符串或空值:
nextCursor:
type: [string, 'null']课程文件声明 3.1.0 没有问题,因为 OpenAPI 3.1 后续的补丁版只是修正与澄清,工具对 3.1.* 应当按同一特性集合处理。但我们仍然要在 CI 里用实际选定的解析器验证这份文件,不能用“规范上应该兼容”代替真实工具测试。这是工具链的现实代价:规范给出兼容原则,团队还是要为自己真正使用的解析器和生成器建立可重复证据。
先确认 YAML 或 JSON 可以被正确解析。如果缩进错了或引号没有闭合,后面的所有检查都没有意义。
再检查 paths、parameters、requestBody、responses 和 $ref 是不是符合规范。例如路径里出现 {orderId} 时,它必须有对应的路径参数定义,否则文档生成器与路由工具会对占位符产生不同理解。
结构合法不代表设计就一定合适。团队还可以检查错误是不是统一使用 application/problem+json,创建资源是不是声明 Location,需要幂等的创建类操作是不是要求幂等键,让不同服务使用同一套协作语言。
最后一层要把请求真正发给服务。如果契约写的是 201,代码却返回 200;或者契约说 Idempotency-Key 必填,实现却完全忽略,静态文件校验是看不出来的,只有运行时验证能暴露这种契约漂移。
openapi.yaml 是订单对外契约的教学切片,并没有穷举项目内部的所有演示端点。发布真实产品前,要先确定哪些路径是对外承诺,再确保契约对这个范围是完整的。
看到一个只用 Node.js 标准库的实现,很容易转身就问:“那么到底该选哪个框架?”这个问题没有脱离上下文的答案,因为框架不是跑分越高就越适合,而是要看它能不能在团队的限制下稳定交付。具体做决策时,我们先问四组问题:

如果团队需要较少规定、想自己组织目录和中间件,Express 5 仍然是很直接的选项,代价是团队必须自己补上结构约定,否则不同服务很快会长成不同形状。如果团队希望把 Schema 校验、序列化、日志和插件封装放在核心体验里,Fastify 5 更贴近这类约束。不过,“基准测试更快”不能自动推导出“业务一定更快”,数据库和下游等待往往才是真正瓶颈。
如果团队规模较大,希望通过模块、依赖注入、装饰器和统一工程结构降低协作分歧,NestJS 的强约定可能更合适,代价是更多抽象层和学习成本。如果组织已经在 Spring 或 Python 生态中积累了认证、数据访问、可观测性和值班经验,为了一份网络跑分改换语言,通常会失去更多。这些没有谁天然更先进,只有谁更符合当前组织能够承担的复杂度。
你可以把标准库教学实现当成一条基线,再看框架是不是真的为团队减少了可预见的重复工作。如果加上框架后仍然每个服务都要重新决定路由、校验、日志和启停策略,那就只是多了一层依赖。一个有价值的框架至少应稳定减少以下工作:
但框架不会替你决定订单和支付该不该拆开,也不会自动解决“支付成功、回执丢失”这种结果不确定。这些仍然是你的架构责任,也是评估框架时不能被跑分和代码示例遮住的部分。
OpenAPI 是机器可读的,所以自然可以用来生成代码、文档和测试输入。但“能生成”不等于“应该把整个系统交给生成器”,因为生成器看得见契约结构,却看不见业务风险和组织责任。更可控的做法是把生成边界画在稳定、机械、可重建的位置,并且让人继续对边界之外的决策负责。
这些内容共同的特点是:契约改变后,可以删掉旧产物再生成,不需要人手在生成代码里长期维护业务逻辑。这也意味着它们不是真正的修改入口,有问题时应该改契约、生成配置或模板,再重建产物。
生成器不理解你的业务风险。它可以为 POST /orders 生成一个方法,却不知道这个方法被重试两次时会不会重复扣款;生成出的服务端骨架也不知道支付拒绝后应该释放哪一笔预留。因此,一个实用的工程规则是:
契约 → 生成命令 → 可删除产物 → 编译与测试生成命令要在仓库中固定,生成器版本也要锁定,否则同一份契约会在不同机器上产生不同结果。CI 要能从空目录重新生成,并检查产物是不是与当前契约一致。同时不要让开发者直接修改生成文件,否则下一次生成就会把人工修复覆盖掉,而且团队很难判断真正的修复应该回到契约还是生成模板。
订单项目的 npm test 不是调用一个孤立函数,而是会启动四个真实子进程,等待健康检查通过,再从 API 网关发出请求。无论测试通过还是失败,finally 都会停止子进程,避免一次失败影响下一次验证。这使得它能发现“函数各自正确,服务连起来却错了”的问题,但一套可交付 API 不应该只有这一层。
契约测试和端到端测试不是同一件事。契约测试关心的是消费者会发出什么消息,提供方能不能返回消费者实际需要的最小响应,它不负责证明整条业务流程正确。因此,契约测试能让两个服务更低成本地独立验证,却不能替代少量关键端到端路径。
安全扫描也有同样的边界。依赖扫描可以发现已知漏洞,静态扫描可以发现一部分危险代码,动态扫描可以从运行中的 API 寻找异常行为。但“用户 A 能不能修改用户 B 的订单”这类对象级授权问题,必须使用两个身份构造业务场景才能真正验证,不能从“扫描已通过”推导出授权一定正确。
扫描工具通过不等于 API 已经安全。它只说明这次扫描在当前配置、身份和规则范围内没有找到问题。业务授权、敏感字段暴露和滥用场景仍然需要人编写定向测试。
进入项目目录后,只需要运行下面的命令。它会自己启动服务、等待就绪、执行验证并在最后清理进程。完整流程都由脚本负责:
npm test项目会使用 43300–43303 端口启动一组独立进程,避免与手工演示使用的端口互相影响。运行过程没有手工删减某个难以通过的检查。完整执行后,这份代码给出了以下测试结果:
> restful-api-microservices-demo@1.0.0 test
> node scripts/test.js
✓ 聚合健康检查会确认三个下游服务
✓ 未携带令牌时返回统一 401 问题详情
✓ 资源型 URL 可创建订单并返回 Location、ETag 和链接
✓ 重复的幂等键返回同一个订单
✓ If-None-Match 命中时返回 304
✓ 支付已入账但回执超时时,相同幂等键的重试不会重复扣款
✓ 支付被拒后会释放已预留库存
✓ If-Match 防止旧版本覆盖,取消后执行退款和库存补偿
✓ 长时间任务先返回 202,再通过操作资源查询
✓ 两次超时后网关打开熔断器
✓ 限流超额时返回 429 和 Retry-After
✓ 同一 trace ID 会穿过网关、订单、库存和支付服务
12/12 项测试通过。这 12 项并不只检查快乐路径,它们把这门课最想强调的几种分布式失败也变成可执行断言:响应丢失、重复请求、局部失败、旧版本覆盖、下游超时和调用链断裂。测试名称能告诉我们验证了什么,下面再用一条完整请求看客户端实际收到的协议证据。启动项目后执行:
curl -i http://127.0.0.1:43100/api/orders \
-H 'Authorization: Bearer demo-admin-token' \
-H 'Idempotency-Key: reader-example-001' \
-H 'Content-Type: application/json' \
-H 'X-Trace-Id: trace-release-001' \
-d '{"productId":"keyboard-001","quantity":2,"amount":398}'响应中最值得检查的不是“有 JSON”,而是协议语义与业务状态对得上。状态行、响应头和资源表述必须一起成立,只验证其中一块会漏掉关键契约。我们把这三部分放在一起读:
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
x-trace-id: trace-release-001
etag: "v1"
location: /api/orders/ord-0001
{
"id": "ord-0001",
"productId": "keyboard-001",
"quantity": 2,
"amount": 398,
"currency": "CNY",
"status"
201 告诉客户端新资源已经建立,Location 给出新资源的地址,ETag 则给出当前表述版本。x-trace-id 让这次请求可以在四个进程的日志里被连起来,排障人员不必先猜哪个服务出错。这些都是客户端可以依赖、测试可以断言、运维可以查找的交付信号,它们共同把一次“能返回 JSON”的请求变成可验证的 API 行为。
本地运行 npm test 很重要,但它还依赖一个人记得运行,也依赖他没有为了赶时间跳过某个检查。一条真正的交付线要把关键检查移到 CI,并把它们设成合并与发布的前置条件。这样做会增加流水线时间和维护成本,换来的是每一次变更都必须给出同样的证据。

一个拉取请求可以依次经过这些门。前一道没有通过时,就不让未验证产物继续流向后面的发布环节:
先解析并校验 OpenAPI 文件,再执行团队风格规则。如果契约自己都无法成为可信输入,后面不应继续构建。
对新旧契约做语义差异检查。删除响应字段、收紧枚举、新增必填参数或改变状态码时,要明确判断对已有消费者的影响。
运行单元、Schema、契约和集成测试。测试失败时,不允许靠“重跑几次”将不稳定测试变绿。
执行代码、依赖、密钥和 API 动态安全扫描。失败阈值要由团队预先定义,不在每次发布前临时讨价还价。
质量门不是检查项越多越好,每一项都应该有明确的输入、失败条件、责任人和处理时限。如果某个扫描永远产生几百条没人处理的告警,它就不是门禁,只是背景噪声,还会慢慢让团队习惯忽略真正的高风险信号。反过来,也不要因为无法一次消除全部历史问题,就放弃新增问题门禁。一种实用做法是锁住基线:旧问题进入明确的治理队列,新变更不能再引入同级或更高风险。
“在我的电脑上没问题”通常有两层含义:一层是代码和运行时没有被精确固定,另一层是本地环境里隐含了一堆没有写下来的配置。容器主要解决前一层:它把运行时、系统库和应用文件组成一个可验证的镜像,让 CI 测过的运行物能原样进入后续环境。但配置与密钥仍然要被单独设计,容器不会自动知道哪个地址、超时或凭据适合当前环境。

教学项目的 package.json 声明 Node.js 20 或更高版本即可运行,但这只是代码的最低兼容边界,不是生产容器的版本选型建议。到当前时间,Node.js 20 已经结束官方支持,继续把“还能跑”当成“可以上生产”,会失去后续安全与稳定性修复。因此生产镜像应该选仍处于支持周期的 LTS 主线,下面的 Dockerfile 就用当前 LTS 主线运行库存服务:
FROM node:24-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src
USER node
EXPOSE 43101
CMD ["node", "src/services/inventory.js"]这个片段很短,但已经能说明构建与运行的几个底线。它们不会让容器自动安全,却能先删掉一批没有必要的风险:
latest。真正的发布还要把基础镜像锁定到内容摘要,因为标签可能指向新内容,摘要才能精确标识 CI 已经测过的那一份镜像。只锁定应用镜像也不够,它还要与运行时配置明确分开,否则同一个标签在不同环境中仍会表现得像两个系统。
这份教学项目用 DEMO_BASE_PORT 一次平移四个端口,很适合本地演示。在真实容器平台中,每个服务通常会有独立的地址、端口和凭据,这些值也会随环境和发布批次改变。因此,进一步改造时应该把它们变成明确配置:
ORDER_SERVICE_URL
INVENTORY_SERVICE_URL
PAYMENT_SERVICE_URL
REQUEST_TIMEOUT_MS
OTEL_EXPORTER_OTLP_ENDPOINT配置要有启动时校验,必需值缺失时,进程应该立即退出,不要等到第一个用户请求才暴露错误。这样会牺牲“带着缺配置也勉强启动”的表面可用性,却能让容器平台立即阻止错误实例接收流量。密钥则不应写入镜像、代码仓库或启动日志,而应由发布平台在运行时注入,并有独立的访问权限、轮换记录和撤销机制。
不要把项目中的 demo-admin-token 放进生产镜像。它只是为了让课程请求可复现的教学值,不具备签名、过期、轮换和主体权限等生产身份能力。
通过 CI 并不代表新版本应该立刻接管全部流量,因为测试环境无法完全复制生产数据分布、真实并发、缓存状态和下游抖动。灰度发布的目的,是用一小部分可控流量验证这些测试环境无法完全证明的假设,再根据证据决定扩大还是撤回。它会让一次发布需要更长的观察时间,但也把“全部用户一起帮我们测试”改成了有边界的实验。

一次订单 API 灰度可以按流量比例逐步推进。下面的数字是一条可理解的演示路径,真实比例要根据请求量和风险容忍度决定:
0% → 新版本只做预热和健康检查
5% → 观察技术指标与订单业务结果
25% → 扩大客户端、地域或实例范围
50% → 确认下游容量和数据一致性
100% → 保留旧版本回滚窗口,继续观察每一步都要有时间窗口和明确决策条件,否则流量比例只是一串好看的数字。只看 CPU 和内存也不够,因为进程还在健康运行,并不代表订单、库存和支付的结果还能对得上。因此,订单链路至少应该同时看:
5xx 与超时比例。p95 和 p99 延迟。回滚条件要在发布前写好,如果发布后才开始讨论“这个错误率算不算高”,团队很容被沉没成本影响,一次次延长观察窗口。提前写下阈值意味着放弃一部分现场“灵活性”,却能让回滚在压力下仍然是一个快速、可执行的决策。
代码回滚也不等于数据自动回滚。如果新版本已经改写数据结构或发出下游不能撤销的命令,单纯把流量切回旧版本并不能恢复原状。数据库迁移因此要优先设计成向前兼容:先增加新结构,让新旧版本能同时运行;等流量和数据稳定后,再在后续发布中清理旧结构,为回滚保留真正可用的窗口。
订单项目的网关不只是一个反向代理,它集中完成 Bearer 认证、读写权限、限流、路由、路径改写、熔断、健康聚合与指标暴露。这些是跨服务入口政策,集中到网关可以减少重复、统一发布,代价是网关自身会成为需要谨慎运维的共享边界。
网关不该因此逐步长成第二个单体。“预留库存后再支付,支付拒绝就释放库存”是订单业务流程,所以它留在订单服务,而不是塞进网关路由脚本。如果每条业务流程都需要改网关,服务表面上已经拆开,发布节奏却仍然被一个中心组件锁在一起。一个简单的边界判断是:
网关让入口可控,可观测性则让内部可解释。

项目现在已经有一个很重要的起点:它会把 x-trace-id 传给网关、订单、库存和支付服务,每个进程都记录结构化日志。这使得分散日志至少拥有一个可以交叉查找的键。因此,一次订单的记录可以按同一标识拼回下面这条路径:
inventory-service POST /reservations 201
payment-service POST /payments 201
order-service POST /orders 201
api-gateway POST /api/orders 201这些日志可以定位一条已知 trace ID 经过了哪些服务,但它们还不是 OpenTelemetry 追踪。当前项目没有生成 span,没有记录父子关系,也没有把数据导出到收集器,因此还不能直接计算某个下游 span 对总延迟的贡献。要接入 OpenTelemetry,最先要做的不是“装一个仪表盘”,而是在应用代码加载前初始化 SDK 和自动埋点,让后续创建的 HTTP 客户端与服务端能被正确包装。
先在改造分支中安装 Node SDK、自动埋点和 OTLP trace exporter。这些依赖分别负责 SDK 生命周期、常用 Node 模块埋点与 trace 导出。安装命令如下:
npm install \
@opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-proto安装后,为 CommonJS 应用建立一个早于业务代码加载的入口。如果埋点比 HTTP 模块更晚加载,前面已经创建的客户端和服务器就可能不会被包装。初始化入口可以这样组织:
// instrumentation.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const {
getNodeAutoInstrumentations,
} = require('@opentelemetry/auto-instrumentations-node');
const {
OTLPTraceExporter,
} = require('@opentelemetry/exporter-trace-otlp-proto');
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter
启动服务时,要确保这个文件比 HTTP 服务更早加载。同时把服务名与收集器地址放进运行时配置,不要烧录到镜像中。一个订单服务的启动命令如下:
OTEL_SERVICE_NAME=order-service \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
node --require ./instrumentation.js src/services/order.js应用停机流程还要调用 sdk.shutdown(),让已经缓冲的 span 在进程退出前完成导出。完成这些代码不等于接入已经结束,还要从运行与故障角度检查它。下面三个问题都要有可重复答案:
service.name 和环境属性。当前 OpenTelemetry JavaScript 的追踪和指标能力已处于稳定状态,日志能力的成熟度不同,所以不要因为接了 tracing,就删掉现有的结构化日志与错误上下文。更稳妥的做法是让 trace ID 同时出现在 span 和应用日志里,这样才能从高延迟链路跳到对应服务日志。同时保留两套信号会增加存储与治理成本,却也避免了把开发中的日志能力误当成唯一故障证据。
发布前最危险的一句话是:“应该没问题,先上了再说。”它把没有证据的乐观当成了发布决策,也把本应该在低压环境中讨论的分歧留到了故障现场。检查单的作用不是让流程看起来正式,而是把发布时不该临时决定的事提前决定,让每个勾选项背后都有证据和责任人。
这张检查单的最后一项很容被忽略,因为发布记录看起来不会直接影响当前流量。但如果事后不知道当时发布的究竟是哪份契约、哪个镜像和哪组配置,再多日志也很难还原现场。这项工作会增加记录和保留成本,换来的是事故处理时不用靠人的记忆猜测当时环境。
这条工程路径可以让一组已经拆分的 API 变得可交付,但它同时也暴露了拆分的完整代价。如果订单、库存和支付都在同一进程与同一数据库里,很多状态变更可以在一个本地事务中完成,出错时也能用一次回滚恢复。这种简单性是单体真实拥有的工程优势,不是“还没来得及拆分”的过渡状态。
拆开之后,各个能力可以独立演进、独立扩容、独立发布,这些收益在团队节奏或容量要求真正不同时很有价值。但原来的本地调用也变成了网络调用,原来的本地事务变成了幂等、重试、补偿、对账与流程状态持久化。你没有消除复杂度,只是用一类复杂度换取了另一类收益,并把更多工作移到了服务之间。
判断是不是拆分,要看得到的收益能不能覆盖这些新增成本。这个问题不靠“大家都在用”回答,而要回到团队和业务的真实约束。如果符合下列情况,一个模块边界清楚的单体往往更诚实:
模块化单体不是“微服务之前的失败形态”,它可以在一个进程里保留订单、库存和支付的业务边界,同时先避免网络不确定性和跨服务运维成本。等到某个边界真的需要独立扩容、独立合规或独立团队所有权时,再把它拆出去,决策会有更充足的证据,也更容易说清楚愿意支付哪些分布式成本。
这门课最后留下的不应该是一长串工具名称,而应该是一个很具体的判断方式。当你准备划出一条新的服务边界时,也要同时列出为它支付的交付与运维成本。把下面这笔账同时摆到桌面上:
当你用服务边界换来独立演进时,
也必须一起买单的有:
契约、超时、幂等、测试、发布、回滚和可观测性。如果团队还不准备承担这些事,先不拆,反而是更成熟的工程决定。
只在前面全部通过后构建镜像,生成内容摘要和软件物料清单,然后扫描最终镜像。发布对象是这个经过验证的不可变摘要,不是后续重新打包的“同名镜像”。
把同一镜像推进到预发布环境,运行健康检查、冒烟测试和关键故障场景。只有这里的证据通过,才进入生产灰度。