上一篇【第86篇】生产就绪检查清单——你的K8s集群真的可以上线吗 下一篇【第88篇】多集群管理实战——从单集群到联邦集群的进化之路
摘要
前面86篇把K8s的组件、原理、运维都讲透了,但很多同学还是犯愁:道理我都懂,可我那个跑了三年的Spring Boot应用,到底怎么搬上去?
这篇就干一件实事——把一个传统微服务应用原封不动(但不完全原样)搬上K8s。你会看到Dockerfile怎么写才不笨重、配置怎么从硬编码变成ConfigMap、健康检查怎么从"手写接口"变成K8s探针、日志怎么从写文件变成标准输出、最后用一套Deployment+Service+Ingress+ConfigMap把服务跑起来,并用金丝雀发布灰度上线。
这不是"Hello World",是真实项目组踩过坑的迁移清单。
一、改造前的样子:一个典型传统微服务
先看看我们要迁移的对象。一个常见的Spring Boot微服务,传统部署长这样:
【传统部署的"三件套"】
源码包 order-service.jar
配置文件 application.yml (数据库地址/端口/日志路径写死)
启动脚本 nohup java -jar order-service.jar &
部署方式:
1. 登录服务器
2. scp jar包上去
3. 改 application.yml
4. kill 旧进程, 启动新进程
5. 祈祷别出事
这种玩法在单机能跑,但问题一堆:配置散落在各台机器、扩缩容靠手工、回滚基本靠"再发一版"、一台机器挂了服务就瘫。
要点:K8s化改造的核心不是"把jar塞进容器",而是把"环境差异"和"运维动作"从人身上转移到声明式YAML里。配置外迁、探针接管健康、副本由控制器保证、路由由Ingress统一。
二、第一步:Dockerfile多阶段构建
传统做法很多人直接 FROM openjdk:8 然后把jar拷进去,镜像动辄600MB+,还把构建工具带进去了。正确姿势是多阶段构建:
# 阶段一:构建(编译环境,最后扔掉)
FROM maven:3.8-openjdk-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline # 提前拉依赖,利用层缓存
COPY src ./src
RUN mvn package -DskipTests -o
# 阶段二:运行(只带运行期需要的东西)
FROM eclipse-temurin:17-jre
WORKDIR /app
# 用非root用户跑,安全(对应第055篇SecurityContext)
RUN groupadd -r app && useradd -r -g app app
COPY –from=build /app/target/order-service.jar ./app.jar
USER app
EXPOSE 8080
# 优雅退出交给K8s的SIGTERM(对应第035篇)
ENTRYPOINT ["java", "-jar", "-XX:+ExitOnOutOfMemoryError", "./app.jar"]
几个关键点:
- 多阶段:构建工具和源码不进最终镜像,体积砍掉一大半。
- 层缓存:先 COPY pom.xml 单独拉依赖,改业务代码时不用重新下载整个依赖树。
- 非root:用 USER app 跑,避免容器里是root(第055篇讲过的SecurityContext思想,在镜像层先落实)。
- 基础镜像固定:别用 latest,用 eclipse-temurin:17-jre 这种带版本/ digest的,可重现、可扫描。
要点:镜像越小,调度越快、节点磁盘压力越小、CVE攻击面越小。docker images 里那些800MB的java镜像,基本都是没做多阶段构建的"祖传镜像"。
构建并推到仓库:
# 构建(注意加上构建上下文和标签)
docker build -t registry.example.com/order-service:1.4.2 .
# 推送
docker push registry.example.com/order-service:1.4.2
# 顺手扫个漏洞(第059篇)
trivy image registry.example.com/order-service:1.4.2
三、第二步:配置外迁——干掉硬编码
传统Spring Boot把数据库地址写死在 application-prod.yml。上K8s后,配置必须通过ConfigMap注入,做到镜像不变、环境变。
# configmap.yaml —— 把配置从代码里"拔"出来
apiVersion: v1
kind: ConfigMap
metadata:
name: order–service–config
namespace: shop
data:
application.yml: |
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://mysql.shop.svc:3306/orders
username: ${DB_USER}
password: ${DB_PASSWORD}
redis:
host: redis.shop.svc
management:
endpoints:
web:
exposure:
include: health,info,metrics # 给探针和监控用
Secret里的敏感信息(密码)单独放,不进ConfigMap(第021/057篇):
apiVersion: v1
kind: Secret
metadata:
name: order–service–secret
namespace: shop
type: Opaque
stringData:
DB_USER: order_app
DB_PASSWORD: "S3cret#2026" # 生产请用Vault/External Secrets
启动时把ConfigMap挂成文件,Spring Boot天然读 application.yml:
# 在Deployment的容器里
env:
– name: DB_USER
valueFrom:
secretKeyRef:
name: order–service–secret
key: DB_USER
– name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: order–service–secret
key: DB_PASSWORD
volumeMounts:
– name: config
mountPath: /app/config # Spring Boot默认会读 config/application.yml
volumes:
– name: config
configMap:
name: order–service–config
要点:配置外迁后,同一个镜像能在dev/test/prod跑,只是挂不同的ConfigMap。这也让"改配置不用重新打包",回滚配置只是换一个ConfigMap版本(配合Reloader还能热加载)。
四、第三步:健康检查改造成探针
传统应用常自己写个 /health 接口,然后靠外部脚本去curl。K8s原生支持更精细的三种探针(第034篇详细讲过),直接接管这块:
livenessProbe: # 活不下去就重启
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30 # 给Spring Boot启动留时间
periodSeconds: 10
failureThreshold: 3
readinessProbe: # 没就绪就不接流量
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
failureThreshold: 2
startupProbe: # 启动慢的保护伞(老应用启动要2分钟)
httpGet:
path: /actuator/health/liveness
port: 8080
failureThreshold: 30
periodSeconds: 5 # 最多容忍 30*5=150秒启动
Spring Boot Actuator 自带 health 端点,配合 liveness/readiness 分组(Spring Boot 2.3+)刚好对应K8s语义。
【探针分工,别再混为一谈】
liveness → "我还活着吗?" 失败=杀掉重启
readiness → "我能接活吗?" 失败=摘流量(不杀)
startup → "启动完了吗?" 期间屏蔽前两个
典型惨案: 把 readiness 当 liveness 用
→ 依赖的Redis抖一下, Pod被反复杀, 雪崩
要点:startupProbe 是老应用上K8s的救星。很多祖传服务启动要一两分钟,没有startupProbe时liveness会在启动期就误杀Pod,陷入无限重启循环。
五、第四步:日志标准化——别再写文件
传统应用把日志写到 /var/log/order-service/app.log。容器里写文件有两个坑:Pod一删日志没了;多个副本日志分散。K8s的约定是标准输出(stdout/stderr),让日志采集层(DaemonSet,第076篇)统一收。
# 在Spring Boot的 logback-spring.xml 里
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy–MM–dd HH:mm:ss} [%thread] %–5level %logger{36} – %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender–ref ref="CONSOLE" /> <!– 只打控制台 ––>
</root>
然后Deployment里一个 emptyDir 都不用挂日志盘,日志自然进stdout,被kubelet捕获:
# 看某个Pod的日志
kubectl logs -n shop deploy/order-service –tail=100
# 多副本全看(–prefix标副本名)
kubectl logs -n shop deploy/order-service –prefix -f
# 再用PLG(第076篇)集中检索
要点:日志上云原生的第一原则——应用只管往stdout打,落盘和轮转交给采集器和节点。这跟传统"应用自己管日志文件"是思维模式的切换。
六、全套YAML:把服务真正跑起来
把上面所有片段拼成一个完整Deployment,再补Service和Ingress:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order–service
namespace: shop
labels:
app: order–service
tier: backend
spec:
replicas: 3
selector:
matchLabels:
app: order–service
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # 金丝雀/滚动时零中断
template:
metadata:
labels:
app: order–service
version: "1.4.2"
spec:
containers:
– name: order–service
image: registry.example.com/order–service:1.4.2
ports:
– containerPort: 8080
resources:
requests:
cpu: "200m"
memory: "512Mi"
limits:
cpu: "1"
memory: "1Gi"
env:
– name: DB_USER
valueFrom:
secretKeyRef: { name: order–service–secret, key: DB_USER }
– name: DB_PASSWORD
valueFrom:
secretKeyRef: { name: order–service–secret, key: DB_PASSWORD }
volumeMounts:
– { name: config, mountPath: /app/config }
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
failureThreshold: 30
periodSeconds: 5
volumes:
– name: config
configMap:
name: order–service–config
—
apiVersion: v1
kind: Service
metadata:
name: order–service
namespace: shop
spec:
selector:
app: order–service
ports:
– port: 80
targetPort: 8080
—
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: order–service
namespace: shop
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
– host: shop.example.com
http:
paths:
– path: /api/order
pathType: Prefix
backend:
service:
name: order–service
port:
number: 80
要点:maxUnavailable: 0 + maxSurge: 1 是零中断发布的黄金组合——滚动更新时先起新Pod再下旧Pod,永远有副本在接流量。配合readinessProbe,新Pod没就绪前不会进负载均衡。
七、灰度上线:金丝雀发布实战
直接全量发布新版本太莽。用Service的selector天然支持金丝雀——给一小撮Pod打不同 version 标签:
【金丝雀思路(无需额外工具)】
流量 → Service(order-service)
├── 选择 app=order-service (含v1.4.2 和 v1.5.0)
└── 但 v1.4.2 有 9个, v1.5.0 只有 1个
→ 约10%流量落到新版本, 观察无异常再全量
更精细: 用 Ingress/Nginx 按 header 或权重切(第018/077篇)
# 先发1个新版本副本(独立Deployment或同Deployment调标签)
kubectl set image deploy/order-service order-service=registry.example.com/order-service:1.5.0
# 但更稳的是用两个Deployment分别控副本数(1:9)
# 金丝雀Deployment replicas:1, 稳定Deployment replicas:9
# 两者label都带 app=order-service, 共享同一个Service
# 观察指标(第075篇)
kubectl get pods -n shop -l app=order-service
curl -s shop.example.com/api/order/health
确认新版本稳了,再把稳定Deployment也升到1.5.0,下线金丝雀Deployment。需要更高级的按权重/按用户灰度,上Argo Rollouts或Istio(第077/078篇)。
八、改造前后对比
| 配置 | 写死在yml,散落各机 | ConfigMap/Secret注入,镜像不变 |
| 扩缩容 | 手工加机器+scp | kubectl scale 或HPA自动 |
| 健康检查 | 自己写脚本curl | liveness/readiness/startup探针 |
| 日志 | 写本地文件 | stdout,采集层统一收 |
| 发布 | kill+启动,可能中断 | 滚动更新零中断 |
| 回滚 | 再发一版 | kubectl rollout undo 秒回 |
| 灰度 | 基本没有 | 金丝雀/Service分流 |
本篇小结
把一个传统Spring Boot微服务搬上K8s,本质是四件"接管":Dockerfile多阶段构建接管打包(镜像小、可重现)、ConfigMap/Secret接管配置(镜像不变环境变)、探针接管健康检查(K8s自管生死)、stdout接管日志(采集层统一收)。再靠Deployment+Service+Ingress把它们串成一套声明式YAML,最后用金丝雀发布灰度上线、kubectl rollout undo 秒回滚。
这不是把jar塞进容器那么简单,而是把运维动作从"人手"搬进"声明"。一个服务改完,扩缩容、回滚、灰度全都有了。下篇我们换个更大的视角:当单个集群不够用,多集群怎么管。
上一篇【第86篇】生产就绪检查清单——你的K8s集群真的可以上线吗 下一篇【第88篇】多集群管理实战——从单集群到联邦集群的进化之路



