ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微服务容器化实战:从Docker镜像构建到Kubernetes部署

微服务容器化实战:从Docker镜像构建到Kubernetes部署

微服务这个词喊了好几年,但真正动手把一个单体应用拆成微服务,再用 Docker 和 Kubernetes 容器化部署起来,中间的路远比想象中长。我前段时间完整经历过这个过程:从最初在本地用 docker-compose 跑通三个服务,到最终在一个模拟生产环境的 Kubernetes 集群上完成发布、扩容、滚动更新和日志采集。这篇文章把这套实战路径完整写下来,包括拆服务时怎么划边界、镜像怎么构建才够小够安全、K8s 上有哪些配置是避不开的、以及我们实际踩过的一堆坑。内容默认你已经了解 Docker 和 K8s 的基本概念,但还没真正把它们串起来做成一套可用的微服务部署体系,那你很适合读下去。

1. 为什么微服务容器化会变成一道必答题

先说业务背景。我参与的是一个线上商城的模拟项目,最开始就是典型的单体架构:一个 Spring Boot 应用负责用户登录、商品查询、下单、支付回调,再加一个 MySQL 数据库。开发阶段一切顺利,直到开始做活动大促,问题集中爆发。

第一次压测就发现,商品查询接口的负载把整个应用拖垮,用户下单也跟着超时。单体应用最大的问题不是性能不够,而是所有功能耦合在一起,你没法只扩容订单模块。哪怕你在订单模块前加了一层缓存,流量一上来,整台机器的线程池、内存、CPU 还是会被所有接口共享消耗。最后能做的只有整体加机器,成本极高。

这就是微服务拆分的第一驱动力:让最热门的模块可以独立扩缩容。但拆分只是第一步,服务拆完之后,如何打包、分发、部署、运维,又成了新问题。你不可能继续在每个服务器上手工装 JDK、配置环境变量、拷贝 Jar 包。这时候容器化自然就成了首选方案。

Docker 解决的是“打包和交付”的问题。把服务代码、运行环境、依赖库、配置文件全部打进一个镜像,镜像在哪儿跑,行为都一致。Kubernetes 解决的是“编排和调度”的问题——几十个服务实例放在哪些机器上、挂了怎么重启、流量怎么分发、如何滚动升级,这些全部交给集群自动处理。

Docker 和 Kubernetes 的组合,本质上把你从“运维一堆服务器”变成“维护一个集群”。这个转变对团队的要求是:你不再关心某台机器上跑了什么进程,而是声明“我想要这个服务的三个副本”,剩下的由平台保证。

提示:如果你的服务规模只有两三个,流量一天几万请求,直接用 docker-compose 就够。强行上 Kubernetes 可能没收益反而增加维护成本。微服务拆分和容器化要跟着业务复杂度走,不要为了技术而技术。

2. 拆分微服务时的边界判断,比技术本身更值得花时间

很多人拿到微服务拆分任务,第一反应是照着原有的模块代码直接拆文件。但代码层面的“模块”和运行层面的“服务”根本是两码事。服务拆分本质是数据边界、调用边界、团队协作边界三者的权衡。

我们当时踩过一个典型的坑:为了追求“服务划分细致”,把用户信息、用户积分、用户地址拆成了三个服务。结果发现,几乎每个页面请求都需要同时调这三个服务,业务代码里充斥着跨服务调用,性能反而下降了一个量级。后来才意识到,拆服务要看“业务变更频率”和“数据访问模式”,而不是看“实体数量”。

我当时总结了一套比较实用的判断标准,分享出来:

  • 按变更频率拆:经常一起变的放在一个服务里。用户基本信息、头像、昵称是高频变更项,而用户积分余额这类数据变动频率也高但数据语义完全不同,可以考虑拆开。
  • 按数据归属拆:一个微服务应该拥有自己独立的数据库 Schema,不允许其他服务直接访问它的表,只能通过 API 访问。这条规则是硬约束。
  • 按流量特征拆:像商品查询这种读多写少、流量巨大的服务,拆出来后可以做多级缓存、独立扩容;而订单服务写多读少,事务复杂,单独拆出来有利于保证一致性。
  • 按团队边界拆:大团队里,一个服务由一个小组长期维护,减少跨团队沟通成本。独立部署权限是这个判断里的加分项。

我们最终把商城项目拆成了六个服务:用户服务、商品服务、库存服务、订单服务、支付服务、消息中心。每个服务独立数据库,服务之间只通过 REST API 或消息队列通信。

拆分之后紧接着的问题是服务间通信的复杂度。订单服务下单时要扣库存,如果同步调用库存服务的接口,一旦库存服务响应慢,订单接口的响应时间也会跟着变长。我们当时的处理方案是:核心链路用同步接口,非核心逻辑走消息队列异步化。比如下单完成后发送通知消息,这个动作放进 MQ,由消息中心消费后再推送,不让它占用下单接口的时间。

这个决策直接影响后续容器化的形态:服务数量定了,每个服务的资源需求、副本数、依赖关系也定了,Docker 镜像怎么构建、K8s 上怎么编排就都有了依据。

3. Docker 镜像构建:从“能跑”到“能上生产”的四个阶段

服务拆分完成后,接下来最实际的工作就是给每个服务写 Dockerfile 并构建镜像。我们一开始写的 Dockerfile 非常简单,就是官方文档里那种“全量 JDK + 复制 Jar 包”的写法:

FROM openjdk:8-jdk COPY target/order-service.jar /app/order-service.jar ENTRYPOINT ["java", "-jar", "/app/order-service.jar"]

这种镜像跑起来完全没问题,但到了真正要上 Kubernetes 的时候,问题就来了。

3.1 镜像体积失控会拖慢发布速度

当时其中一个服务依赖了大量第三方库,构建出的镜像体积接近 700MB。每次更新版本,镜像推送到私有仓库再拉取到集群节点,要花好几分钟。测试环境频繁发布的时候,大家的时间全耗在了等待镜像推送和拉取上,这非常影响迭代效率。

镜像体积直接决定了发布速度。我们在真实项目里被这个坑折磨了一周,后来才正经研究起了镜像瘦身。核心思路是“多阶段构建”——构建阶段用完整的基础镜像,运行阶段换成精简基础镜像,只复制构建产物进去:

FROM maven:3.8-jdk-8 AS builder COPY . /workspace WORKDIR /workspace RUN mvn clean package -DskipTests FROM openjdk:8-jre-alpine COPY --from=builder /workspace/target/order-service.jar /app/order-service.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/order-service.jar"]

换掉基础镜像和构建方式之后,同样的服务镜像从 700MB 一下降到 180MB 左右,效果立竿见影。如果再配合 jlink 精简 JDK 模块,还能进一步压到 100MB 以内。不过对多数团队来说,用 openjdk 精简版本已经够用了,不必追求极端。

3.2 .dockerignore 不让本地文件混进镜像

另一个常见问题是我犯过的低级错误。之前某个服务的 Dockerfile 里没有 .dockerignore 文件,构建时把整个项目目录,包括 target、.git、日志文件全部打进了构建上下文。本地构建还好,CI 环境一旦构建,几百兆的非必要文件全部打包上传,构建效率被拖慢。

在每个服务根目录放好 .dockerignore 是最简单也最容易被忽略的优化:

target/ .git/ *.log .idea/ *.iml

3.3 镜像标签不能用 latest

在开发和测试阶段,随手打一个 latest 标签好像没有太大问题。但真正开始用 Kubernetes 做滚动更新的时候,latest 标签带来的“镜像不可变”问题就暴露了——你没法确认线上跑的到底是不是这个镜像,回滚更是无从谈起。

我们的规范是每个镜像自带版本号,并且和 Git 提交号关联。在 CI 流水线里,镜像标签直接用构建号生成,例如 order-service-v1.2.0-build-145。这样线上每个 Pod 跑的具体版本一目了然,回滚时只需要改一下部署文件里的镜像标签就可以。

3.4 基础镜像的安全扫描

云原生环境下,镜像安全是一个不可回避的问题。openjdk:8-jdk 这种老镜像里的基础组件可能存在大量已知漏洞,这不符合安全评审的要求。

我们后来的做法是用 trivy 这类工具,在 CI 阶段对镜像做一次漏洞扫描,高危漏洞直接让流水线失败。虽然会有一些误报和需要升级基础镜像的额外工作,但和线上安全问题比,这个成本完全值得。

4. Kubernetes 集群落地:托管平台选择,网络与存储也要一次性想好

镜像构建完了,就要考虑怎么把它们调度起来。我们在选型上花了不少时间——主要纠结于自建集群和托管集群之间的取舍。

4.1 自建还是托管,关键看团队的运维能力

自建 Kubernetes 集群是非常灵活的选择,你可以在任意云服务器甚至物理机上部署集群,完全掌控网络插件、存储插件、Ingress 控制器等组件的版本和配置。代价则是:etcd 的备份恢复、控制平面的高可用、证书轮换、网络插件异常排查,全要自己扛。

托管集群则把控制平面和大部分节点运维交给云厂商,你只需要关心工作节点和业务运行。中小团队和业务专注型的项目,托管集群的收益明显更高。我们选择的是托管集群方案——把精力省下来专注在业务服务上。

注意:无论选择哪种方式,etcd 备份都不能省。托管集群的 etcd 虽然由平台维护,但如果你在集群里存储了自定义资源(比如某些 Operator 的状态),要考虑如何恢复。建议至少每周做一次集群资源备份。

4.2 网络模型:NodePort 直连不适用于生产环境

Kubernetes 默认有三种 Service 类型:ClusterIP、NodePort、LoadBalancer。很多新手第一次部署服务时,图快直接用 NodePort,外部访问给节点 IP 加端口号。这在测试环境没问题,但生产环境里 NodePort 的缺陷很明显:它的访问入口是具体某台节点,节点挂了访问就断了;而且端口管理混乱,几百个服务根本分不清哪个端口对应哪个服务。

我们最终采用的标准方案是:Service 统一用 ClusterIP 类型,集群前面加一个 Ingress 控制器作为统一的流量入口,通过域名路径和虚拟主机来区分服务。这样外部流量的入口收敛为几个 Ingress IP,内部服务用 ClusterIP 互访,结构清爽得多。

apiVersion: v1 kind: Service metadata: name: order-service spec: selector: app: order-service ports: - port: 8080 targetPort: 8080

4.3 存储选型:数据库是否应该容器化

这是容器化实战里争议最大的一块。我们的原则很简单:有状态服务的存储尽量不碰容器本地存储。MySQL、Redis、ES 这类有状态组件,要么使用外部托管数据库服务,要么采用 StatefulSet + 云盘动态供给的方式部署。

在模拟项目里,数据库我们直接用了外部的托管实例,K8s 集群里的微服务只负责连接它。理由很直接:如果数据库这个最底层状态组件也依赖集群内的 PVC,那么集群节点出问题时,数据恢复的复杂度会成倍上升。而使用托管实例之后,数据库的备份、高可用、监控都由平台负责,我们只需要在应用层做好连接重试。

如果你确实需要把数据库放到集群内,一定要用 StatefulSet 加动态存储卷(比如云盘),并开启存储卷的快照备份。完全不建议直接把数据写在宿主机目录里,那等同于是手动放弃 Kubernetes 的调度和迁移能力。

4.4 资源请求与限制:给每个 Pod 定好“预算”

大部分刚开始上 K8s 的人都容易踩同一个坑——部署文件里不写 resources 字段。你可能会觉得这没什么关系,实际上等于把所有 Pod 当成“无上限的资源消耗者”,一个服务的异常流量会把节点 CPU 和内存全部吃满,其他服务全部卡死。

我们后来在部署规范里强制要求每个工作负载必须写清楚 requests 和 limits:

resources: requests: cpu: 500m memory: 512Mi limits: cpu: "1" memory: 1Gi

解释一下这两个字段的意义:requests 是调度依据,用来告诉调度器“这个 Pod 至少需要多少资源”;limits 是运行时限制,是硬上限。压测过程中我们发现,如果 limits 不设置,某些 Java 服务的堆内存会默认占满整台节点;如果 requests 不设置,调度器可能把所有 Pod 堆到同一台机器上,导致资源分布极不均衡。

不过有一点要注意,内存高水位警戒线其实主要靠 JVM 参数配合。K8s 的 limits 只限制容器可用内存,如果进程超过会被 OOMKilled。JVM 启动参数里建议单独设置:

- -Xms512m - -Xmx512m - -XX:+UseContainerSupport

UseContainerSupport 这个参数在 JDK 8u191+ 之后默认开启,它让 JVM 自动识别容器内存限制,避免出现“明明 limits 只有 1G,JVM 却按宿主机内存设置堆大小”的问题。

5. 服务编排配置:Deployment、Service、Ingress 三者的配合逻辑

Kubernetes 上部署一个微服务,最少涉及的资源对象是 Deployment、Service 和 Ingress。初学者往往只关注 Deployment 本身,忽略了三者的配合,线上链路跑通才算真正理解编排。

5.1 Deployment 负责“副本和更新”,Service 负责“寻址和负载均衡”

Deployment 是工作负载的声明,它创建出 ReplicaSet,再由 ReplicaSet 管理固定的 Pod 副本数。我们一般不必直接操作 Pod,只要改 Deployment 的副本数、镜像版本或资源配置,Deployment 自动完成滚动更新和故障恢复。

Service 则是 Pod 之上的一个稳定访问入口。Pod 的 IP 是动态的,随时可能因为调度变化而更换,但 Service 的虚拟 IP 和 DNS 名称是稳定的。集群内其他服务通过这些 Service 名互相访问。

滚动更新时要特别注意strategy配置。生产环境建议用 RollingUpdate 策略,并分配合适的 maxSurge 和 maxUnavailable:

strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0

这个配置的含义是:更新时先增加一个临时 Pod,保证始终有一个旧版本 Pod 还在服务;等新 Pod 就绪后再关闭旧 Pod。这样整个更新期间服务不中断。maxUnavailable 设为 0 意味着不主动杀掉旧 Pod,避免流量真空窗口。

5.2 探针配置:没有 readinessProbe 的滚动更新等于开盲盒

我们第一次做滚动更新的时候就出事过:新版本代码有 bug,启动时间比预期长很多,但 K8s 并不知情。它看到新 Pod 进程起来了,就把流量切进去,结果大量请求直接打到还没准备好的服务上,接口 500 一片。

正确做法是给每个服务配置三种探针:

  • startupProbe:用于慢启动应用,判断进程是否成功启动,给 JVM 类服务足够的初始化时间。
  • readinessProbe:表示应用是否“就绪”,是否可以从 Service 接收流量。探针失败时,POD 会被从 Service 的后端列表中剔除,但容器不会重启。
  • livenessProbe:表示应用是否“存活”,探针失败会杀掉容器并重新创建,用于避免死锁状态的服务占着资源不干活。

对 Java 微服务来说,readinessProbe 用 HTTP 端口检查最直接:

readinessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 20 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 60 periodSeconds: 15 failureThreshold: 3

经验:不要把 Liveness 探针和 Readiness 探针配成同一套参数。如果只是数据库暂时不可用,应用其实还是“活着”的,不该触发重启;但此时它无法处理请求,Readiness 应该把它摘掉。如果把 Liveness 的阈值设太激进,数据库抖动会导致频繁重启,整个服务雪崩。

5.3 Ingress:对外流量的统一入口

Ingress 承担的是“外部域名 -> 集群服务”的映射。我们部署了一个 Nginx Ingress Controller,通过配置 Ingress 资源把不同路径分发到不同 Service:

apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: mall-ingress spec: rules: - host: api.mall.example.com http: paths: - path: /user pathType: Prefix backend: service: name: user-service port: number: 8080 - path: /product pathType: Prefix backend: service: name: product-service port: number: 8080

这种配置的优点是服务之间的路由规则一目了然,需要调整时直接改 Ingress 资源再 apply 一次就行。

5.4 优雅停机:让旧实例退场时不丢请求

这是很多人容易忽略的细节。默认情况下,Pod 被删除时 K8s 会先发 SIGTERM 信号给容器,再等待 grace period 后强制杀掉。但 Java 应用的 Spring Boot 容器如果不做特殊处理,默认收到 SIGTERM 后可能直接退出,导致正在处理的请求被中断。

我们的处理是在 application.yml 中开启优雅停机:

server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s

同时 Deployment 的 spec.terminationGracePeriodSeconds 设为 60 秒,保证旧 Pod 在停止前有时间处理完存量请求。这套组合配置下来,滚动更新期间的请求几乎没有任何可见的失败。

6. 配置管理、服务发现与可观测性:云原生部署的三块拼图

镜像一旦发布到集群里,就不再是你本地那套“改配置重启”的开发模式。配置怎么管理、服务之间怎么找到对方、出了问题怎么看日志和指标,这三件事决定了这个集群能不能真正跑起来。

6.1 ConfigMap 和 Secret 的拆分工

一个服务通常有环境差异配置,比如数据库地址、日志级别、缓存开关。这些不该打进镜像,而是通过 ConfigMap 注入。Secret 则专门放敏感信息,比如数据库密码、密钥、证书。

我们的用法是:每个服务的部署文件里引用 ConfigMap 和 Secret,而不是把配置直接写死在 Deployment 里。这样测试环境和生产环境的镜像完全一致,环境差异只靠不同的 ConfigMap 和 Secret 区分。

apiVersion: v1 kind: ConfigMap metadata: name: order-service-config data: application.yml: | server: port: 8080 spring: datasource: url: jdbc:mysql://mysql-host:3306/order_db --- apiVersion: apps/v1 kind: Deployment metadata: name: order-service spec: template: spec: containers: - name: order-service image: registry.example.com/order-service:v1.2.0-build-145 volumeMounts: - name: config mountPath: /app/config readOnly: true volumes: - name: config configMap: name: order-service-config

修改 ConfigMap 后不会自动生效到已有 Pod。如果需要调整配置并重新加载,需要重新发布一次,或者用一些配置热加载的中间件方案。这一点要提前跟团队讲清楚,避免误以为改掉 ConfigMap 后线上马上生效。

6.2 服务发现:K8s 的 DNS 就是天然的服务注册中心

微服务架构里服务间通信需要解决“找到对方地址”的问题。传统方案是搭注册中心(比如 Nacos、Eureka),但在 Kubernetes 里有一个更简单和可靠的选择——直接利用 K8s 内部的 DNS 机制。

同一个命名空间下,服务 A 可以通过http://order-service:8080直接访问服务 B,K8s 自带的 CoreDNS 会完成自动解析,Service 的负载均衡也会把请求转发到任何一个健康的 Pod。这比引入独立的注册中心组件省下不少维护成本,也少了一个全局性的高可用组件。

如果你的微服务已经在用 Nacos 这类注册中心,也不冲突,可以共存。但我们的实际经验是,在 K8s 环境里优先使用 Service DNS,业务代码不需要依赖任何注册中心 SDK,逻辑更干净。

6.3 日志:必须做到统一采集,而不是 ssh 进 Pod 看日志

容器环境的显著特点是 Pod 会随时重建,如果还靠 “kubectl logs” 或者登录机器翻日志文件,出了问题你会非常被动。

我们采用的标准方案是:应用日志直接写标准输出 stdout/stderr,然后通过日志采集器(例如 fluent-bit 或 filebeat)以 DaemonSet 方式部署在每个节点上,采集容器日志并发送到统一的日志平台,比如 ElasticSearch 或 Loki。

配合上链路追踪工具(比如 SkyWalking、Jaeger),排查问题时的路径就清晰了:日志平台看错误堆栈,链路追踪看整个调用链的耗时分布,指标工具看 CPU、内存、QPS 的变化趋势。

6.4 监控告警:不只盯 CPU 和内存,业务指标更关键

K8s 自带的 metrics-server 只能提供基础的 CPU 和内存数据,对于判断业务是否健康远远不够。我们配合 Prometheus 采集 Spring Boot Actuator 暴露的指标,再通过 Grafana 展示,配置了三个梯度的告警:

  • 基础设施指标:节点 CPU 超过 80%、Pod 被重启、镜像拉取失败。
  • 应用指标:接口响应时间 P99 超过阈值、错误率超过 1%、线程池活跃线程数过高。
  • 业务指标:订单创建失败数、支付回调堆积数、MQ 消费积压量。

告警配置的原则是“少而准”。告警太多容易让人麻木,最后真正的重要信号反而被淹没。我们实际运营下来,真正有价值的高频告警其实集中在三块:MQ 消费积压、接口 P99 变慢、Pod 频繁重启。

7. 部署实操:从镜像构建到滚动发布的一次完整流程

这里我把一次完整的发布流程完整写出来,照做基本就能跑通一套流程。假设你的代码改动已经 push 到远程仓库,目标是发布到 Kubernetes 集群。

第一步,构建镜像并推送到私有镜像仓库:

docker build -t registry.example.com/order-service:v1.2.0-build-145 . docker push registry.example.com/order-service:v1.2.0-build-145

第二步,更新 Deployment 中的镜像版本并应用:

kubectl set image deployment/order-service order-service=registry.example.com/order-service:v1.2.0-build-145 -n production

也可以改 yaml 后用kubectl apply -f deployment.yaml,效果一致。

第三步,观察滚动更新的进度:

kubectl rollout status deployment/order-service -n production

这个命令会阻塞等待,直到新 Pod 全部就绪、旧的 Pod 全部销毁。如果出现问题可以随时中断回滚。

第四步,确认服务状态和日志:

kubectl get pods -n production -o wide kubectl logs deployment/order-service -n production --tail=200

如果一切正常,可以再跑一轮接口巡检,确认关键链路无误。

回滚的命令也要记住:

kubectl rollout undo deployment/order-service -n production

这个命令会回到上一个版本的 ReplicaSet,整套机制帮我们在一次因为配置错误导致的线上事故里,只用了不到半分钟就恢复了服务。这也是容器化和 K8s 相比传统部署最大的收益——回滚动作不再依赖运维手工替换包,而是一次声明式的状态切换。

8. 踩坑记录:四个高频问题,按真实排查链路复盘

这一节把我们在实践中遇到的几个高频问题完整复盘,包括现象、排查过程和最终根因,方便你之后遇到类似情况时少走弯路。

8.1 Pod 一直处于 CrashLoopBackOff,日志却看不到有效堆栈

场景是某个服务部署到测试集群后,Pod 反复重启,kubectl logs 只能看到进程启动即退出的信号,没有任何异常堆栈。最开始我们以为是代码问题,反复回看启动日志也没发现线索。

完整排查链路是:

  1. 查看 Pod 状态和事件:kubectl describe pod xxx,发现最后一条事件是 “Container terminated with exit code 137”。
  2. 退出码 137 是 SIGKILL,说明进程是被强制杀掉的,不是自身异常退出。结合资源限制看,最可能的原因是内存超过 limits。
  3. 查看 Deployment 的 limits 配置,发现内存上限只有 256Mi,而服务启动时 JVM 初始化就需要 300M 以上。
  4. 调整 limits 和 JVM 堆参数后,Pod 恢复正常。

事后总结:Java 微服务在容器里启动内存消耗远大于平稳运行,配置 limits 时一定要把启动阶段的临时开销考虑进去。可以先不设 limits,观察实际内存峰值,再留出 20%-30% 余量设定正式值。

8.2 滚动更新时大量请求报 503,问题出在探针参数不合理

我们当时做滚动更新,服务几乎没有中断,但网关层开始大量出现 503。开始以为是 Ingress 配置问题,后来才发现是探针参数太激进。

排查链路是这样的:

  1. 查看 Ingress Controller 日志,发现上游连接失败的记录,错误指向后端服务。
  2. 查看 Service 的后端 Pod 列表,发现偏移中同时包含新旧版本 Pod。
  3. 观察新 Pod 状态,一直处于未就绪状态,但旧 Pod 已经被逐渐销毁。
  4. 进一步查看 readinessProbe 配置,发现failureThreshold: 1、periodSeconds: 3——应用启动时 JVM 还没完全初始化,连续三次探针失败,直接把新 Pod 判为不可用。
  5. 调整配置,initialDelaySeconds 给足 20 秒,periodSeconds 调成 10 秒,问题消失。

这次血的教训告诉我们,探针不是越严格越好,它应该符合应用的真实启动模型。不同服务的启动速度不一样,必须针对每个服务自行校准。

8.3 Service 解析正常但跨服务调用超时,最后定位到网络策略

有一次订单服务调用用户服务,偶尔会出现偶发性超时。单独 curl 用户服务的 Pod IP 是通的,但从订单服务所在节点访问时经常超时。

排查链路:

  1. 先从订单服务的 Pod 里直接 curl 用户服务的 ClusterIP,正常。
  2. 再从订单服务所在节点执行同样操作,异常。
  3. 对比节点网络和集群 CNI 插件信息,发现节点之间的大包(MTU 相关问题)传输异常,小包正常。
  4. 检查网络插件版本和内核参数,确认是 MTU 协商不一致导致跨节点大包被丢弃。
  5. 将网络插件的 MTU 配置调整为与实际网络一致的数值后,问题解决。

这类网络问题在云原生环境中比较典型,现象隐蔽、排查链路长。建议团队提前准备网络排查的常用工具集,比如 ping、traceroute、nc、ip route、ethtool,以及熟悉 CNI 插件的基本工作原理。

8.4 镜像安全扫描误报导致发布阻塞,能分辨“必须处理”和“可以忽略”

安全扫描策略上线后,CI 频繁失败。排查原因时发现,绝大多数告警来自 Java 基础镜像中的老版本组件漏洞,而这些组件在实际运行环境中并没有被利用的路径。

我们当时的处理思路是:

  1. 扫描结果按“可直接利用”和“需要组合条件才能利用”分类。
  2. 不可利用的漏洞加入白名单,附上解释说明,避免 CI 反复报警。
  3. 对于确实能被利用的高危漏洞,优先升级基础镜像版本或替换精简镜像,而不是每台机器手工修补。

这个环节虽然繁琐,但必须沉淀为团队规范和知识库,不然每次上线前都要耗时沟通。

9. 流水线化:把构建、扫描、发布串成一条可控链路

到这里,镜像、K8s 编排、配置、探针这些“零件”基本齐了。真正让整套体系稳定运转的是把它们串成自动化的发布流水线。

我们的流水线分为几个阶段,核心逻辑可以照搬:

  1. 代码提交触发构建:Git 仓库收到 push 后,CI 平台拉取代码。
  2. 单元测试与静态检查:跑测试用例,检查代码规范和潜在 bug。
  3. 构建镜像:用多阶段 Dockerfile 构建出带版本号的镜像。
  4. 安全扫描:扫描镜像漏洞,高危则中断。
  5. 推送镜像:推送到私有镜像仓库,并记录镜像标签和 Git 提交号的对应关系。
  6. 更新 K8s 清单:更新部署仓库里的镜像标签,K8s 集群内通过 GitOps 或定时同步的方式触发重新部署。
  7. 滚动更新与验证:发布完成后自动跑冒烟测试,确认关键接口正常。如果失败则自动回滚。

实现整条流水线的方式很多,技术栈也很多,核心在于把“上线前必须做的动作”都固化下来。人工反复检查配置、登录服务器改部署,一旦并发发布多个服务,出错的概率一定飙升。

还有一点我认为比工具更重要:环境和版本的可追溯性。每条发布记录都对应一个镜像标签、一份部署清单、一组配置文件和一轮测试结果。线上出了任何问题,从时间点反查发布记录,第一轮定位就能缩小到具体变更,这会省下巨量的沟通成本。

10. 从部署完成到稳定运行,还有几件事值得长期坚持

容器化和 K8s 跑起来只是起点,真正让系统稳定运行是个持续优化的过程。根据我们运行了几个月的体会,有几件事值得长期坚持。

第一件事是每两周做一次发布复盘。看这个周期里哪些服务发布最频繁,哪些发布导致过故障,哪些配置是手滑改错的。复盘不是为了追责,而是把频繁出错的操作做成自动化校验。比如因为 YAML 缩进问题出过错,就在流水线里加一个格式校验步骤。

第二件事是持续降低镜像体积。镜像体积直接影响发布速度和存储成本。每个季度抽时间看看有没有可以换用更精简基础镜像的服务,JVM 参数有没有必要再微调。这些优化单独做没有惊天动地的效果,三个月累积下来发布效率的提升非常明显。

第三件事是给团队做一次 K8s 知识普及。部署体系越复杂,开发人员的操作习惯越关键。项目里至少让每个开发都清楚:改配置要改 ConfigMap 而不是进容器改、看日志去日志平台而不是 kubectl exec 然后 tail、滚动更新后要先看探针状态再走人。

最后说说我对“云原生部署”的理解。Docker 和 Kubernetes 是工具,但真正改变的是应用的生命周期管理方式——从“一台机器上跑一个进程”变成了“一个声明式的集群里调度无状态工作负载”。这套体系搭建起来之后,开发、测试、生产环境的差异被压缩到几乎为零,发布变得快速而可回滚,扩容不再需要手工准备机器。整个过程虽然踩了不少坑,但回头看,这笔投入非常值得。如果这篇文章能帮你少走几步弯路,我会非常高兴。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进