前阵子帮一个团队把一个 Spring Boot 的单体服务从“手动打包、手动上线、天天等着用户反馈”的状态,迁到了 Docker + Kubernetes 上。整个过程踩了不少坑,也总结出了一些可以直接抄作业的流程。这篇就完整分享一下,从镜像打包、YAML 编写到滚动更新、问题排查的整个链路,尽量把每个关键步骤背后的“为什么”也说清楚。无论你是一个人在折腾个人项目,还是团队准备做微服务化的演练,这篇都会对你有实际帮助。
1. 先搞清楚:为什么一定要用 Docker 和 K8s 来部署
1.1 容器化解决了 Spring Boot 部署的哪些现实问题
你大概率遇到过这种情况:本地跑得好好的代码,到了测试环境启动就报ClassNotFoundException,或者线上环境和开发环境的 JDK 版本差一个小版本,启动时内存参数配置完全对不上。这些问题的根源往往不是代码本身,而是运行环境不一致。
Docker 把“运行环境”整个打包成镜像,应用、JDK、基础系统库、时区设置、环境变量全部固化下来。镜像一旦构建成功,在任意一台装了 Docker 的机器上跑出来的结果都是一致的。对 Spring Boot 这类“自带 Tomcat 的胖 JAR”应用来说,容器化尤其适合,因为应用本身不需要额外安装容器服务器,天然就是一个独立的进程。
另外,容器化让“进程生命周期管理”变得规范。以前上线靠脚本 kill -9、nohup 启动、手动保存日志文件,很容易出现僵尸进程或者日志丢失。放进容器之后,所有日志走标准输出,由容器引擎统一接管,至少在运维层面是清爽的。
1.2 为什么“只用 Docker 还不够”,必须上 K8s
直接拿 Docker 跑命令确实也能把 Spring Boot 服务拉起来,但很快就发现一个问题:单机 Docker 只解决“怎么运行”,不解决“怎么保证服务一直可用”。
比如你某个实例挂了,Docker 不会自动把你的服务拉起另外一台机器;流量大了要加副本,你还得手动去别的机器上 docker run 一个新容器,然后手动改负载均衡配置;发布的时候要先停老容器再启新容器,中间必然有一段时间服务不可用。
K8s(Kubernetes)解决的就是“编排”层的需求:
- 自愈:Pod 挂了,控制器自动创建新 Pod 替换,确保副本数始终满足你的预期。
- 水平伸缩:结合 HPA(Horizontal Pod Autoscaler),可以根据 CPU、内存或自定义指标自动增减副本数。
- 滚动更新与回滚:发布新版本时,逐个地替换旧 Pod,过程中服务不中断;出问题可以一键回滚到上一个版本。
- 服务发现:K8s 内置的 Service 对象会为后端 Pod 提供稳定的虚拟 IP 和 DNS 名称,应用之间互相调用不再需要手动维护 IP 列表。
所以,你可以把 Docker 理解成“集装箱”,把 K8s 理解为“港口调度系统”。单靠集装箱也能运货,但大批量货物要高效流转、应对突发吞吐,就必须有调度系统来统一管理。
1.3 一套适用大多数场景的部署架构
经过这段时间的实际操作,我建议团队在刚开始切入 K8s 时,先别把架构搞得太复杂,只需要一个起点:“代码仓库 -> 构建镜像 -> 推送到私有仓库 -> K8s 集群拉取镜像并运行”。整体架构如下:
- 开发端:本地开发仍然使用
docker run快速验证镜像是否能正常启动。不要跳过这一步,这是性价比最高的问题拦截点。 - 构建产物:使用多阶段构建,把“编译环境”和“运行环境”分开,最终镜像里只保留运行需要的 JRE 和 Spring Boot 胖 JAR。
- 镜像仓库:每个镜像用“项目名 + 版本号(或 git commit)”打 tag,确保可以回溯到任意一个历史版本。
- K8s 集群:至少一个工作节点,通过 Deployment 控制器管理 Pod 副本数,通过 Service 暴露内部访问入口,外部流量再由 Ingress 转发到 Service。
- 发布方式:一条命令
kubectl set image deployment/xxx或直接改 YAML 中的 image 版本,K8s 自动完成滚动更新。
这个架构避免了“一上来就上 Service Mesh 或者分布式链路追踪”的高门槛,却能很好地把应用部署的稳定性、可重复性建立起来。
2. 环境准备与工具链选型
2.1 你需要准备哪些核心工具
在动手之前,先把运行环境理清楚。实际工作中,我会分成“两个环境”来准备。本地调试环境是给开发者用的,至少要有 Docker(如果在 Mac 或 Windows 上建议用 Docker Desktop,Linux 上直接装 Docker Engine 就行),以及 kubectl 命令行工具(用 stable 版本,避免出现过旧的 kubectl 版本无法和集群 API 交互的问题)。集群环境是实际承载应用服务的,可以是云厂商的托管 K8s 服务(托管控制面方便省心),也可以是自己用多台服务器搭建的集群(适合学习 K8s 内部机制)。另外建议在本地和 CI 里都准备好 Helm,它是一个 K8s 资源包的“软件包管理工具”,可以避免重复维护大量 YAML 文件。
2.2 镜像仓库怎么选
镜像仓库的选择,我的建议非常简单:如果团队没有特殊要求,优先用云服务商提供的镜像仓库。自建仓库需要额外处理存储容量、垃圾回收(GC)、高可用和权限认证的问题,如果没有专门的人去维护,很容易在某一天发现存储全满而无法推送镜像。
用云仓库时应该开启“镜像不可变”策略。这是一个很关键的细节。团队里经常有人习惯用latest标签,推了几次之后,最后一次推送的镜像到底是不是最新代码,谁也说不清。开启不可变标签之后,同一个 tag 只能使用一次,要么换 tag,要么删除后重新推送,这能倒逼团队规范版本管理。
2.3 CI/CD 流水线的关键思考
刚开始我其实不太推荐团队一上来就搞完整 CI/CD。先把“本地手动构建镜像 + 手动 kubectl apply”跑通,理解整个流程之后,再把构建和发布动作自动化。因为如果基础链路都还没走通就上流水线,出了问题时你分不清是代码问题、镜像问题还是流水线配置问题。
自动化流水线一般至少包含三个环节:编译打包(例如常见的 Mavenmvn clean package -DskipTests命令)、镜像构建并推送(在 Dockerfile 目录下执行docker build并docker push)、触发部署(使用kubectl set image命令更新 Deployment 镜像版本)。流水线的产物就是一个带版本号的新镜像,只有这一步做扎实,后续的灰度发布、回滚才有操作基础。
3. Spring Boot 应用如何打好 Docker 镜像
3.1 多阶段构建:不要把所有东西都塞进一个镜像
这是我见过新手最容易踩的坑:直接用一台有 Maven 和 JDK 的完整环境,把源码 copy 进去再mvn package,也没清理中间产物,最后打出来的镜像动辄 1GB、2GB。镜像大,拉取就慢,发布就慢,而且里面还带着一堆编译工具,对生产环境来说安全隐患也大。
正确做法是用多阶段构建。第一阶段是 Maven 构建环境,执行编译打包;第二阶段只拷贝编译好的 JAR 文件,然后用底层的 JRE 镜像来运行。这样最终镜像里只有运行时需要的文件,从 1GB 瘦身到 200MB 是非常普通的提升。
下面是一个可以直接使用的 Dockerfile 示例(基于较新版本的 OpenJDK 基础镜像和 Maven),注意构建时依赖下载会比较慢,国内环境建议把 Maven 仓库地址换成镜像源:
# 第一阶段:构建环境 FROM maven:3.9-openjdk-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests -B # 第二阶段:运行环境 FROM openjdk:17-jdk-slim WORKDIR /app COPY --from=builder /app/target/app.jar app.jar EXPOSE 8080 ENTRYPOINT ["java","-jar","/app/app.jar"]在这套方案里,mvn dependency:go-offline会把依赖提前下载好,后续构建如果 pom.xml 没有变化,可以走 Docker 层缓存,构建速度会快很多。openjdk:17-jdk-slim相比完整的openjdk:17镜像少了包管理工具和一些非必要组件,体积更小、攻击面也更小。
3.2 JVM 内存参数是一个必须重视的配置
Java 应用跑在容器里有个老坑:JVM 没有意识到自己在一个“有限内存”的容器里。在早些版本的 JDK 中,如果容器内存上限是 512MB,但 JVM 启动时按宿主机内存大小分配了 2GB 堆内存,内核会发现实际占用超过容器 limit,直接把容器杀了,你看到的现象就是 Pod 被 OOMKilled,但查看应用日志又没发现明显异常。
JDK 8u191+ 以及 JDK 9 之后,JVM 默认支持容器感知,会读取 cgroup 的内存限制。但为了更可控,我还是建议显式地设置内存参数。我的经验是:给容器的内存 limit 留出一定余量,JVM 堆内存不要贪得满满当当。例如容器 limit 设为 1024Mi,那么-Xmx就设 512m 或 640m,剩下部分留给 Metaspace、线程栈、JIT 编译和堆外内存。
在 Dockerfile 里可以这样调整:
ENTRYPOINT ["java","-XX:MaxRAMPercentage=70.0","-jar","/app/app.jar"]-XX:MaxRAMPercentage=70.0是“按容器内存上限的 70% 来分配 JVM 堆”,这样就不用在命令行写死具体数值,后续调整容器 limit 时 JVM 也会跟着变。实际运营下来,这个参数比手写-Xmx更灵活,也更不容易出问题。
3.3 健康检查与优雅停机:让 K8s 知道你的服务“活没活”
Spring Boot 自带 Actuator 健康检查端点,但很多团队打包镜像时根本没有启用它,或者只是启用了但没有告诉 K8s 该怎么探测。
在 K8s 里,关键探针有两个:就绪探针(readinessProbe)和存活探针(livenessProbe)。就绪探针决定流量要不要打到这个 Pod;存活探针决定容器要不要被重启。Spring Boot 项目我一般建议这样配置:
- 就绪探针:请求
/actuator/health/readiness,用于判断应用是否已经准备好接收流量。 - 存活探针:请求
/actuator/health/liveness,用于判断应用进程是否还健康,如果挂了就重启。
注意,如果你把/actuator/health(既包含磁盘、数据库等组件健康状态)同时用于两种探针,那么一旦数据库出问题,应用本身可能还能继续运行,但存活探针会不断失败,K8s 会反复重启你的 Pod,这会导致问题扩大化。这个细节在搭建 K8s 配置时特别容易忽略。
优雅停机同样很重要。Spring Boot 2.3 以上版本支持优雅停机配置:
server: shutdown: graceful然后在 K8s 里给容器加preStop钩子,让新版本发布或节点排空时,应用先停止接收新流量,再慢慢处理完存量请求,最后退出:
lifecycle: preStop: exec: command: ["sh", "-c", "sleep 5"]这里的 5 秒是给负载均衡器(实际上在 K8s 中是从 Service 端点列表中摘除 Pod)一些反应时间,避免正在处理的请求被直接中断。
3.4 镜像标签策略:别再用 latest 了
镜像 tag 是发布链条里最容易被忽视的环节之一。我一直强调团队里不要用latest,因为它不是“不可变”的,同一天内你可以推无数次latest,最后线上运行的镜像到底对应哪次提交,追溯起来非常痛苦。
推荐的 tag 方案:用 git 短 commit hash 或版本号,例如app-service-1.4.2、app-service-a3bf21c。在 CI 流水线里,我一般会用“构建号 + commit hash”拼接,格式类似v1.4.2-3-a3bf21c。这样做的好处是,每一份线上运行的镜像都能精确对到一次代码提交,排查问题、回滚都有据可查。
4. K8s 资源清单设计与核心步骤
4.1 命名空间:多环境隔离的第一道门
很多团队在 K8s 里不分环境、不分项目,所有资源都堆在 default 命名空间,这很快就会变成一团乱麻。
建议按项目环境划分命名空间,比如app-prod、app-test、app-dev。命名空间不仅能从资源层面隔离不同用途的对象,还能结合 ResourceQuota 给每个环境设置资源总量上限,防止某个环境把所有节点资源吃光。创建命名空间很轻:
kubectl create namespace app-prod大部分 YAML 里,我会在metadata下加上namespace: app-prod。这是最简单也最值得养成的工程习惯。
4.2 ConfigMap 和 Secret:把配置从镜像里解放出来
Spring Boot 的配置有个特点:用application.yml硬编码在 JAR 包里太死板,每次换环境都要重新打包镜像。正确做法是把配置放到 K8s 的 ConfigMap 里,用环境变量的方式注入,或者在容器启动时挂载成文件。
例如,在 ConfigMap 里放一个application-prod.yml,然后在 Deployment 中挂载到容器路径:
apiVersion: v1 kind: ConfigMap metadata: name: app-config namespace: app-prod data: application-prod.yml: | server: port: 8080 spring: datasource: url: jdbc:mysql://mysql-service:3306/app_db username: app_user password: change-me management: endpoints: web: exposure: include: health,info,metrics然后在 Deployment 里:
volumeMounts: - name: config mountPath: /app/config volumes: - name: config configMap: name: app-config启动命令调整为:
java -jar app.jar --spring.config.additional-location=file:/app/config/application-prod.ymlSpring Boot 会优先读取application默认配置,再叠加外部指定路径的配置。这样,开发、测试、生产环境共用同一个镜像,差异只体现在 ConfigMap 和 Secret 上。需要提醒的是,像数据库密码这类敏感信息不要直接放在 ConfigMap 里。K8s 原生的 Secret 虽然是 base64 编码,但对有集群权限的人几乎是透明的,因为它只是做了编码,没有加密。生产环境更稳妥的方式是集成外部的密钥管理方案(如云厂商的 KMS),或者至少开启 etcd 加密存储。
4.3 Deployment 设计:资源配额、探针和更新策略
Deployment 是整个服务部署的核心控制器。我先给出一个比较通用的 YAML 模板,然后重点解释里面的参数为什么这么设置。我们用一段代码块来展示:
apiVersion: apps/v1 kind: Deployment metadata: name: app-service namespace: app-prod spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 selector: matchLabels: app: app-service template: metadata: labels: app: app-service spec: imagePullSecrets: - name: registry-secret containers: - name: app-service image: registry.example.com/app-service:v1.4.2 ports: - containerPort: 8080 resources: requests: cpu: 250m memory: 512Mi limits: cpu: "1" memory: 1024Mi readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 20 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 60 periodSeconds: 15 timeoutSeconds: 2 failureThreshold: 3 lifecycle: preStop: exec: command: ["sh", "-c", "sleep 5"]在strategy里,maxUnavailable: 1表示滚动更新过程中允许最多有多少个 Pod 暂时不可用,maxSurge: 1表示最多可以额外创建多少个新 Pod。综合起来就是“先启动一个新 Pod,等它就绪后,再停一个旧 Pod”,保证整个发布过程始终有足够的实例对外提供服务。如果你把这个值配成maxUnavailable: 0, maxSurge: 2,那就是先多拉起两个新版本 Pod(如果集群有资源),确认健康后再逐个回收旧 Pod,对流量更友好。
关于 requests 和 limits,这是一个很容易混淆的概念,我单独展开讲。
4.4 Service 与 Ingress:怎么让外部流量进来
Deployment 管的是 Pod 的“死活”,而 Service 负责给这组 Pod 提供一个稳定的访问入口。
- ClusterIP:默认类型,只能在集群内部访问,适合服务之间互相调用。比如 Spring Boot 连接数据库,我会给数据库服务建一个 ClusterIP 类型的 Service,应用直接通过 DNS 名
mysql-service访问。 - NodePort:把 Service 暴露到每个节点的固定端口上,适合临时测试,但对外暴露一堆端口不够优雅。
- Ingress:使用负载均衡器或 Ingress Controller 来根据域名/路径转发请求,生产环境通常用这种方式。它会根据域名把
/api/**的请求转发到对应的 Service。
一个简单的 Ingress 配置可以是这样:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: app-ingress namespace: app-prod spec: ingressClassName: nginx rules: - host: api.example.com http: paths: - path: / pathType: Prefix backend: service: name: app-service port: number: 80在真正部署 Ingress Controller 时,我建议先在本地环境用 NodePort 验证整个链路是通的,再切到 Ingress 暴露。因为 Ingress Controller 本身的安装和证书配置是一套独立的体系,把它纳入“基础设施”统一维护,而不是和应用代码一起频繁变动,会省去很多生产事故。
4.5 完整 YAML 编排:从零到一的最佳实践顺序
新手在创建 K8s 资源时最容易犯的错误是“一股脑把所有 YAML 一起 kubectl apply”,结果看不清是哪个资源出了问题。
我的个人习惯是分四步走:
- 第一步:创建命名空间(确保资源不会散落在 default 里)。
- 第二步:创建 Secret 和 ConfigMap(配置先就位)。
- 第三步:创建 Service(先给 Pod 一个稳定的访问入口)。
- 第四步:创建 Deployment(让 Pod 真正跑起来)。
每应用完一步,先用kubectl get看对应资源的状态,确认无误再进入下一步。如果一次连招全上,排查问题时反而难以定位。
5. 从镜像到集群:实操部署流程实录
5.1 本地快速验证镜像
构建完 Dockerfile 后,别急着推到仓库再拉到 K8s。先在本地跑一遍:
docker build -t registry.example.com/app-service:v1.4.2 . docker run --rm -p 8080:8080 -e SPRING_PROFILES_ACTIVE=dev registry.example.com/app-service:v1.4.2本地跑通了,再执行docker push。这一步能在几十秒内帮你过滤掉大量低级别问题,比如镜像里的时区不对、启动依赖的外部服务连不上导致启动失败等。
5.2 推送镜像并部署
镜像推送到仓库之后,在 K8s 集群里执行:
kubectl apply -f 00-namespace.yaml kubectl apply -f 01-configmap.yaml kubectl apply -f 02-secret.yaml kubectl apply -f 03-service.yaml kubectl apply -f 04-deployment.yaml kubectl apply -f 05-ingress.yaml如果是第一次拉私有仓库镜像,需要先创建imagePullSecrets。这一步经常被很多人忽略,导致 Pod 一直停留在 ImagePullBackOff。创建方法大致是:
kubectl create secret docker-registry registry-secret \ --docker-server=registry.example.com \ --docker-username=your_name \ --docker-password=your_password \ -n app-prod然后在 Deployment 的spec.template.spec下引用imagePullSecrets。
5.3 观察 Pod 启动过程
部署完成后,不要只盯着终端里那几行输出。我一般会开两个终端,一个持续观察 Pod 状态:
kubectl get pods -n app-prod -w另一个用 describe 看事件:
kubectl describe pod app-service-xxxx -n app-prodkubectl describe会在 Events 里展示拉镜像、挂载卷、启动容器等各个阶段的状态。如果 Pod 卡在ContainerCreating,往往和卷挂载或镜像拉取有关;如果卡在CrashLoopBackOff,说明容器启动后崩溃了,下一步用kubectl logs看应用日志。
5.4 滚动更新与回滚:发布的“安全气囊”
当新版本镜像打好了,更新 Deployment 的方式很简单:
kubectl set image deployment/app-service app-service=registry.example.com/app-service:v1.4.3 -n app-prod或者用kubectl edit deployment app-service -n app-prod修改 image 字段。执行之后,K8s 会按照先前配置的滚动更新策略逐个替换 Pod。
发布过程中我最依赖的命令是:
kubectl rollout status deployment/app-service -n app-prod它会实时展示滚动更新的进度。如果中途发现新版本有问题,在更新还没结束时可以用kubectl rollout undo一键回滚。如果已经更新完成,同样是kubectl rollout undo deployment/app-service,它会把 Deployment 回退到上一个版本的 ReplicaSet,这是 K8s 内置的安全气囊。
5.5 灰度发布思路(浅尝即可,但也值得提前规划)
有些团队在发布关键业务时会要求“先给 10% 流量跑一段时间,确认没问题再全量”。K8s 原生 Deployment 的滚动更新是逐步替换,但不是真正的“按比例灰度”。如果你需要灰度,有几种实现方式:
- 多 Deployment 并存:新版本单独部署一个 Deployment,副本数设成 1,通过修改 Service 的后端 selector 来控制流量比例。缺点是完全按副本数比例分配,不能做到 1% 或 2% 精确灰度。
- Ingress 流量切分:如果 Ingress Controller 支持 header 或权重路由(例如基于 nginx 的 canary 注解),可以根据请求头把少量用户引导到新版本。
- 引入专业的发布系统:Argo Rollouts 这类工具可以把发布步骤定义在 Pipeline 里,支持暂停、人工确认、自动分析。
对于刚上手 K8s 的团队,我建议先把滚动更新和回滚玩熟,灰度发布等业务真正需要时再引入工具,否则工具本身的学习成本也会拖慢节奏。
6. 线上运行必须关注的:资源限制、伸缩与可观测性
6.1 requests 和 limits 怎么理解才不容易出错
资源配额是 K8s 排布 Pod 的重要依据,也是新手最容易出错的地方。requests是“调度保证值”,K8s 会按照 requests 来为 Pod 寻找合适的节点,保证至少能获得这么多资源;limits是“硬上限”,容器最多只能用这么多,一旦超过就会被限制 CPU 或杀掉内存。
我的经验是:内存的 requests 和 limits 不要设得相同,给 JVM 留一些弹性。比如把 limits 设为 1024Mi,requests 设为 512Mi,这样在正常情况下 Pod 使用 600Mi 不会立刻被杀,只有当内存确实增长到 1024Mi 时才会触发 OOMKilled。相比之下,CPU 的 requests 和 limits 可以偏宽松,CPU 是可压缩资源,过度限制只会导致容器被限流而变慢,而不是被杀掉。
6.2 HPA:让副本数跟着负载走
Spring Boot 应用在高峰期和低峰期的负载差异往往很大。HPA 可以按 CPU 使用率来自动伸缩。假设我们想让 Pod 的 CPU 平均使用率保持在 60%:
kubectl autoscale deployment app-service --cpu-percent=60 --min=2 --max=10 -n app-prodHPA 的计算公式大致是“期望副本数 = 当前副本数 × 当前平均 CPU 使用率 / 目标使用率”。例如现在有 3 个 Pod,平均 CPU 使用率为 180%(说明每个 Pod 算 60%,合计表现约 1.8 个完整核),目标使用率是 60%,那么期望副本数大概为 9 个。增加副本会摊薄单 Pod 平均负载,直到稳定在目标值附近。注意,HPA 基于 requests 计算使用率,所以 requests 设置合理的话,HPA 伸缩才会准确。
过一段时间会发现,只靠 CPU 伸缩还不够,比如业务是 IO 密集型的,或者受队列长度影响,这时候就需要用 Prometheus Adapter 接入自定义指标。这部分建议在基础 CPU 伸缩跑通之后再研究。
6.3 日志和监控:没有可观测性就没有安全感
K8s 中 Pod 是“随时可能消失”的,所以日志不能只存在宿主机上。常见的做法:
- 日志采集:每个节点跑一个日志采集代理(比如 Filebeat 或 Promtail),把
/var/log/containers/*.log采集到 ES/Loki 等日志系统里。Spring Boot 应用只需要把日志打到 stdout,采集代理会自动抓取。 - 监控告警:使用 Prometheus 抓取 Spring Boot Actuator 暴露的
/actuator/prometheus指标,配合 Alertmanager 设置告警规则。
要注意的是,Spring Boot 应用想要被 Prometheus 抓取到指标,必须引入micrometer-registry-prometheus依赖,并且在application.yml里暴露prometheus端点。如果没有这些准备,界面上永远看不到应用的 JVM 内存、HTTP 请求延迟等关键指标。
我一开始就把这些指标都接好了,后来在排查一次“接口偶尔变慢”的问题时,直接通过监控图表看到是 GC 频繁导致的停顿,效率比靠用户反馈高得多。
6.4 安全细节:非 root 用户和镜像扫描
很多基础镜像默认以 root 身份运行,这等于给了容器内进程过大权限。安全工作之一是,在 Dockerfile 里创建普通用户并切换过去:
RUN groupadd -r app && useradd -r -g app app USER app这能让容器进程即使被攻破,也很难直接拿到宿主机的 root 权限。另外,镜像构建完成后,尽量用docker scan或容器镜像扫描产品扫描一遍已知漏洞。我遇到过镜像里某个底层库有高危 CVE 的情况,重新选新基础镜像版本后解决。这些动作要放进流水线,不要靠人肉记得每次扫描。
7. 排错实录:那些让新手抓狂的常见问题
7.1 CrashLoopBackOff:从日志到探针,按顺序排查
这是最常见的问题,Pod 一启动就退出,K8s 反复重启,状态显示 CrashLoopBackOff。遇到它,我一般按以下顺序排查:
- 第一步,查看日志:
kubectl logs <pod> -n <namespace>。如果日志里明确抛出了数据库连接失败或端口被占用,基本是配置或依赖问题。 - 第二步,查看前一个容器的日志。有些时候 Pod 重启速度太快,当前容器日志还没刷新出来,用
kubectl logs <pod> --previous能看到上一次退出的输出。 - 第三步,检查探针配置。如果应用本身启动慢,
initialDelaySeconds设得太小会导致探针在应用还没完全起来时就失败了,然后被标记为不健康并重启。Spring Boot 项目如果第一次启动要加载大量类,initialDelaySeconds建议设置至少 20 秒以上。 - 第四步,查看 Pod Events,看是 OOMKilled 还是探针失败。
kubectl describe pod里会直接写清楚。
7.2 镜像拉取失败:ImagePullBackOff 的排查思路
镜像拉取失败通常有五种原因,按概率排一下:
- 私有仓库地址写错(少了 registry 前缀,甚至直接写了镜像名没有域名)。
- 没有创建 imagePullSecrets,或者 Secret 里的账号密码过期。
- 镜像 tag 不存在。你在本地看着有,但可能没推上去,或推到了别的仓库。
- 集群节点解析不了镜像仓库域名。自建仓库时,节点
/etc/hosts或 CoreDNS 没配好,就会一直超时。 - 镜像仓库需要认证但配置错误,或者仓库开启了不可变标签而 tag 已经存在。
遇到 ImagePullBackOff,先kubectl describe pod看事件,里面有 Pulling 和 Failed 的具体原因。接着在节点上手动docker pull一下看看能不能成功,这是区分“节点问题”和“配置问题”最直接的办法。
7.3 本地能跑但容器里起不来
明明在宿主机上直接跑 JAR 没有问题,放容器里就各种异常。这种情况大部分和“隐藏的环境差异”有关。最典型的几个坑:
- 时区不一致。容器默认通常是 UTC,而业务日志、数据库连接里可能有时区判断,导致时间差 8 小时。解决方式是在 Dockerfile 里设置时区:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone- 字符集问题。数据库连接或文件读取时出现中文乱码,检查 JVM 默认编码。在 JVM 启动参数里显式加
-Dfile.encoding=UTF-8可以避免。 - 文件系统权限。应用试图写日志文件或临时目录,但容器内用户对挂载卷没有写权限,导致启动失败或者运行时报错。这种问题
kubectl logs里通常有 Permission denied 字样。
7.4 版本不一致带来的诡异问题
团队里有人本地用 JDK 17 写完代码,CI 里用 JDK 11 构建,运行环境却是 JDK 8——这种错位经常会产生“编译期没问题,运行期 ClassNotFoundException”或“NoSuchMethodError”这样的诡异问题。
所以在多阶段构建里,构建环境 JDK 版本和运行环境 JRE 版本要保持一致。另外,本地编辑代码用的 Spring Boot 版本和部署时镜像里的 Spring Boot 版本也尽量用同一个版本,不要出现“本地是 3.x,线上还跑 2.x”的情况。为减少这类分歧,应把 Spring Boot 版本、基础镜像版本都统一纳入项目配置管理,而不是每次想起什么就临时改。
实践中的一点体会
大概把这个流程跟着完整走一遍之后,你会发现最耗时间的其实不是“写 YAML”,而是“统一团队对容器的认知”。比如每个人的本地 JDK 不同、打包习惯不同、对镜像 tag 规范不敏感,就会在部署时遇到各种磨人的问题。如果你们团队刚开始迁移,可以先找一个非核心服务试点,按上面这套流程跑通,把镜像构建、YAML 模板、发布回滚流程都稳定下来,再逐步扩大范围。等这套流程跑顺了,你会发现加一台机器、扩容副本、回滚发布都变成了“三分钟内能搞定的事”,留给自己的时间自然就多了。