从虚拟机到容器:Java应用云原生改造的完整路径与注意事项
从虚拟机到容器:Java应用云原生改造的完整路径与注意事项
适读人群:正在推进Java服务容器化改造的技术负责人和工程师 | 阅读时长:约25分钟 | 适用版本:Spring Boot 2.7+/3.x、K8s 1.24+
开篇故事
我们公司在2021年启动了一个大型改造项目:把运行了7年的Java服务集群,从虚拟机迁移到K8s。整个系统有60多个服务,里面有核心交易系统(稳定运行5年,每天处理千万级订单)、有年代久远的单体应用(200万行代码,还在跑)、有相对新的微服务(Spring Boot,差不多可以直接上)。
那是一段痛苦而充实的时光。不是所有服务都能"直接容器化",有些服务依赖了宿主机的特定文件路径、有些用了本地缓存(假设单机部署)、有些日志写到本地文件从不轮转……每一个问题单独看都不难解决,但60多个服务一起改,就是个系统性工程。
两年后,我们完成了90%的迁移,资源利用率从原来的平均20%提升到了65%,发布周期从原来的每两周一次缩短到每天多次,运维人力减少了60%。
今天把这段经历整理成一份完整的改造路径,让后来者少走弯路。
一、核心问题分析
云原生改造的四个阶段
云原生改造不是"换个运行环境"这么简单,它是一个涉及架构、代码、运维、流程的系统性改造:
阶段一:容器化(Lift and Shift):把应用打成Docker镜像,在K8s上运行,但不改代码和架构。这是最快的迁移方式,但只获得了K8s的一部分收益(统一运维平台),不能充分利用云原生的弹性、自愈等特性。
阶段二:云原生适配:改造代码,让应用符合云原生的十二要素原则:无状态化、外化配置、容器感知等。这一步才让应用能够充分利用K8s的弹性伸缩、滚动更新、服务发现等能力。
阶段三:微服务化:如果原来是单体或大型服务,拆分成独立部署的微服务。这一步技术风险最高,需要最多的架构设计和代码改造。
阶段四:云原生进阶:引入服务网格、GitOps、可观测性平台等高级能力,进一步提升系统的可靠性和工程效率。
二、原理深度解析
虚拟机环境 vs 容器环境的本质差异
核心差异总结:
IP和主机名不再固定:虚拟机的IP和主机名基本固定,但容器每次重启可能换IP,Pod被调度到不同Node也会换IP。任何写死IP的配置都需要改成服务名(DNS解析)。
文件系统是临时的:容器重启后,容器层的文件系统会被重置,任何写到容器文件系统的数据(日志、临时文件、上传的文件)都会丢失。需要把数据外化到持久化存储。
进程随时可能被终止:K8s可能因为节点故障、滚动更新、资源压力等原因随时终止Pod。应用必须能优雅处理SIGTERM信号,快速完成正在处理的请求,然后退出。
资源受到严格限制:容器有明确的CPU和内存Limit,JVM的参数必须从"按物理机配置"改成"按容器Limit配置"。
三、完整迁移路径
第一步:应用评估(Pre-Migration Assessment)
# 检查应用的容器化就绪度
# 以下几项如果有问题,需要提前处理
# 1. 检查是否有写本地文件的操作(日志、上传、临时文件)
grep -r "FileWriter\|FileOutputStream\|new File\|Files.write" src/ \
--include="*.java" | grep -v test
# 2. 检查是否有硬编码的IP地址
grep -r "([0-9]{1,3}\.){3}[0-9]{1,3}" src/ --include="*.properties" \
--include="*.yml" --include="*.yaml"
# 3. 检查是否依赖了主机名
grep -r "InetAddress.getLocalHost\|hostname" src/ --include="*.java"
# 4. 检查是否有本地缓存(假设单机部署)
grep -r "ConcurrentHashMap\|Caffeine\|Guava Cache" src/ --include="*.java" | \
grep -i "singleton\|static"第二步:写一个生产级Dockerfile
这是761期文章深入讲过的内容,这里给出一个评估通过后直接可用的模板:
# 生产级多阶段构建Dockerfile
FROM maven:3.9.6-eclipse-temurin-17-alpine AS builder
WORKDIR /build
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests
FROM eclipse-temurin:17-jre-alpine AS layertools
WORKDIR /extracted
COPY --from=builder /build/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:17-jre-alpine AS runtime
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=layertools /extracted/dependencies/ ./
COPY --from=layertools /extracted/spring-boot-loader/ ./
COPY --from=layertools /extracted/snapshot-dependencies/ ./
COPY --from=layertools /extracted/application/ ./
USER appuser
EXPOSE 8080
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.JarLauncher"]第三步:应用代码的云原生适配
适配一:无状态化改造
// 改造前:本地缓存(假设单机,有状态)
@Service
public class UserService {
private static final Map<Long, UserDTO> localCache = new ConcurrentHashMap<>();
public UserDTO getUser(Long userId) {
return localCache.computeIfAbsent(userId, this::loadFromDb);
}
}
// 改造后:外部缓存(Redis,支持多实例)
@Service
public class UserService {
@Autowired
private StringRedisTemplate redisTemplate;
@Cacheable(value = "user", key = "#userId")
public UserDTO getUser(Long userId) {
return loadFromDb(userId);
}
}适配二:配置外化
// 改造前:配置文件写死路径(VM上的绝对路径)
@Value("${app.config.path:/etc/myapp/config.properties}")
private String configPath;
// 改造后:通过环境变量或ConfigMap传入
@Value("${APP_CONFIG_PATH:/app/config/config.properties}")
private String configPath;
// 在K8s里通过ConfigMap挂载到/app/config/目录适配三:优雅关闭
// Spring Boot的优雅关闭配置(application.yml)
server:
shutdown: graceful # 优雅关闭(等待正在处理的请求完成)
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 最多等30秒K8s的Deployment配置里也要相应设置:
spec:
template:
spec:
terminationGracePeriodSeconds: 60 # 要比shutdown timeout大
containers:
- lifecycle:
preStop:
exec:
command: ["sleep", "15"] # 先等15秒再关闭(让负载均衡更新)适配四:JVM参数调整
# 旧的VM配置(固定堆大小)
JAVA_OPTS="-Xms4g -Xmx8g -XX:PermSize=256m -XX:MaxPermSize=512m"
# 新的容器配置(按容器Limit百分比)
JAVA_OPTS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:+ExitOnOutOfMemoryError \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-Djava.security.egd=file:/dev/./urandom \
-Dnetworkaddress.cache.ttl=10"
# 注意:PermSize/MaxPermSize是Java 7以前的参数,Java 8+已移除
# 替换为:-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m适配五:日志输出改造
# logback-spring.xml - 生产环境输出JSON到stdout
<configuration>
<springProfile name="prod">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"service":"${spring.application.name}"}</customFields>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</springProfile>
<!-- 本地开发保持人类可读格式 -->
<springProfile name="local,dev">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
</root>
</springProfile>
</configuration>第四步:K8s部署配置
一个完整的生产级Deployment模板:
# complete-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
namespace: production
labels:
app: order-service
version: v1.2.3
spec:
replicas: 3
selector:
matchLabels:
app: order-service
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: order-service
version: v1.2.3
spec:
terminationGracePeriodSeconds: 60
serviceAccountName: order-service-sa
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 2000
volumes:
- name: app-config
configMap:
name: order-service-config
- name: app-secrets
secret:
secretName: order-service-secret
defaultMode: 0400
containers:
- name: order-service
image: registry.company.com/order-service:1.2.3
imagePullPolicy: IfNotPresent
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ["ALL"]
ports:
- name: http
containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "prod"
- name: SPRING_CONFIG_ADDITIONAL_LOCATION
value: "file:/app/config/,file:/app/secrets/"
- name: JAVA_OPTS
value: >-
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0
-XX:+ExitOnOutOfMemoryError
-XX:+UseG1GC
-Dnetworkaddress.cache.ttl=10
volumeMounts:
- name: app-config
mountPath: /app/config
readOnly: true
- name: app-secrets
mountPath: /app/secrets
readOnly: true
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2000m"
memory: "2Gi"
startupProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 10
failureThreshold: 30
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 30
failureThreshold: 3
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
periodSeconds: 10
failureThreshold: 2
successThreshold: 2
timeoutSeconds: 5
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 15"]第五步:迁移风险评估与灰度策略
# 迁移灰度:先迁移5%流量到容器,验证没问题后再全量
# 利用Nginx Ingress Controller的Canary功能
# 1. 新建指向K8s service的Ingress(金丝雀)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: order-service-canary
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "5"
spec:
rules:
- host: api.company.com
http:
paths:
- path: /orders
backend:
service:
name: order-service-k8s
port:
number: 8080
# 2. 原来的VM服务继续接收95%流量
# 3. 观察错误率、延迟等指标
# 4. 逐步提升到100%,撤掉VM四、生产最佳实践
迁移优先级排序
不要把所有服务同时迁移,按以下优先级分批进行:
第一批(低风险,高收益):新开发的服务,本来就是Spring Boot,没有遗留问题;无状态的Worker服务(消费消息队列,处理后即丢弃),迁移最简单;内部工具服务,影响用户有限。
第二批(中等风险):有状态但已使用外部存储的服务;有少量本地文件操作但可以改造的服务。
第三批(高风险,需要重点改造):核心交易、支付等服务,必须做充分测试;依赖本地状态的服务,需要架构调整;老旧单体服务,可能需要先重构再迁移。
性能基线对比
迁移前后必须做性能对比,确保容器化没有带来性能退步。关键指标:
| 指标 | 迁移前基线 | 迁移后目标 | 实测结果 |
|---|---|---|---|
| P99响应时间 | 200ms | ≤220ms | 195ms |
| 吞吐量(TPS) | 5000 | ≥4750 | 5200 |
| CPU使用率(峰值) | 70% | ≤75% | 68% |
| 内存使用(稳态) | 4GB | ≤4.5GB | 3.8GB |
| 启动时间 | 45s | ≤60s | 52s |
五、踩坑实录
坑一:服务发现从IP注册改成域名,但遗漏了一处硬编码
某个服务迁移到K8s后,服务注册中心(Nacos)里注册的是Pod IP,而不是Service域名。VM时代Pod IP相对固定,无所谓;但K8s里Pod重启后IP变了,Nacos里的注册信息过期,其他服务调用失败。
解决方案:Spring Cloud应用在K8s上需要配置Nacos使用ServiceName注册,而不是Pod IP:
spring:
cloud:
nacos:
discovery:
# 使用K8s Service域名注册,而不是Pod IP
ip: ${SPRING_CLOUD_CLIENT_HOSTNAME:${spring.application.name}}
# 或者直接用Service的ClusterIP(稳定)
# ip: ${KUBERNETES_SERVICE_HOST}坑二:本地Session(HttpSession)在多副本下失效
服务迁移到K8s后,水平扩容成3个副本,用户开始反映登录状态时不时失效。原因是HTTP Session存储在JVM内存里(SpringSession默认),3个Pod之间Session不共享,用户的请求被路由到不同Pod时就变成了"未登录"状态。
解决方案:把Session迁移到Redis共享存储:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>spring:
session:
store-type: redis
redis:
namespace: "spring:session:order-service"坑三:定时任务在多副本下重复执行
迁移到K8s后扩成了3个副本,Spring的@Scheduled任务在3个Pod里同时执行,导致批处理结果重复,数据错乱。VM时代只有一个实例,不会有这个问题。
解决方案有三个:使用分布式锁(Redisson)保证同一时刻只有一个Pod执行;使用ShedLock分布式任务锁;把定时任务提取成独立的K8s CronJob(最干净的方案):
apiVersion: batch/v1
kind: CronJob
metadata:
name: order-cleanup-job
spec:
schedule: "0 2 * * *"
concurrencyPolicy: Forbid # 禁止并发执行
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: order-cleanup
image: registry.company.com/order-service:1.2.3
command: ["java", "-jar", "app.jar", "--spring.profiles.active=cleanup-job"]六、总结
从虚拟机到容器的云原生改造,不是一次性的"迁移",而是一个持续的现代化过程。核心原则可以归纳为十二要素应用(The Twelve-Factor App),其中对Java服务最重要的几点:
一,配置外化(III. Config):所有环境配置通过环境变量或ConfigMap注入,代码里没有硬编码的环境相关信息。
二,无状态化(VI. Processes):应用进程无状态,可以随意启动、停止、扩容,状态存储在外部(数据库、Redis)。
三,日志即事件流(XI. Logs):应用只负责把日志写到stdout,日志收集、存储、分析由基础设施负责。
四,快速启动和优雅终止(IX. Disposability):应用要快速启动(便于弹性伸缩),收到SIGTERM后要优雅关闭(处理完正在进行的请求再退出)。
做好了这四点,Java服务的云原生改造就成功了一大半。其余的RBAC、NetworkPolicy、HPA等能力,可以在基础改造完成后逐步完善。
