实战④:测试、CI/CD 与上线交付

bee2026-10-08122 分钟0 次阅读
为 BeeOrder 补齐质量与交付链路:测试策略分层、GitHub Actions 流水线、生产 Compose 编排、优雅停机与探针、Nginx 反代、上线检查清单与压测入门——最后一次把项目推到线上。
1 / 180
小节
〇、30 秒看懂
2 / 180

前面 45 篇已经把 BeeOrder 写完了:能下单、能扣库存、能收支付回调。这一篇只做一件事——把它从「在我电脑上跑得动」变成「任何人都能在别处跑得动,而且出事时退得回去」。交付的本质,是把三类隐性前提写成显式资产:可重复的构建(同一份代码在任何机器打出同一个产物)、可验证的质量(该拦的错误由机器拦,而不是靠人记得)、可退场的运行(新版本进来时,旧版本先排空再退出)。这三件事只要还有一件依赖「我记得」,故障就一定从那件发生。

3 / 180

先把六个词一句话解释(全文反复出现):

4 / 180
  • 制品(artifact):一次构建产出的、之后不再修改的交付物——一个 jar 或一个镜像 tag。上线部署的永远是制品,不是源码
  • 镜像 tag:制品的指纹。用 commit sha 打 tag,才能回答「线上此刻跑的到底是哪一次构建」这个要命的问题
  • 就绪探针(readiness):回答「现在能不能给我流量」。依赖没备好,就该摘流量,而不是杀进程
  • 存活探针(liveness):回答「这个进程还有救吗」。只该判断进程自身,配错了会让一次数据库抖动变成全体实例重启
  • 优雅停机(graceful shutdown):先不再接新请求,等在途请求跑完,再退出进程
  • 灰度发布(canary):先只把 5% 的流量交给新版本,确认没问题再逐步扩大
5 / 180
类比

餐厅打烊。 一家店收摊的正确顺序是:先把门口的「营业中」翻成「暂停接待」(readiness 变红、停止接新请求),再让服务员把桌上客人的最后一道菜上完、账结掉(在途请求排空),最后才关灯锁门(进程退出)。直接拉闸(SIGKILL)是什么后果?客人嘴里还含着饭、账没结就走了——落到系统里,就是一条「库存已扣、订单未写」的中间态数据。所以优雅停机不是一个开关,而是一整套打烊流程。

6 / 180
类比

便当盒分层装。 镜像不是一整块,而是叠起来的只读层:米饭(依赖,几乎不变)、菜(你的代码,天天变)、酱料(配置,按环境换)。分层的价值是——只换菜不用重蒸米饭:改一行业务代码时,那层几百 MB 的依赖照样从缓存里直接取。反过来说,把饭菜酱全炒成一团(COPY . . 之后再装依赖),改一个字就得整盒重做,构建从几十秒涨回几分钟。

7 / 180
架构图
图 · 本篇地图:交付要过的五道关
图 · 本篇地图:交付要过的五道关
8 / 180

上图五道关正好对应本篇的主线:质量护栏(第二、三节)、构建护栏(第四、五节)、运行护栏(第六至十节)、退场纪律(第八节)、发布节奏(第十二节)。任何一次上线翻车,都能在这棵树上找到一个唯一的位置——这就是排查的起点,也是本篇最后那张报错速查表的索引方式。

9 / 180
原理动画
动图 · 一次滚动发布的完整时序
动图 · 一次滚动发布的完整时序
10 / 180

这条时间线是本篇最重要的一张图,后面每一节都在拆它的某一步。先记住三个结论:能上线的只有 CI 验证过的那个 tag(第①帧)、老实例的退场是「先摘流量再排空」两步而不是一步(第④⑤帧)、硬杀是宽限期到点的兜底,不是常规手段(第⑦帧)。

11 / 180

学完这一篇,你应该能回答三个问题:

12 / 180
  1. 「在我电脑上是好的」到底缺了哪三类前提?分别由哪个环节补上?
  2. 为什么 readiness 能挂数据库、liveness 绝对不能?配反了会发生什么具体事故?
  3. 一次 docker compose up -d 之后,那 30 秒里应当按什么顺序确认哪些事?
13 / 180
小节
一、交付前最后一道关:为什么「在我电脑上是好的」不算交付
14 / 180

「在我电脑上是好的」是交付史上最贵的一句话。它的潜台词是:我的机器上堆着一批隐性条件——版本、时区、内存、配置——恰好凑成了能跑的状态,而这些条件从来没被写下来。上线要做的,就是把这些隐性条件全部显式化。

15 / 180

三个真实翻车现场,每一个我都亲眼见过:

16 / 180
对照表
翻车现场现象根因交付时该有的防线
JDK 版本不一致服务器 java -jar 第一行就 UnsupportedClassVersionError本地 JDK 21 编译,服务器只装了 17镜像里锁定 JDK,构建产物与运行时同一基础镜像
时区没对齐订单时间比用户看到的早 8 小时,对账对不上容器默认 UTC,应用/数据库各按自己的默认跑全链路统一时区,镜像显式设置 TZ
连接池照搬本地流量一上来就 Connection is not available, request timed out生产沿用默认 maximumPoolSize=10连接池按数据库上限与并发推算,并纳入压测
17 / 180

这三个场景的共同点不是「某个参数写错了」,而是环境没有被版本化。把隐性前提摊开看,其实只有三类,每类都有自己的「显式化位置」和「验证者」:

18 / 180
对照表
隐性前提平时藏在哪显式化之后写在哪谁来验证
构建前提:JDK 版本、Maven 插件、依赖树你本机的 ~/.m2 和 IDE 里的 SDK 下拉框Dockerfile 的第一行 FROM + pom.xmlCI 每次开一台干净的 runner,不给缓存
运行前提:时区、内存限额、连接池、GC你本机 32GB 内存,以及「本地没人访问所以不炸」Compose 的 environment / JAVA_TOOL_OPTIONS / deploy.resources健康检查 + 压测
协作前提:谁 review 过、测试跑没跑、灰度切没切团队的记忆和群聊记录分支保护规则 + 流水线的 needs 门禁机器,不接受「我本地跑过了」
19 / 180
要点

交付不是「把 jar 拷上去」,而是「让任何人都能用同一条命令、在任意一台机器上,得到同一个可运行的系统」。凡是没写进代码或配置里的前提,都是未来的故障单。

20 / 180
小节
二、测试策略分层:三层各有各的活
21 / 180

测试不是写得越多越好。它的意义是在特定成本下覆盖特定风险。把测试分成三层,每层只负责一类问题,就不会出现「写了几百个测试还是没防住线上事故」的尴尬。

22 / 180
对照表
层级关键注解 / 手段覆盖对象依赖速度建议配比
单元测试JUnit 5 + MockitoService 业务分支、金额计算、状态流转全 Mock,无 IO毫秒级60%
切片测试@WebMvcTestController 契约:路由、参数校验、状态码、JSON 结构只装 Web 层,Mock Service百毫秒级25%
集成测试@SpringBootTest + TestcontainersSQL、事务、唯一约束、幂等、连接池行为真 MySQL / Redis 容器秒级15%
23 / 180
说明

比例不是教条,而是成本约束。别用集成测试去测一个 if-else 分支,那是用秒级成本验证毫秒级问题;也别用单元测试去确认「SQL 到底走没走索引」——Mock 永远给不出真实答案。

24 / 180
小节
2.1 单元测试:Service 层用 Mockito 隔离依赖
25 / 180

先测下单成功的核心分支:库存充足、金额正确、订单落库。

26 / 180
代码对照
代码java
@ExtendWith(MockitoExtension.class)class OrderServiceTest {    @Mock private OrderMapper orderMapper;    @Mock private OrderItemMapper orderItemMapper;    @Mock private InventoryService inventoryService;    @InjectMocks private OrderService orderService;    @Test    void createOrder_shouldSucceed_whenStockIsEnough() {        CreateOrderCmd cmd = new CreateOrderCmd(1001L, List.of(new ItemCmd(2001L, 2)));        when(inventoryService.deduct(2001L, 2)).thenReturn(true); // 扣减成功        OrderVO vo = orderService.create("idem-key-001", cmd);        assertThat(vo.orderNo()).startsWith("BO");        assertThat(vo.amount()).isEqualByComparingTo("199.00"); // 金额用 BigDecimal 比较        verify(orderMapper, times(1)).insert(any(Order.class));  // 恰好落库一次    }}
解读
  • @Mock 造出假的 Mapper 与库存服务,测试完全不碰数据库
  • when(...).thenReturn(true) 编排「库存充足」这条前置事实
  • isEqualByComparingTo 比较金额,避开 BigDecimal.equals 对 scale 的敏感
  • verify(..., times(1)) 断言「只落库一次」——这正是幂等要守住的底线
27 / 180
小节
2.2 库存不足:失败分支才是防资损的关键
28 / 180
代码对照
代码java
@Testvoid createOrder_shouldFail_whenStockIsNotEnough() {    CreateOrderCmd cmd = new CreateOrderCmd(1001L, List.of(new ItemCmd(2001L, 2)));    when(inventoryService.deduct(2001L, 2)).thenReturn(false); // 扣减失败    BizException ex = assertThrows(BizException.class,            () -> orderService.create("idem-key-002", cmd));    assertThat(ex.getCode()).isEqualTo(ErrorCode.STOCK_NOT_ENOUGH);    verify(orderMapper, never()).insert(any(Order.class)); // 关键:绝不落单}
解读
  • 库存扣减返回 false,业务必须抛「库存不足」,而不是静默返回
  • never() 断言订单表一条都没插,防止「扣减失败却把订单写了进去」这种脏数据
  • 异常走统一错误码体系(见 #44),前端才能稳定处理
29 / 180
小节
2.3 重复支付回调:幂等测试比并发测试还重要
30 / 180

支付网关最典型的行为就是重复回调。同一笔 trade_no 回调两次,系统只能入账一次。

31 / 180
代码对照
代码java
@Testvoid handleCallback_shouldBeIdempotent_whenDuplicated() {    PayCallback cb = new PayCallback("trade-9f3a", "BO20260207001", new BigDecimal("199.00"));    boolean first  = paymentService.handleCallback(cb);    boolean second = paymentService.handleCallback(cb); // 完全相同的重复回调    assertThat(first).isTrue();          // 第一次真实入账    assertThat(second).isFalse();        // 第二次被幂等挡下,不再改状态    verify(orderMapper, times(1)).markPaid(anyString()); // 状态只流转一次}
解读
  • 第一次回调把订单从 CREATED 推到 PAID,返回 true
  • 第二次命中唯一键 trade_no 或状态机校验,直接返回 false,不再重复处理
  • times(1) 是这份测试的灵魂:幂等的定义就是「副作用只发生一次」
32 / 180
小节
2.4 切片测试:`@WebMvcTest` 锁死接口契约
33 / 180

契约一旦定死,前端才敢并行开工。@WebMvcTest 只装 Web 层,不启动数据库,专测「HTTP 这一面」。

34 / 180
代码对照
代码java
@WebMvcTest(OrderController.class)class OrderControllerTest {    @Autowired private MockMvc mockMvc;    @MockBean private OrderService orderService;    @Test    void createOrder_shouldReturn200_withOrderNo() throws Exception {        when(orderService.create(anyString(), any()))                .thenReturn(new OrderVO("BO20260207001", new BigDecimal("199.00"), "CREATED"));        mockMvc.perform(post("/api/orders")                        .header("Idempotency-Key", "idem-key-001")                        .contentType(MediaType.APPLICATION_JSON)                        .content("{\"userId\":1001,\"items\":[{\"productId\":2001,\"quantity\":2}]}"))                .andExpect(status().isOk())                .andExpect(jsonPath("$.code").value(0))                .andExpect(jsonPath("$.data.orderNo").value("BO20260207001"));    }}
解读
  • @WebMvcTest 只扫描 Controller,Service 用 @MockBean 顶替,启动极快
  • 断言的是响应契约:状态码、统一响应体的 code 与 data 结构
  • 前端把这份测试当「接口说明书」,字段名对不上时 CI 先红
35 / 180
小节
2.5 集成测试:`@SpringBootTest` + Testcontainers 用真 MySQL
36 / 180

SQL、唯一约束、事务回滚这些只有真数据库才测得出来。Testcontainers 的妙处在于:用代码声明一个一次性的真 MySQL,测试机器无需预装数据库。

37 / 180
代码对照
代码java
@SpringBootTest@Testcontainersclass OrderRepositoryIT {    @Container    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")            .withDatabaseName("beeorder").withUsername("bee").withPassword("bee");    @DynamicPropertySource    static void props(DynamicPropertyRegistry r) {        r.add("spring.datasource.url", mysql::getJdbcUrl);        r.add("spring.datasource.username", mysql::getUsername);        r.add("spring.datasource.password", mysql::getPassword);    }    @Autowired private OrderService orderService;    @Test    void shouldNotOversell_underConcurrency() {        // 真库上跑并发下单,验证条件更新真的挡住了超卖    }}
解读
  • @Testcontainers 让 JUnit 管理容器生命周期:类加载时起,测试后销毁
  • @DynamicPropertySource 把容器随机端口回填给 Spring,配置零硬编码
  • 复杂 SQL 与 WHERE available >= ? 的条件更新行为,只有在这里才能被验证
38 / 180
小节
2.6 覆盖率不是目标,断言强度才是
39 / 180

交付评审时最容易骗人的数字是「行覆盖 82%」。看下面两个测试,它们都能让那一行代码计入覆盖率,但只有第二个真的能挡住回归:

40 / 180
代码对照
代码java
// 弱断言:只证明这行被执行过,算进了覆盖率,什么都没锁住@Testvoid createOrder_any() {    OrderVO vo = orderService.create("k", cmd);    assertThat(vo).isNotNull();}// 强断言:把「金额怎么算、状态怎么流转、副作用几次」全部钉住@Testvoid createOrder_contract() {    OrderVO vo = orderService.create("k", cmd);    assertThat(vo.amount()).isEqualByComparingTo("199.00");    assertThat(vo.status()).isEqualTo("CREATED");    verify(inventoryService, times(1)).deduct(2001L, 2);    verify(notificationService, never()).notify(any());   // 提交前绝不发通知}
解读
  • 覆盖率衡量「代码被执行过没有」,断言衡量「行为被钉住没有」,两者互不替换
  • 一条实用纪律:每个新增的业务分支,至少一条 verify 或 assertThrows,光 isNotNull() 不算
  • 最后那条 never() 恰恰是 #45 第十四节的护栏——通知若在事务提交前发出,覆盖率一样是 100%
41 / 180
小节
三、测试数据与并发测试:超卖这种事,只能靠并发测出来
42 / 180

单元测试永远测不出超卖——因为 Mock 不会并发。并发问题必须用并发去验证,这是本篇唯一不能妥协的一条。

43 / 180
代码对照
代码java
@Testvoid shouldNeverOversell_when100RequestsCompeteFor5Items() throws Exception {    int threads = 100;    ExecutorService pool = Executors.newFixedThreadPool(threads);    CountDownLatch ready = new CountDownLatch(threads);    CountDownLatch start = new CountDownLatch(1);   // 发令枪    CountDownLatch done  = new CountDownLatch(threads);    AtomicInteger success = new AtomicInteger();    for (int i = 0; i < threads; i++) {        final long userId = 1000L + i;        pool.submit(() -> {            ready.countDown();            try {                start.await();                       // 所有线程在此待命,同时放行                orderService.create("key-" + userId,                        new CreateOrderCmd(userId, List.of(new ItemCmd(2001L, 1))));                success.incrementAndGet();            } catch (BizException e) {                // 库存不足,属预期失败            } catch (Exception ignored) {            } finally {                done.countDown();            }        });    }    ready.await();    start.countDown();        // 真正的高并发发生在这之后    done.await(30, TimeUnit.SECONDS);    Integer remain = jdbcTemplate.queryForObject(            "SELECT available FROM inventory WHERE product_id = 2001", Integer.class);    assertThat(success.get()).isEqualTo(5);   // 恰好 5 单成功    assertThat(remain).isZero();              // 库存归零,无超卖}
解读
  • ready 让 100 个线程全部就位,start 当发令枪,把并发压到同一瞬间
  • success 计数器断言恰好 5 单成功,多一单就是超卖
  • 最后再查数据库 available,确认账实一致(库存必须是 0)
44 / 180
对照表
预期指标通过标准说明
成功下单数恰好 5多了是超卖,少了是误杀
剩余库存0不能为负
库存流水条数5与成功订单一一对应
失败订单数95均返回「库存不足」错误码
45 / 180
坑

并发测试若不加 start 发令枪,线程会退化成串行执行,看起来「通过」其实什么都没测到。并发测试的成败取决于是否真的同时发生,而不是线程数写了多少。

46 / 180
小节
3.1 测试数据的三条纪律
47 / 180
对照表
纪律反例正解
每个测试自带数据,互不依赖全类共用一条 order_id=1,先跑的测完改状态,后跑的直接失败@BeforeEach 建数据、@AfterEach 清,或干脆每次用新 ID
时间不能靠系统时钟「订单 15 分钟未支付就关闭」,测试要 sleep(15min)把 Clock 注入进来,测试里拨一个假时间
外部依赖必须有桩测试真连支付网关,CI 一跑就产生一笔真扣款@MockBean 或 WireMock 起一个假网关,把签名也算进去
48 / 180
提示

集成测试请一律用 Testcontainers 现场起库,共享的「测试环境数据库」是回归测试不可信的第一大原因——别人手动改了一行数据,你的测试第二天就红,而且没人知道为什么。

49 / 180
小节
四、CI 流水线:把「检查」变成机器的事
50 / 180

CI 的价值只有一句话:让错误在合并前暴露,而不是在上线后暴露。人肉检查一定会漏,机器不会(只要规则写对)。

51 / 180
架构图
图 1 · 从提交到上线
图 1 · 从提交到上线
52 / 180

下面是一份可直接用的 GitHub Actions 流水线,分三阶段:构建测试 → 打包发镜像 → 部署。阶段之间用 needs 串联,前一级失败后面直接不跑,实现失败快速反馈。

53 / 180
代码对照
代码yaml
name: beeorder-cion:  push:    branches: [ main ]    tags: [ 'v*' ]  pull_request:    branches: [ main ]jobs:  build-and-test:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - name: Set up JDK 21        uses: actions/setup-java@v4        with:          distribution: temurin          java-version: '21'          cache: maven                     # 缓存 ~/.m2,依赖下载提速      - name: Run tests        run: mvn -B -ntp clean verify       # 单元 + 切片 + 集成测试一起跑      - name: Upload test report        if: always()        uses: actions/upload-artifact@v4        with:          name: surefire-reports          path: target/surefire-reports  package:    needs: build-and-test                  # 测试不过,镜像不构建    if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')    runs-on: ubuntu-latest    permissions:      contents: read      packages: write    steps:      - uses: actions/checkout@v4      - name: Log in to GHCR        uses: docker/login-action@v3        with:          registry: ghcr.io          username: ${{ github.actor }}          password: ${{ secrets.GITHUB_TOKEN }}      - name: Build and push image        uses: docker/build-push-action@v6        with:          context: .          push: true          tags: |            ghcr.io/acme/beeorder:${{ github.sha }}            ghcr.io/acme/beeorder:latest  deploy:    needs: package    if: startsWith(github.ref, 'refs/tags/v')   # 只有打 tag 才真正上线    runs-on: ubuntu-latest    environment: production    steps:      - name: Deploy over SSH        uses: appleboy/ssh-action@v1        with:          host: ${{ secrets.DEPLOY_HOST }}          username: ${{ secrets.DEPLOY_USER }}          key: ${{ secrets.DEPLOY_KEY }}          script: |            cd /opt/beeorder            export IMAGE_TAG=${{ github.sha }}            docker compose pull app            docker compose up -d app            ./scripts/wait-healthy.sh app   # 健康检查通过才认为部署成功
解读
  • cache: maven 是流水线提速的关键一步,重复构建时依赖不再重新下载
  • needs 串起三阶段:测试红 → 不打包;打包失败 → 不部署,省下大量等待
  • if: startsWith(github.ref, 'refs/tags/v') 把「上线」收窄到打 tag,日常 push 绝不误发布
  • wait-healthy.sh 轮询容器健康状态,避免「容器起来了但服务没好」被误判为成功

提示:CI 里跑集成测试会真的启动 MySQL 容器,切忌把测试环境变量指向生产库。测试连接串一律由 Testcontainers 提供,生产密钥永远只存在于部署环境的 secrets 里。

54 / 180
小节
4.1 镜像才是制品:一份多阶段 Dockerfile
55 / 180

流水线的最后一步产出的不是 jar 而是镜像,因为镜像把「应用 + JDK + 时区 + 启动参数」一起封住了,jar 只封住了应用。这就是 #37 那套写法在交付阶段的最终形态:

56 / 180
代码对照
代码dockerfile
# ---------- 阶段一:构建(带 JDK + Maven,用完即弃) ----------FROM maven:3.9-eclipse-temurin-21 AS builderWORKDIR /buildCOPY pom.xml .RUN mvn -B dependency:go-offline          # 依赖不变 → 这一层永远命中缓存COPY src ./srcRUN mvn -B clean package -DskipTests# 把 fat jar 拆成四层,让依赖层在镜像里也命中缓存RUN java -Djarmode=layertools -jar target/beeorder.jar extract# ---------- 阶段二:运行(只有 JRE,没有 Maven、没有源码) ----------FROM eclipse-temurin:21-jre-jammyWORKDIR /appENV TZ=Asia/Shanghai \    JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:MaxMetaspaceSize=256m -Xss512k"# 非 root 运行,降低容器逃逸面RUN useradd -r -u 1001 -s /usr/sbin/nologin beeUSER beeCOPY --from=builder /build/dependencies/ ./COPY --from=builder /build/spring-boot-loader/ ./COPY --from=builder /build/snapshot-dependencies/ ./COPY --from=builder /build/application/ ./EXPOSE 8080# exec 形式:java 自己是 PID 1,SIGTERM 才收得到(第八节的前提)ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
解读
  • 四条 COPY 的顺序就是缓存策略:越稳定的越靠上,application/ 放最后
  • JAVA_TOOL_OPTIONS 放在运行阶段的环境变量里,运维可以不改镜像用 -e 覆盖
  • USER bee 之后,容器里连 curl 都要注意镜像里到底有没有这个二进制(第七节的 healthcheck 就靠它)
  • ENTRYPOINT 必须是 exec 数组形式,否则第八节整套优雅停机配置一句都不会执行
57 / 180

构建阶段那三个卫生问题(.dockerignore 要不要先写、builder 层的 ~/.m2 为什么每次都重下、alpine 的 musl 为什么会偶发解析失败)都藏在这一节的取舍里,切到 build 那一档能逐帧看到:

58 / 180
内核实验
TeaVM多阶段构建与镜像卫生未启动
选 build:第 ② 帧解释 .dockerignore 为什么必须第一个写,第 ④ 帧是 alpine/musl 那个 UnknownHostException 的来源
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
59 / 180

四条 COPY 的顺序背后是 Docker 的层缓存规则:越稳定的层越先复制,越容易变的层越靠外——这样重建时失效的永远只是最上面那几层。下图把镜像从底到顶拆开,点每一层看它是被什么更新触发重建的:

60 / 180
交互图解
分层镜像分层:谁最稳,谁在上面1 / 5
从底往上点五层:基础镜像几乎不变,dependencies 只随 pom.xml 变,application 每次构建必变——越靠外失效越频繁,重建的代价越小
→
→
→
→
基础镜像 eclipse-temurin:21-jre
不换基础镜像就永不失效。它同时锁定了镜像里的 Java 版本——第十五节那条 UnsupportedClassVersionError,第一道防线就在这里。
全部看懂了从底到顶:越稳定越先 COPY,每次必变的 application 永远放最后——这就是上面四条 COPY 顺序的全部理由。
61 / 180

镜像的骨架也可以直接生成出来:勾选你这一版需要的项,每一条都注释了它对应的前提(停机信号、内存感知这些,正是第八、九节的配置能在容器里生效的前提):

62 / 180
生成器
生成器生成一份按需的交付镜像 DockerfileDockerfile5 / 8
先只留「多阶段 + JRE」看最小骨架;再叠「分层拆包」看 COPY 顺序为什么这么排;最后加「非 root」「内存感知」「健康检查」「停机信号 exec」——每加一条,都对应本节或第八节讲过的一个前提
产物
# ---------- 构建阶段:要完整 JDK 与 Maven ----------
FROM maven:21-eclipse-temurin AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B dependency:go-offline        # 先只拷 pom,依赖层可被缓存
COPY src ./src
RUN mvn -B -DskipTests package \
    && java -Djarmode=layertools -jar target/*.jar extract --destination /app

# ---------- 运行阶段:只要 JRE ----------
FROM eclipse-temurin:21-jre
WORKDIR /app
# 变化频率从低到高排列,改代码不会让依赖层缓存失效
COPY --from=build /app/dependencies/ ./
COPY --from=build /app/spring-boot-loader/ ./
COPY --from=build /app/snapshot-jar/ ./
COPY --from=build /app/application/ ./

RUN groupadd -r app && useradd -r -g app app && chown -R app:app /app
USER app

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
ENV SPRING_PROFILES_ACTIVE="prod"

EXPOSE 8080 9090
# exec 形式:java 就是 PID 1,SIGTERM 能送达(shell 形式做不到)
ENTRYPOINT ["java", "-jar", "app.jar"]

# docker build -t beeorder:0.0.1 .
# docker run --rm -p 8080:8080 -m 512m beeorder:0.0.1
勾了这些,代价与理由在这里
多阶段构建构建阶段要 JDK + Maven,运行阶段只要 JRE,镜像从 700MB 降到 200MB 量级。
分层解包Spring Boot 插件产出 layers.idx,把依赖与业务代码分开,改一行代码不必重推 200MB。
运行镜像用 JRE 而非 JDK容器里不需要 javac/jar 工具链;换 jre 镜像少一层攻击面。
非 root 用户运行容器逃逸时第一道拦阻;很多 K8s 集群的 PodSecurity 会直接拒绝 root。
-XX:MaxRAMPercentage 而不是 -Xmx 写死容器内存改成一档,堆就跟着改;写死 Xmx 是 OOMKilled(exit 137)的经典成因。
63 / 180
小节
4.2 部署脚本要会「等」,而不是「睡」
64 / 180

流水线里那行 ./scripts/wait-healthy.sh app 是整个 CI 唯一把「容器起来了」和「服务能用了」分开的地方。别用 sleep 30,要用轮询:

65 / 180
代码对照
代码bash
#!/usr/bin/env bash# scripts/wait-healthy.sh —— 只有 readiness 真的 UP 才返回 0set -euo pipefailSVC="${1:-app}"URL="${HEALTH_URL:-http://127.0.0.1:8080/actuator/health/readiness}"DEADLINE=$((SECONDS + 120))while [ $SECONDS -lt $DEADLINE ]; do  body=$(curl -fsS "$URL" 2>/dev/null || true)  case "$body" in    *'"status":"UP"'*) echo "healthy after ${SECONDS}s"; exit 0 ;;  esac  sleep 3doneecho "not healthy in 120s, dumping logs:" >&2docker compose logs --tail=200 "$SVC" >&2exit 1          # 非 0 → 流水线红 → 后续 stage 不执行,等于自动阻断发布
解读
  • 用 readiness 而不是 health:前者是「能不能接流量」,后者是聚合状态,可能包含无关组件
  • 超时后打印日志再退出,把「为什么红」直接留在构建输出里,不用登服务器
  • exit 1 的价值在于它让「回滚」变成一个决策而不是一个意外:既然红了就没切流量,直接不部署下一批
66 / 180
小节
五、分支策略与流水线的关系
67 / 180

流水线不是孤立的,它必须和分支模型咬合。BeeOrder 采用最朴素的 trunk-based 变体:main 保护 + PR 必须绿 + tag 触发发布。

68 / 180
对照表
分支 / 事件触发流水线必须通过产物
feature 分支 push仅 build-and-test三层测试全绿无(随手验证)
向 main 发 PRbuild-and-test测试 + 契约检查无(门禁)
合并进 mainbuild-and-test + package测试全绿:sha 与 :latest 镜像
打 v1.2.0 tag全流水线 + deploy全部部署到生产
69 / 180
说明

main 要打开分支保护(Branch protection),要求「PR 至少 1 个 review + 所有 status check 通过」才可合并。GitLab CI 的等价写法就是 rules 按分支/tag 匹配 + only: tags,思路一模一样,只是 yml 语法换了名字。

70 / 180
小节
六、交付形态:fat jar、war 还是镜像
71 / 180

上线前必须做一次的选择,其实是「交付物长什么样」。这三者不是同一维度的替代品:jar 与 war 是打包格式,镜像是运行环境。

72 / 180
对照表
维度可执行 fat jarwar(外部 Tomcat)容器镜像
谁提供 Servlet 容器应用自己(内置 Tomcat 打进包里)宿主机上的 Tomcat应用自己(在镜像里)
JDK 版本谁保证运维装,容易和 CI 不一致Tomcat 用的那套 JDKDockerfile 的 FROM
依赖在哪BOOT-INF/lib,由自定义类加载器读WEB-INF/lib + WEB-INF/lib-provided分层 COPY 后与 #37 一致
启动命令java -jar app.jar拷进 webapps/ 由 Tomcat 解包docker run,入口仍是 java
一台机器多实例容易(换端口)受限于同一个 Tomcat最容易(换容器)
适合微服务、K8s、绝大多数新项目公司强制的统一 Tomcat 基线需要「环境一起交付」时
73 / 180

war 想用 java -jar 启动、又想丢进外部 Tomcat,有两个硬前提,少一个就 404:

74 / 180
java
// 前提一:启动类继承 SpringBootServletInitializer,外部容器才找得到引导入口public class BeeOrderApplication extends SpringBootServletInitializer {    @Override    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {        return builder.sources(BeeOrderApplication.class);    }}
75 / 180
代码对照
代码xml
<!-- 前提二:内置容器改成 provided,否则外部 Tomcat 会和它抢 servlet-api --><dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-tomcat</artifactId>    <scope>provided</scope></dependency>
解读

类比:罐头和半成品菜。 fat jar 是一罐红烧肉——肉、汤、调味料全封在里面,开罐即食(java -jar 就能跑),代价是罐子本身也有重量(内置 Tomcat 那几十 MB)。war 是一袋半成品菜:菜是真的做好了,但必须倒进别人家的锅里(外部 Tomcat)才能吃,你连灶台都不用买。镜像则连微波炉一起打包——不是「菜」的差别,而是「连厨房一起交付」。

76 / 180

这也解释了交付阶段最常见的一类「明明打包成功了却起不来」:把 repackage 之后的 fat jar 用 java -cp app.jar com.beeorder.BeeOrderApplication 启动,于是找不到依赖——BOOT-INF/lib 里的 jar 不在系统 classpath 上,只有 JarLauncher 那个自定义类加载器才认得它。这个坑在实验里可以直接复现:

77 / 180
内核实验
TeaVM打进镜像的那个 jar 里到底有什么未启动
先选 layout 认清 BOOT-INF 目录,再切 loader 看 LaunchedClassLoader 怎么找到依赖,最后切 war 看部署到外部 Tomcat 时加载方式怎么变
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
78 / 180
决策
决策BeeOrder 是单体应用,公司没有强制的 Tomcat 基线,运维只给了两台装了 Docker 的空机器。交付物应该选哪一种?
79 / 180
小节
七、生产环境 Compose 编排
80 / 180

开发用的 Compose 只求「能跑起来」,生产用的 Compose 要回答四个问题:怎么重启、怎么保数据、怎么限资源、怎么收日志。

81 / 180
代码对照
代码yaml
services:  app:    image: ghcr.io/acme/beeorder:${IMAGE_TAG:-latest}    restart: unless-stopped    env_file: [ .env.production ]    environment:      SPRING_PROFILES_ACTIVE: prod      TZ: Asia/Shanghai      JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75 -XX:+UseG1GC"      SERVER_SHUTDOWN: graceful                      # 第八节:优雅停机由环境变量注入      SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: 25s    depends_on:      mysql: { condition: service_healthy }      redis: { condition: service_healthy }    ports:      - "127.0.0.1:8080:8080"          # 只暴露给宿主本机,由 Nginx 反代    stop_grace_period: 40s              # docker stop 的 SIGKILL 宽限期,必须大于排空时间    healthcheck:      test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health/readiness"]      interval: 15s      timeout: 5s      retries: 5      start_period: 40s                # 给 JVM 启动留足时间    logging:      driver: json-file      options: { max-size: "20m", max-file: "5" }    deploy:      resources:        limits: { cpus: "2.0", memory: 1g }    networks: [ bee-net ]  mysql:    image: mysql:8.0    restart: unless-stopped    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci    environment:      MYSQL_DATABASE: beeorder      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}      TZ: Asia/Shanghai    volumes:      - mysql-data:/var/lib/mysql      - ./backup:/backup                 # 备份脚本输出目录    healthcheck:      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_ROOT_PASSWORD}"]      interval: 10s      retries: 6    networks: [ bee-net ]  redis:    image: redis:7-alpine    restart: unless-stopped    command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]    volumes: [ redis-data:/data ]    healthcheck:      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]      interval: 10s      retries: 6    networks: [ bee-net ]networks:  bee-net: { driver: bridge }volumes:  mysql-data:  redis-data:
解读
  • restart: unless-stopped 配合 healthcheck,让容器崩溃与依赖未就绪都有确定的处理方式
  • start_period: 40s 是给 JVM 冷启动的宽限期,避免刚开机就被判死
  • stop_grace_period: 40s 必须大于 Spring 侧的 timeout-per-shutdown-phase,否则排空到一半就被 SIGKILL(第八节详述)
  • logging 限制日志体积,deploy.resources 限制 CPU/内存,防止单个服务拖垮整机
  • mysql-data 数据卷保证容器重建不丢数据;./backup 挂载出来给 mysqldump 存备份
82 / 180

MySQL 备份就是一条 cron 加一条命令:

83 / 180
bash
# /opt/beeorder/scripts/backup.shdocker compose exec -T mysql \  mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction beeorder \  | gzip > "/backup/beeorder-$(date +%F-%H%M).sql.gz"find /backup -name '*.sql.gz' -mtime +7 -delete   # 保留 7 天
84 / 180
对照表
维度开发 Compose生产 Compose
镜像来源本地 build 或 Dockerfile仓库拉取固定 tag
端口直接暴露 8080只绑 127.0.0.1,由 Nginx 对外
数据卷可临时、可丢弃命名卷 + 定时备份
资源限制无cpus / memory 双重限制
日志默认 stdoutjson-file 轮转,20m × 5
健康检查通常省略每个服务都有,且含 start_period
停机宽限默认 10 秒stop_grace_period 显式写,与排空对齐
配置明文 application-devenv_file + secrets,配置外置
85 / 180
坑

容器时区没设,MySQL 与应用各按自己的默认跑,订单时间就会错 8 小时。生产里应用、数据库、Redis 三者都要显式设置 TZ,并在存储与展示上统一约定(存 UTC 或统一本地时区,团队二选一,绝不混用)。

86 / 180
小节
八、优雅停机:摘流量 → 排空 → 退出
87 / 180

第三节那张动图的第④⑤⑥帧全在这一节。优雅停机不是一个配置,而是三个环节的接力:编排层把实例摘出流量池、应用等在途请求跑完、信号真的走到 JVM。任意一环断掉,症状都是「日志里一句 shutdown 都没有,请求被硬切」。

88 / 180
yaml
# application-prod.ymlserver:  shutdown: graceful              # 停止接收新请求,等 in-progress 的请求处理完spring:  lifecycle:    timeout-per-shutdown-phase: 25s   # 排空预算,必须小于 stop_grace_periodmanagement:  endpoint:    health:      probes:        enabled: true             # 打开 /actuator/health/liveness 与 /readiness 两个组      group:        readiness:          include: readinessState,db,redis   # 依赖只进 readiness:故障就摘流量,不杀进程        liveness:          include: livenessState             # 进程自身的死循环/OOM 才归它管  health:    livenessstate:      enabled: true    readinessstate:      enabled: true
89 / 180

三段配置对应三个环节,缺一不可:

90 / 180
对照表
环节谁负责配错的症状正确姿势
摘流量编排层(K8s 的 readiness / Compose + Nginx 的上游健康检查)新请求不断打进来,排空永远等不完先让 readiness 变红,等 2~5 秒再开始收尾
排空Spring server.shutdown=graceful在途请求被 Connection resettimeout-per-shutdown-phase 给够,且没有长事务
收到信号ENTRYPOINT 的 exec 形式 / tini日志里一句 shutdown 都没有,10 秒后被硬杀exec 数组形式,或 shell 形式里写 exec java -jar …
91 / 180

两侧宽限期必须对齐,这是最常见的失配:Spring 侧配了 25 秒排空,而 docker stop 默认只等 10 秒就发 SIGKILL,于是后 15 秒的请求照样被腰斩。

92 / 180
bash
# 三种正确的等法docker stop -t 40 beeorder-app            # 单容器:显式加宽限期docker compose stop --timeout 40 app      # Compose:同样要显式# K8s:terminationGracePeriodSeconds 必须 > timeout-per-shutdown-phase
93 / 180

配错和配对的两种样子并排放在一起,就是下面这张对照图的全部内容——左边是那被腰斩的 15 秒,右边是两个数字对齐之后的从容:

94 / 180
架构图
图 · 宽限期必须对齐:10 秒硬杀 vs 40 秒排空
图 · 宽限期必须对齐:10 秒硬杀 vs 40 秒排空
95 / 180

验证方法很土但最有效:一边 docker compose stop --timeout 40 app,一边 docker compose logs -f app,看得见 Commencing graceful shutdown 和 Waiting for requests to complete 两行才算通。下面这个实验把信号链路和三种停机姿势走完:

96 / 180
内核实验
TeaVM上线与优雅停机:摘流量 → 排空 → 退出未启动
依次切 ready / drain / kill 三档:ready 看两个探针的分工,drain 看 80 条在途请求怎么被等完,kill 看硬杀时那几条扣了库存没写订单的请求去了哪
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
97 / 180
类比

电影院打烊。 关灯是「不再卖票」(readiness 变红、停止接新请求),放映厅里正在看的观众要看完这一段才离场(在途请求排空),广播通知得先递到领班手里才有用(信号走到 PID 1)。十分钟还不走,保安直接清人(SIGKILL)——爆米花就打翻在地了。

98 / 180

镜像里的信号这一环最容易断在 ENTRYPOINT 的写法上——dockerimg 的 signal 那一档专门演示 shell 形式如何把 /bin/sh 放上 PID 1、顺手把 SIGTERM 吞掉:

99 / 180
内核实验
TeaVMSIGTERM 到底有没有走到 JVM未启动
切到 signal:对照第 ②③ 帧看清 exec 与 shell 形式的区别,再看第 ⑥ 帧为什么宽限期必须两边对齐
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
100 / 180

把三段接力连起来,就是下面这条完整的停机时序——从 readiness 变红到进程真正退出,每一拍谁在等谁、断在哪一拍会留下什么症状,都画在里面了:

101 / 180
原理动画
动图 · 停机的七步接力:从摘流量到退出
动图 · 停机的七步接力:从摘流量到退出
102 / 180

这条链路与其读两遍,不如自己在内核控制台里点着走一遍——下面这几条命令从探针分工一路演到硬杀与优雅的对比,敲完再回去看配置,每行都认识:

103 / 180
内核控制台
104 / 180
小节
九、Actuator:探针与暴露面
105 / 180

上一节那两份 probes 配置由谁提供?就是 Actuator。它是交付阶段唯一的一个「运维接口」,也是安全审计最喜欢点名的地方。

106 / 180
yaml
management:  server:    port: 9090                     # 独立管理端口:业务端口 8080 一个 actuator 都不放  endpoints:    web:      exposure:        include: health,info,metrics,prometheus   # 白名单,绝不写 *  endpoint:    health:      show-details: when-authorized  info:    env:      java:        enabled: true
107 / 180
代码对照
代码java
// 给指标贴业务标签:下单成功率比 QPS 有用得多@Componentpublic class OrderMetrics {    private final MeterRegistry registry;    public OrderMetrics(MeterRegistry registry) {        this.registry = registry;    }    public void recordCreate(String outcome) {          // outcome: success / stock / validation        registry.counter("beeorder.order.create", "outcome", outcome).increment();    }}
解读
  • management.server.port=9090 之后,Nginx 只需反代 8080,Actuator 天然不出公网
  • include 白名单写 * 是最常见的高危配置:/actuator/env 会泄露数据库密码,/actuator/heapdump 能让你整个进程的密钥被人下载走
  • show-details: when-authorized 保证外部只看 UP/DOWN,运维登录才看得到是哪个依赖挂了
  • /actuator/info 建议由构建插件写入 git sha —— 线上到底跑的哪一版,这个问题要能用一条 curl 回答

类比:厨房没备完菜,别挂「营业中」的牌子。 readiness 就是这个牌子:它问的是「菜备齐了吗、能上座吗」,所以数据库断了要变红(先别接客)。liveness 问的是「这家店还在不在」——房子没塌就别说它死了。要是把数据库也挂进 liveness,主从切换抖 3 秒,编排层就会把六家门店全拆了重建:牌子是红的,但店好好的。

108 / 180
内核实验
TeaVMActuator 端点与健康聚合未启动
先切 health 看清 liveness / readiness 两个组的聚合规则;再切 risk 看 exposure 写 * 时 /env 和 /heapdump 各泄露了什么;最后切 metrics 看指标怎么被抓走
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
109 / 180

探针、宽限期、镜像 tag——交付里的术语长得都像,区别却全在「它在回答哪个问题」,配错一个就是一次事故。来一局术语配对:先点左边的名词,再点右边它真正回答的那个问题:

110 / 180
配对闯关
闯关交付术语配对:每个词都在回答一个问题已配对 0/6 · 配错 0
左边六个名词都出现在本篇的配置里,右边是它们各自守着的那条边界
先点左边一个
111 / 180
决策
决策`/actuator/health` 要不要把数据库、Redis 都算进去?
112 / 180
小节
十、Nginx 反向代理配置
113 / 180

容器里的 8080 不直接给公网。Nginx 负责 TLS、压缩、真实 IP 透传和大文件限制——这些都不该由应用自己做。

114 / 180
代码对照
代码nginx
server {    listen 80;    server_name api.beeorder.example.com;    # HTTP 一律跳 HTTPS    return 301 https://$host$request_uri;}upstream beeorder {    server app:8080 max_fails=3 fail_timeout=10s;   # 单机只有一个后端,多实例时加行    keepalive 32;}server {    listen 443 ssl;    server_name api.beeorder.example.com;    ssl_certificate     /etc/nginx/certs/fullchain.pem;    ssl_certificate_key /etc/nginx/certs/privkey.pem;    client_max_body_size 20m;        # 商品图片等上传上限    gzip on;    gzip_types application/json text/plain text/css application/javascript;    gzip_min_length 1024;    location / {        proxy_pass http://beeorder;  # app 是 Compose 里的服务名,直接走内网 DNS        proxy_http_version 1.1;        # 透传真实客户端信息,否则应用日志里全是 Nginx 的内网 IP        proxy_set_header Host              $host;        proxy_set_header X-Real-IP         $remote_addr;        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;        proxy_set_header X-Forwarded-Proto $scheme;        proxy_connect_timeout 5s;        proxy_read_timeout    30s;        # 发布瞬间后端正在排空:让 Nginx 换一台重试,而不是把 502 甩给用户        proxy_next_upstream error timeout http_502 http_503;    }    # 健康检查走内网,绝不暴露到公网    location = /healthz {        proxy_pass http://beeorder/actuator/health/readiness;        access_log off;    }}
解读
  • proxy_pass http://app:8080 用服务名直连,靠 Compose 内网 DNS 解析,无需写死 IP
  • X-Real-IP / X-Forwarded-For / X-Forwarded-Proto 三件套,把真实来源和协议告诉应用
  • proxy_next_upstream 是发布期间少有人配的一项:老实例正在排空时,Nginx 会先试别人,而不是直接吐 502
  • 生产还要把证书目录(/etc/nginx/certs)以只读方式挂进 Nginx 容器,用 certbot 或 acme.sh 定期续期
115 / 180

应用侧必须配合打开 server.forward-headers-strategy=framework,原因很直接:默认情况下 Spring Boot 不信任这些代理头。不开的话,应用生成的重定向 URL 会变成 http:// 而不是 https://,request.getRemoteAddr() 拿到的是 Nginx 内网地址,绝对 URL 拼接也会出错。

116 / 180
代码对照
代码yaml
server:  forward-headers-strategy: framework   # 让 Spring 读取并信任 X-Forwarded-* 头  tomcat:    remoteip:      remote-ip-header: X-Forwarded-For      protocol-header: X-Forwarded-Proto
解读

注意:forward-headers-strategy 只有在确信流量一定经过可信代理时才能开。若应用端口直接暴露公网,客户端可以伪造 X-Forwarded-For,反而带来 IP 欺骗风险——上生产必须配合第七节那条「只绑 127.0.0.1」。

117 / 180
小节
十一、上线检查清单:把记忆交给清单
118 / 180

上线出的事,十有八九是「某个该检查的点在紧张时刻被忘掉了」。清单的意义是让 checklist 代替大脑记忆。

119 / 180
原理动画
动图 · 上线前的关键七步
动图 · 上线前的关键七步
120 / 180
对照表
#检查项通过标准
1配置外置无任何环境相关配置写死在 jar 或代码里
2密钥管理数据库/Redis/支付密钥来自 secrets,不在 Git 中
3JVM 参数设置了 MaxRAMPercentage 与 GC,不用默认堆
4日志落盘与轮转容器日志驱动限制大小,应用日志按天滚动
5时区应用、MySQL、Redis 均为同一时区
6健康检查/actuator/health/readiness 可访问且返回 UP
7连接池最大连接数按数据库上限推算并压测验证
8慢查询开关开启慢 SQL 日志(阈值 1s),线上可追溯
9备份策略数据库每日全量 + binlog,且有恢复演练
10回滚方案明确上一个可用镜像 tag,且回滚命令演练过
11监控告警QPS/错误率/P95/连接池/磁盘均有阈值告警
12Actuator 端口隔离/actuator/env、/heapdump 等敏感端点不对外
13数据库迁移DDL 已执行且可重复执行(幂等脚本)
14依赖服务就绪MySQL、Redis 健康后应用才启动
15停机宽限stop_grace_period > timeout-per-shutdown-phase,且 exec 形式入口
16冒烟测试下单→支付→查单主链路人工跑通一遍
17观察窗口上线后 30 分钟内保持值守,随时可回滚
121 / 180
要点

清单不是「越多越好」。真正值得留的,是那些不看清单就一定想不起来、而且错了代价极大的项——第 15 项(宽限期对齐)和第 12 项(Actuator 暴露面)正是这类。

122 / 180
小节
十二、灰度发布与回滚策略
123 / 180
对照表
策略做法优点缺点适用
蓝绿新旧两套环境,切流量回滚秒级资源翻倍单机/关键系统
滚动逐台替换实例资源省新旧版本短暂共存多实例集群
金丝雀 / 灰度先放 5% 流量风险最小需要流量控制能力有网关/服务网格
按用户维度白名单账号先吃新版本能定向验证真实业务样本有偏有内部账号可用
124 / 180
类比

先开一家分店试菜。 新菜直接写进全城 20 家店的菜单,一旦食材有问题就是全站事故;聪明的做法是先在大学城旁开一家分店,用 5% 的客流验证口味、翻台和出餐速度,没问题再铺到其他门店,有问题只关这一家。灰度的本质不是「分批」,而是「让失败的影响面可被限制」。

125 / 180

单机 Compose 场景下,最实用的是镜像版本切换式回滚:把「上一版」的 tag 记下来,出问题切回去即可。

126 / 180
代码对照
代码bash
# 部署新版本:把 tag 写入 .env,再拉起echo "IMAGE_TAG=v1.2.0" >> .envdocker compose pull appdocker compose up -d appdocker compose logs -f --tail=100 app     # 盯一会儿日志# 出问题,回滚到上一版(例如 v1.1.9)—— 只换 tag,不重新构建sed -i 's/^IMAGE_TAG=.*/IMAGE_TAG=v1.1.9/' .envdocker compose pull appdocker compose up -d appcurl -fsS http://127.0.0.1:8080/actuator/health/readiness   # 确认恢复
解读
  • 回滚的本质是「切镜像 tag」,所以每次上线都必须保留上一个可用 tag,并且回滚流程要演练过
  • .env 里只改一行,比改 yml 更不容易出错
  • 回滚后立刻验证健康端点,别把「命令执行成功」当成「服务恢复」
  • 结构与数据要一起想:新 DDL 删了列,回滚镜像也回滚不了数据库——所以 #44 的迁移脚本必须向后兼容(只加列不改语义、先加后删、分两次发布)
127 / 180

灰度期间新旧两个版本并存,最容易出的不是 500,而是「同一笔请求两次落到不同版本」——所以灰度前置条件是:幂等键、状态机、数据库结构三者都能同时容纳两个版本。这个实验演示 5% 流量下两个版本的共存与切回:

128 / 180
内核实验
TeaVM灰度发布:5% 流量的那台新实例未启动
切到 gray:看新版本出隐藏慢查询时,错误率如何被限制在 5% 的流量里,以及回滚为什么只是把权重调回 0
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
129 / 180
坑

healthcheck 的 interval 配太短(比如 2s)而 start_period 又没设,JVM 还没启动完就被判不健康,就会陷入反复重启。先把 start_period 给够(40s 起),再谈检测频率。

130 / 180
小节
十三、压测入门:用数据代替感觉
131 / 180

wrk 适合快速跑出一个「够用的数字」,JMeter 适合做复杂场景(参数化、阶梯加压)。选哪个不重要,关键是压测前先定义目标。

132 / 180

BeeOrder 下单接口的验收目标:

133 / 180
对照表
指标目标解读
QPS≥ 500峰值订单量的 2 倍余量
P95 延迟< 200ms95% 的请求在这个时间内返回
P99 延迟< 500ms长尾不能失控
错误率< 0.1%出现超时或 5xx 即为不合格
库存一致性无超卖压测后对账,账实必须一致
134 / 180

一次典型的下单压测(wrk):

135 / 180
bash
# 12 线程、400 连接、持续 60 秒,POST 下单接口wrk -t12 -c400 -d60s --latency \  -s post-order.lua \  http://127.0.0.1:8080/api/orders
136 / 180

瓶颈排查遵循一条固定路径,从外到内逐层排除:

137 / 180
  • 应用层:线程池是否被打满(Tomcat max-threads),有无锁竞争
  • JVM:GC 是否频繁(jstat -gcutil),堆是否够用
  • 数据库:慢查询日志有没有新增,EXPLAIN 是否走索引
  • 连接池:HikariCP 活跃连接数是否长期等于 maximumPoolSize
138 / 180
说明

压测时 P95 突然抬高,八成不是应用算得慢,而是卡在数据库或连接池。先看连接池的 activeConnections,它往往是最早露馅的指标。

139 / 180

「慢请求与超时」这一条链路最容易被误判成应用问题,实际是被下游拖住。这个实验把五种结局(成功、校验失败、业务异常、数据库故障、慢请求)放在同一条流水线上对比,注意看第几步开始排队、超时在哪一层被截断:

140 / 180
内核实验
TeaVM一个下单请求穿全站:五条分支各自的终点未启动
先跑 happy 建立基线,再切 slow 看请求卡在哪一站、谁先超时;db 那一档解释了第十五节表里 Connection is not available 的由来
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
141 / 180
小节
十四、沙盘:这次上线该切多少流量
142 / 180

「一次切多少」和「老进程怎么退场」是发布节奏里仅有的两个旋钮,也是所有发布事故的现场。下面这个沙盘把两个开关放在一起,每个格子里是同一次压测的六个读数:新版本错误率、P99、被切断的在途请求、脏数据笔数、以及回滚耗时。

143 / 180
沙盘
沙盘发布节奏沙盘:切多少流量 × 老进程怎么退场
运行结果
新版本承接 25 QPS(总 500 QPS 的 5%)
隐藏慢查询暴露:新版本错误率 0.4%,老实例 0%
P99:新版本 1.2s / 老 180ms —— 只有 5% 的用户受影响
readiness 变红,编排把 25 QPS 全部退回老实例
老实例排空在途 80 条,被切断 0 条
脏数据 0 笔 · 回滚耗时 4s(权重调回 0)
这就是灰度的全部价值:故障被限制在 5% 的流量里,你有时间看清、判断、退回去。老实例排空干净,一条请求都没被切。
144 / 180
说明

沙盘里的数字是推演值,结论是硬的:影响面由切流比例决定,脏数据由退场方式决定,两者互相独立、都必须配。灰度不是「大厂才做的事」——单机 Compose 也能用两台 Nginx upstream 权重做 5% 切流,成本只有几行配置。

145 / 180
小节
十五、常见报错速查
146 / 180

下面每一行的「报错原文」都能整段复制去搜索,别意译、别缩写。交付阶段最烦人的地方在于:很多故障不以 Java 异常出现,症状是日志戛然而止或者端口连不上。

147 / 180
对照表
报错原文(片段)现象真实原因30 秒自救
java.lang.ClassNotFoundException: com.beeorder.BeeOrderApplicationrepackage 之后本地能跑,换个命令就找不到主类用 java -cp app.jar 主类名 启动了 fat jar:主类在 BOOT-INF/classes,而不在 jar 根目录,系统 classpath 看不见老老实实 java -jar app.jar(走 JarLauncher);layertools 拆层后用 java org.springframework.boot.loader.launch.JarLauncher
java.lang.NoClassDefFoundError: org/springframework/...(启动几十秒后炸)类找到了,但它依赖的类不在 classpathfat jar 的依赖躺在 BOOT-INF/lib,由 LaunchedClassLoader 加载;手工 unzip 后重新压缩、或 CI 缓存了旧 jar,就丢掉这个结构别手工改包:重新 mvn -B clean package;确认 Main-Class 是 JarLauncher:unzip -p app.jar META-INF/MANIFEST.MF
docker: 'java' is not a command(ENTRYPOINT ["java","-jar","app.jar"] 起不来)容器一起来就退出,日志只有这一行基础镜像是 JRE-less 的 distroless/base 或 alpine 里根本没装 Java;或者 ENTRYPOINT 写成了 ["java","-jar"] 而可执行文件不在 PATHdocker run --rm --entrypoint sh <image> -c 'command -v java' 先看有没有;基础镜像换 eclipse-temurin:21-jre-jammy,distroless 要用 -java 变体并写绝对路径 /opt/java/openjdk/bin/java
java.lang.OutOfMemoryError: Metaspace启动或热部署一段时间后进程自杀,日志里有完整 Java 栈类加载器泄漏:反复 reload、Groovy/脚本引擎动态生成类、或 -XX:MaxMetaspaceSize 设得比实际需要小(容器里常见于限额被压低后没同步调)显式封顶 -XX:MaxMetaspaceSize=256m 并配合 -XX:+HeapDumpOnOutOfMemoryError;查 jcmd <pid> GC.class_stats 或看是不是每轮热重载都在长类
java.lang.OutOfMemoryError: Java heap spaceJava 侧自己报的错,能抓到 hprof堆内真的装不下:集合无界增长、一次查出百万行、缓存没设上限加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp,用 MAT 看支配树;这类失败不会留 exit 137,先分清是谁杀的
容器日志最后一行 Killed,Java 侧一行异常都没有进程被外部处决,无现场、无堆栈总用量(堆 + 元空间 + 线程栈 + 直接内存)超 cgroup 限额,内核直接杀;-Xmx 写死成等于限额是最常见诱因把 -Xmx 换成 -XX:MaxRAMPercentage=75.0;用 docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' 确认;堆外排查靠 NMT + jcmd <pid> VM.native_memory
OOMKilled + Exit Code 137(K8s kubectl describe pod)反复重启,业务日志戛然而止137 = 128 + 9,即 SIGKILL。这是 cgroup 因总用量越限杀进程,跟堆够不够是两件事先算总账:堆 + 200 条 Tomcat 线程栈(约 200MB)+ CodeCache + Netty 直接内存;再谈调比例,别急着加大限额掩盖问题
Web server failed to start. Port 8080 was already in use.本地一启动就退出上一个实例没退干净、IDE 还在跑、或本机另一个服务占了 8080。注意容器场景报的是另一个文案:Bind for 0.0.0.0:8080 failed: port is already allocatedWindows:`netstat -ano \findstr :8080 拿 PID 再 taskkill /PID <pid> /F;Linux/macOS:lsof -i:8080;开发环境直接 -Dserver.port=8081`,生产改 Compose 的宿主侧端口
Healthcheck failed: Read timed out / Compose 里服务长期 unhealthy部署脚本卡住或流水线红,但手工 curl 是通的JVM 冷启动 + 慢初始化(连不上依赖会重试)超过 start_period;或 timeout: 5s 小于一次健康指示器的真实耗时;或 healthcheck 里那个 curl 二进制根本不在镜像里把 start_period 提到 40s 起、timeout 到 5~10s;健康检查打到 readiness 而不是聚合 /health;用 --entrypoint sh 进容器手跑一次那条 test 命令
客户端 Connection refused一次请求都没进去,秒失败没人监听那个端口:容器没起来、EXPOSE 写了但没 -p、或应用启动失败已退出。这是网络层没人接,与应用逻辑无关docker ps 看 PORTS 列有没有 0.0.0.0:8080->8080/tcp;curl -v http://127.0.0.1:8080/actuator/health/readiness 分段看;docker logs 确认进程活着
客户端 Connection reset by peer / 偶发 502请求进去了又被掐断,重试往往就成功对端在请求处理中途退出:老实例被 SIGKILL 硬杀(排空没走完)、Nginx upstream 里还留着已摘除的实例、或 keep-alive 连接被后端先关看是否只在发布瞬间发生 → 补 server.shutdown=graceful 与 stop_grace_period;Nginx 加 proxy_next_upstream error timeout http_502;preStop 先 sleep 2~5s 让流量传播停下来
Connection is not available, request timed out after 30000ms压测一上来就大面积 500,日志里全是这个连接池只有默认 10 条而事务在等外部接口(长事务),或慢查询把连接全占住先查有没有「事务里调远程接口」(#45 第三节);再按数据库 max_connections 与实例数反推 maximum-pool-size,别直接调到 200
UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime (class file version 65.0)服务器第一行就炸本地 JDK 21(class 65)编译,服务器 JRE 是 17(class 61)——典型的构建前提没显式化镜像里锁 FROM eclipse-temurin:21-jre;pom.xml 里锁 <java.version>21</java.version> 与 maven.compiler.release,让 CI 而非机器负责版本
148 / 180
提示

这张表里有三条(Killed、OOMKilled、Connection reset)根本不以 Java 异常的形式出现,症状是日志戛然而止。排查容器问题的第一句命令是 docker inspect <id> --format '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.Status}}',一眼分清「谁杀的」。

149 / 180

表格第 1 行那个 ClassNotFoundException 值得单独留个现场——它最迷惑的地方在于:构建明明是绿的、jar 明明就在手里,报错却像在说「类没打进去」。先别看答案,点出你认为的凶手帧:

150 / 180
报错急救
报错急救ClassNotFoundException: com.beeorder.BeeOrderApplication
fat jar 明明在,主类却说找不到

CI 绿了、镜像也推了,顺手把构建产物拷到服务器,用自写的启动脚本跑:java -cp beeorder.jar com.beeorder.BeeOrderApplication —— 一行没进去,JVM 直接抛 ClassNotFoundException。

Error: Could not find or load main class com.beeorder.BeeOrderApplication
Caused by: java.lang.ClassNotFoundException: com.beeorder.BeeOrderApplication
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:525)
at com.acme.ops.LaunchScript.main(LaunchScript.java:9)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
151 / 180
小节
十六、随堂自测
152 / 180

先来一道热身题,考的是第九节那张决策卡与第八节的探针配置:

153 / 180
随堂自测
随堂自测上线后 MySQL 主从切换抖了 3 秒,K8s 把 6 个 Pod 全部重启,滚动发布同时卡死在健康检查上。查配置发现 liveness 探针打的是默认的 /actuator/health。下列哪种改法真正解决根因?
先自己选一个,选中立刻告诉你对不对
154 / 180

再来一道综合题,把第四、六、十五节串起来:

155 / 180
随堂自测
随堂自测BeeOrder 打成了 app.war,同事用 java -jar app.war 启动成功,你把它拷进外部 Tomcat 的 webapps 目录却得到 404。关于这两种跑法,下列哪项正确?
先自己选一个,选中立刻告诉你对不对
156 / 180
小节
十七、动手练习
157 / 180
小节
第一档 · 照做:跑通一次「带排空的停机」
158 / 180

目标:亲手确认优雅停机真的生效——不是「配了」,而是「日志里看得见」。只需要一台装了 Docker 的机器和一个能跑起来的 BeeOrder 镜像。

159 / 180

第一步,用第七节的 Compose 起栈,确认健康:

160 / 180
bash
docker compose up -ddocker inspect --format '{{.State.Health.Status}}' "$(docker compose ps -q app)"# 预期输出:healthy (若为 starting,等 start_period 的 40s 过去再看)
161 / 180

第二步,造一点在途请求,再停:

162 / 180
bash
# 终端 A:持续打请求,边打边记录响应码while true; do curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' \  http://127.0.0.1:8080/api/orders/1; sleep 0.05; done# 终端 B:显式给 40 秒宽限期docker compose stop --timeout 40 app
163 / 180

第三步,看日志确认三行按顺序出现(少任何一行都要回第八节排查):

164 / 180
text
Commencing graceful shutdown. Waiting for active requests to completegraceful shutdown complete... Tomcat shutdown ...
165 / 180
对照表
观察点期望不期望时先查
终端 A 的响应码全程 200,直到最后一刻才变成 Connection refused出现 5xx/reset → ENTRYPOINT 不是 exec 形式
日志有没有 Commencing graceful shutdown有没有 → server.shutdown=graceful 没生效或信号被 shell 吞了
退出耗时在途多久就等多久,且 < 40s恰好 10s 退出 → 宽限期没传下去(--timeout 写漏)
166 / 180
小节
第二档 · 变式:把探针的分工演出来
167 / 180

目标:用一次人为的数据库故障,亲眼看到 readiness 变红而 liveness 不变红。

168 / 180
bash
# 1. 先把 db 指示器放进 readiness 组(第八节的配置),重启应用docker compose restart app# 2. 记录两个探针的当前状态curl -s http://127.0.0.1:9090/actuator/health/liveness  | python -m json.toolcurl -s http://127.0.0.1:9090/actuator/health/readiness | python -m json.tool# 3. 人为停掉数据库,再看一次:期望 liveness 依然 UP,readiness 变 DOWNdocker compose stop mysqlsleep 20curl -s http://127.0.0.1:9090/actuator/health/readiness   # 期望 "status":"DOWN"curl -s http://127.0.0.1:9090/actuator/health/liveness    # 期望 "status":"UP"# 4. 恢复数据库,确认 readiness 自动回到 UP(这一步就是「摘流量而不是杀进程」)docker compose start mysql
169 / 180

如果第 3 步 liveness 也变红了,说明你把它指到了聚合的 /actuator/health——回去看第九节那段 group 配置。

170 / 180
小节
第三档 · 开放:给 BeeOrder 做一次 30 分钟的发布演练
171 / 180

写一份不超过一页的演练方案,必须回答这五个问题,并且每一问都要有「怎么验证」:

172 / 180
  1. 回滚要多久?谁来下这道命令?(提示:只换 tag,目标 < 60s)
  2. 演练期间故意注入一个「新版本 5% 流量的隐藏慢查询」,你怎么在 5 分钟内发现它?(提示:第十四节沙盘的前两格,以及按版本打 tag 的指标)
  3. 灰度期间一次支付回调先后落到新旧两个版本,幂等还成立吗?依据是什么?(提示:#45 第五节唯一索引兜底)
  4. DDL 是向前兼容的吗?回滚镜像时数据库能不能一起退?(提示:第十一节第 13 项,先加后删、分两次发布)
  5. 哪三个数字是你判断「可以继续扩大灰度」的唯一依据?(建议:错误率、P99、连接池 active 数——和第十二节表格里的一致)
173 / 180

验收标准只有一句:演练必须真的按回滚按钮,不是「我知道怎么回滚」。一个从没被执行过的回滚方案,等同于没有回滚方案。

174 / 180
小节
十八、要点自查:这一篇的六条硬结论
175 / 180
  • 交付 = 把隐性前提显式化:构建前提写进 Dockerfile,运行前提写进 Compose,协作前提写进分支保护与 needs
  • 测试分层按风险分,不按数量分:单元测分支、切片测契约、集成测 SQL 与并发;超卖只能用并发测,覆盖率替代不了断言强度
  • 上线的产物是镜像 tag,不是 jar 也不是源码:没有 CI 验证过的 sha 就没资格部署,回滚只是换一个 tag
  • 优雅停机是三段接力:摘流量 → 排空 → 退出,且两侧宽限期必须对齐,ENTRYPOINT 必须是 exec 形式
  • liveness 不挂依赖,readiness 才挂:配反的代价是一次数据库抖动引发全站重启风暴
  • 发布节奏只有两个旋钮:切多少流量决定影响面,怎么退场决定脏数据量,两者必须同时配
176 / 180

回头看,BeeOrder 的上线不是某一篇的功劳:

177 / 180
  • #1 装 JDK 时你以为在装软件,其实是在理解「字节码 + 虚拟机」这层抽象
  • #6~#14 学 IoC/AOP,是为了让 #45 的下单服务靠一个注解就拿到事务能力
  • #21~#25 打下的配置与 REST 契约,是 #44 API 契约的现实基础
  • #28 的连接池、#31 的事务内核、#32 的缓存,全部在第十三节压测和第十一节清单里复发
  • #33~#37 的测试与部署,加上这一篇的 CI/CD,把「能跑」升级成「敢放上生产」
178 / 180

最后用 #45 那道题收束整个系列:下单与扣库存必须同一事务(要么都成功要么都回滚),记库存流水也在同一事务(它是账的一部分),但发通知、加积分必须独立提交——通知失败不该把订单回滚掉。这个边界判断,贯穿了从 #31 事务传播到 #45 幂等设计、再到本篇灰度回滚的每一处取舍。

179 / 180
决策
决策小团队做 BeeOrder 这样的单体,要不要一开始就上 Kubernetes?
180 / 180
总结

交付这一篇把整个系列闭环了。三句话记住:测试分层,是为了让每类风险都有人守;CI 流水线,是为了让守门这件事不依赖人的自觉;生产编排、探针与上线清单,是为了让「能跑」变成「可回滚、可观测、可信任」。从 #1 的 java -version 到现在的一条 docker compose up -d,你交付的不再是一段代码,而是一套能被别人接手、能被机器验证、能在出事时退回来的系统——这才是一个后端工程师真正的「上线」。