微服务这个词喊了好几年,但真正动手把一个单体应用拆成微服务,再用 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/ *.iml3.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: 80804.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:+UseContainerSupportUseContainerSupport 这个参数在 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 只能看到进程启动即退出的信号,没有任何异常堆栈。最开始我们以为是代码问题,反复回看启动日志也没发现线索。
完整排查链路是:
- 查看 Pod 状态和事件:
kubectl describe pod xxx,发现最后一条事件是 “Container terminated with exit code 137”。 - 退出码 137 是 SIGKILL,说明进程是被强制杀掉的,不是自身异常退出。结合资源限制看,最可能的原因是内存超过 limits。
- 查看 Deployment 的 limits 配置,发现内存上限只有 256Mi,而服务启动时 JVM 初始化就需要 300M 以上。
- 调整 limits 和 JVM 堆参数后,Pod 恢复正常。
事后总结:Java 微服务在容器里启动内存消耗远大于平稳运行,配置 limits 时一定要把启动阶段的临时开销考虑进去。可以先不设 limits,观察实际内存峰值,再留出 20%-30% 余量设定正式值。
8.2 滚动更新时大量请求报 503,问题出在探针参数不合理
我们当时做滚动更新,服务几乎没有中断,但网关层开始大量出现 503。开始以为是 Ingress 配置问题,后来才发现是探针参数太激进。
排查链路是这样的:
- 查看 Ingress Controller 日志,发现上游连接失败的记录,错误指向后端服务。
- 查看 Service 的后端 Pod 列表,发现偏移中同时包含新旧版本 Pod。
- 观察新 Pod 状态,一直处于未就绪状态,但旧 Pod 已经被逐渐销毁。
- 进一步查看 readinessProbe 配置,发现
failureThreshold: 1、periodSeconds: 3——应用启动时 JVM 还没完全初始化,连续三次探针失败,直接把新 Pod 判为不可用。 - 调整配置,initialDelaySeconds 给足 20 秒,periodSeconds 调成 10 秒,问题消失。
这次血的教训告诉我们,探针不是越严格越好,它应该符合应用的真实启动模型。不同服务的启动速度不一样,必须针对每个服务自行校准。
8.3 Service 解析正常但跨服务调用超时,最后定位到网络策略
有一次订单服务调用用户服务,偶尔会出现偶发性超时。单独 curl 用户服务的 Pod IP 是通的,但从订单服务所在节点访问时经常超时。
排查链路:
- 先从订单服务的 Pod 里直接 curl 用户服务的 ClusterIP,正常。
- 再从订单服务所在节点执行同样操作,异常。
- 对比节点网络和集群 CNI 插件信息,发现节点之间的大包(MTU 相关问题)传输异常,小包正常。
- 检查网络插件版本和内核参数,确认是 MTU 协商不一致导致跨节点大包被丢弃。
- 将网络插件的 MTU 配置调整为与实际网络一致的数值后,问题解决。
这类网络问题在云原生环境中比较典型,现象隐蔽、排查链路长。建议团队提前准备网络排查的常用工具集,比如 ping、traceroute、nc、ip route、ethtool,以及熟悉 CNI 插件的基本工作原理。
8.4 镜像安全扫描误报导致发布阻塞,能分辨“必须处理”和“可以忽略”
安全扫描策略上线后,CI 频繁失败。排查原因时发现,绝大多数告警来自 Java 基础镜像中的老版本组件漏洞,而这些组件在实际运行环境中并没有被利用的路径。
我们当时的处理思路是:
- 扫描结果按“可直接利用”和“需要组合条件才能利用”分类。
- 不可利用的漏洞加入白名单,附上解释说明,避免 CI 反复报警。
- 对于确实能被利用的高危漏洞,优先升级基础镜像版本或替换精简镜像,而不是每台机器手工修补。
这个环节虽然繁琐,但必须沉淀为团队规范和知识库,不然每次上线前都要耗时沟通。
9. 流水线化:把构建、扫描、发布串成一条可控链路
到这里,镜像、K8s 编排、配置、探针这些“零件”基本齐了。真正让整套体系稳定运转的是把它们串成自动化的发布流水线。
我们的流水线分为几个阶段,核心逻辑可以照搬:
- 代码提交触发构建:Git 仓库收到 push 后,CI 平台拉取代码。
- 单元测试与静态检查:跑测试用例,检查代码规范和潜在 bug。
- 构建镜像:用多阶段 Dockerfile 构建出带版本号的镜像。
- 安全扫描:扫描镜像漏洞,高危则中断。
- 推送镜像:推送到私有镜像仓库,并记录镜像标签和 Git 提交号的对应关系。
- 更新 K8s 清单:更新部署仓库里的镜像标签,K8s 集群内通过 GitOps 或定时同步的方式触发重新部署。
- 滚动更新与验证:发布完成后自动跑冒烟测试,确认关键接口正常。如果失败则自动回滚。
实现整条流水线的方式很多,技术栈也很多,核心在于把“上线前必须做的动作”都固化下来。人工反复检查配置、登录服务器改部署,一旦并发发布多个服务,出错的概率一定飙升。
还有一点我认为比工具更重要:环境和版本的可追溯性。每条发布记录都对应一个镜像标签、一份部署清单、一组配置文件和一轮测试结果。线上出了任何问题,从时间点反查发布记录,第一轮定位就能缩小到具体变更,这会省下巨量的沟通成本。
10. 从部署完成到稳定运行,还有几件事值得长期坚持
容器化和 K8s 跑起来只是起点,真正让系统稳定运行是个持续优化的过程。根据我们运行了几个月的体会,有几件事值得长期坚持。
第一件事是每两周做一次发布复盘。看这个周期里哪些服务发布最频繁,哪些发布导致过故障,哪些配置是手滑改错的。复盘不是为了追责,而是把频繁出错的操作做成自动化校验。比如因为 YAML 缩进问题出过错,就在流水线里加一个格式校验步骤。
第二件事是持续降低镜像体积。镜像体积直接影响发布速度和存储成本。每个季度抽时间看看有没有可以换用更精简基础镜像的服务,JVM 参数有没有必要再微调。这些优化单独做没有惊天动地的效果,三个月累积下来发布效率的提升非常明显。
第三件事是给团队做一次 K8s 知识普及。部署体系越复杂,开发人员的操作习惯越关键。项目里至少让每个开发都清楚:改配置要改 ConfigMap 而不是进容器改、看日志去日志平台而不是 kubectl exec 然后 tail、滚动更新后要先看探针状态再走人。
最后说说我对“云原生部署”的理解。Docker 和 Kubernetes 是工具,但真正改变的是应用的生命周期管理方式——从“一台机器上跑一个进程”变成了“一个声明式的集群里调度无状态工作负载”。这套体系搭建起来之后,开发、测试、生产环境的差异被压缩到几乎为零,发布变得快速而可回滚,扩容不再需要手工准备机器。整个过程虽然踩了不少坑,但回头看,这笔投入非常值得。如果这篇文章能帮你少走几步弯路,我会非常高兴。