容器化改造实战
三年前,你接手了一个单体 Java 应用。它跑在虚拟机上,部署靠手动拷贝 war 包,回滚靠备份目录。运维团队抱怨:「每次发布都像拆炸弹。」
现在,团队决定迁移到 Kubernetes。你知道这不只是在 Docker 里跑起来那么简单——应用的启动脚本、配置文件、日志路径、依赖版本,都需要重新审视。
这篇文章,是一个真实容器化改造项目的完整复盘。
容器化改造的评估阶段
在动手之前,先回答三个问题:要不要改、能不能改、怎么改。
改造价值评估
不是所有应用都值得容器化。评估维度:
应用类型分类
flowchart TB
subgraph Types["应用分类"]
direction TB
Stateless["无状态应用<br/>(API/Web 服务)"]
Stateful["有状态应用<br/>(数据库/消息队列)"]
Legacy["遗留单体<br/>(老旧架构)"]
Batch["批处理任务<br/>(定时任务/ETL)"]
end
Stateless --> |"容易"| Recommend["推荐改造"]
Stateful --> |"需要准备"| Moderate["可改造"]
Legacy --> |"评估 ROI"| Evaluate["慎重评估"]
Batch --> |"可用"| Consider["可以改造"]
常见改造障碍
容器化改造的典型步骤
第一步:环境配置外部化
传统应用的配置文件往往硬编码在代码中。容器化要求所有配置通过环境变量或配置文件注入。
原代码
public class DatabaseConfig {
private static final String DB_HOST = "10.0.0.100";
private static final int DB_PORT = 3306;
private static final String DB_NAME = "proddb";
}
改造后
public class DatabaseConfig {
private static final String DB_HOST = getEnv("DB_HOST", "localhost");
private static final int DB_PORT = Integer.parseInt(getEnv("DB_PORT", "3306"));
private static final String DB_NAME = getEnv("DB_NAME", "appdb");
private static String getEnv(String key, String defaultValue) {
String value = System.getenv(key);
return value != null ? value : defaultValue;
}
}
Docker
$ docker run -e DB_HOST=mysql.prod.svc \
-e DB_PORT=3306 \
-e DB_NAME=proddb \
myapp
第二步:日志输出标准化
容器中的日志应该输出到标准输出(stdout),而不是写入本地文件。这样 Kubernetes 的日志收集可以正常工作。
日志改造
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// 不要写入文件
// private static final File logFile = new File("/var/log/app.log");
// 使用 SLF4J 输出到 stdout
public class App {
private static final Logger logger = LoggerFactory.getLogger(App.class);
public static void main(String[] args) {
logger.info("应用启动中...");
logger.info("数据库连接: {}", dbHost);
}
}
Dockerfile
# 避免程序把日志写到文件
ENV LOG_DIR=/dev/stdout
# 或者使用符号链接
RUN ln -sf /dev/stdout /var/log/app.log
第三步:健康检查与优雅关闭
容器需要响应 Kubernetes 的健康检查,并在收到 SIGTERM 时优雅关闭。
Spring
import org.springframework.web.bind.annotation.*;
@RestController
public class HealthController {
@GetMapping("/health")
public Health health() {
// 检查关键依赖
boolean dbHealthy = checkDatabase();
boolean cacheHealthy = checkRedis();
if (dbHealthy && cacheHealthy) {
return new Health("OK", true);
} else {
return new Health("DEGRADED", false);
}
}
private boolean checkDatabase() {
try {
jdbcTemplate.execute("SELECT 1");
return true;
} catch (Exception e) {
return false;
}
}
}
优雅关闭处理
import org.springframework.context.event.*;
import jakarta.annotation.*;
public class GracefulShutdown {
@PreDestroy
public void onDestroy() {
logger.info("收到关闭信号,开始优雅关闭...");
// 1. 停止接收新请求
logger.info("停止接收新请求");
// 2. 等待现有请求完成(最多 30 秒)
int waitTime = 30;
while (activeRequests > 0 && waitTime > 0) {
Thread.sleep(1000);
waitTime--;
logger.info("等待 {} 个请求完成...", activeRequests);
}
// 3. 关闭数据库连接
dataSource.close();
logger.info("优雅关闭完成");
}
}
第四步:多阶段 Dockerfile 构建
Java
# 阶段 1:构建
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build
# 复制 pom.xml 并下载依赖(利用缓存)
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 复制源码并构建
COPY src ./src
RUN mvn package -DskipTests -B
# 阶段 2:运行
FROM eclipse-temurin:21-jre-jammy
# 创建非 root 用户
RUN groupadd -r appuser && useradd -r -g appuser appuser
# 复制构建产物
COPY --from=builder /build/target/app.jar app.jar
# 安全配置
RUN chmod 500 app.jar
USER appuser
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# 启动命令
ENTRYPOINT ["java", "-jar", "app.jar"]
Go
# 多阶段构建
FROM golang:1.22 AS builder
WORKDIR /build
# 复制源码(利用缓存)
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
# 运行阶段
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /build/app /app
ENTRYPOINT ["/app"]
改造后的验证清单
功能验证
基础功能验证
# 1. 容器启动测试
$ docker run -d --name myapp-test myapp:latest
$ docker logs myapp-test
$ docker exec myapp-test curl http://localhost:8080/health
# 2. 配置注入测试
$ docker run -e DB_HOST=localhost -e DB_PORT=3306 myapp
$ docker logs | grep "数据库连接"
# 3. 资源限制测试
$ docker run -m 512m --cpus=0.5 myapp
$ docker stats myapp
# 4. 日志输出测试
$ docker logs myapp | grep -E "INFO|ERROR|WARN"
Kubernetes 验证
deployment-validation.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
terminationGracePeriodSeconds: 30 # 优雅关闭时间
containers:
- name: myapp
image: myapp:latest
ports:
- containerPort: 8080
env:
- name: DB_HOST
valueFrom:
secretKeyRef:
name: db-credentials
key: host
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
Kubernetes
# 1. 部署测试
$ kubectl apply -f deployment.yaml
$ kubectl rollout status deployment myapp
# 2. 健康检查验证
$ kubectl describe pod -l app=myapp | grep -A 5 "Liveness"
$ kubectl get pods -l app=myapp
# 3. 滚动更新测试
$ kubectl set image deployment/myapp myapp=myapp:v2
$ kubectl rollout undo deployment/myapp # 测试回滚
# 4. 负载测试
$ kubectl run load-test --image=busybox -- /bin/sh -c \
"while true; do wget -q -O- http://myapp:8080/health; done"
回滚策略
容器化改造不是单向的。必须准备好回滚方案。
回滚方案设计
flowchart TB
subgraph Current["当前状态"]
VM[虚拟机部署<br/>版本 1.0]
end
subgraph Migration["改造中"]
Container[容器部署<br/>版本 1.1]
end
subgraph Rollback["回滚选项"]
R1[回滚到 VM]
R2[回滚到旧镜像]
end
VM --> Container
Container --> |"失败"| R1
Container --> |"部分失败"| R2
保留旧版本
保留旧版本
# 1. 不要立即删��旧镜像
$ docker tag myapp:v1.0 myapp:v1.0-backup-$(date +%Y%m%d)
$ docker push harbor.example.com/myapp:v1.0-backup-20240115
# 2. 保留旧的 VM 版本(容器化初期)
$ ssh vm-server "cp -r /opt/app /opt/app-backup-$(date +%Y%m%d)"
# 3. 配置回滚脚本
$ cat rollback.sh
#!/bin/bash
kubectl rollout undo deployment myapp
echo "已回滚到上一版本"
常见问题与反模式
问题 1:硬编码文件路径
现象:应用写入 /var/log/app.log,容器删除后日志消失。
解决方案:使用 stdout + 日志收集,或挂载 Volume。
# 错误
RUN mkdir -p /var/log && chmod 777 /var/log
# 正确
ENV LOG_DIR=/dev/stdout
问题 2:缺少启动检查
现象:容器启动后立即接收流量,但数据库连接还没建立。
解决方案:添加 readinessProbe,确保依赖就绪后再接收流量。
问题 3:敏感信息硬编码
现象:数据库密码写在 Dockerfile 中。
解决方案:使用 Kubernetes Secret 或环境变量。
Secret
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
type: Opaque
stringData:
password: my-secret-password
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
问题 4:忽略资源限制
现象:应用内存泄漏导致宿主机 OOM。
解决方案:始终设置内存限制和请求值。
权衡矩阵
延伸思考
容器化改造不是终点,而是起点。
改造完成后,你会获得:更快的部署速度、更一致的环境、更容易的回滚。但同时也需要面对新的挑战:
- 镜像安全:容器内的依赖需要定期更新和安全扫描
- 网络复杂性:Service、Ingress、NetworkPolicy 的配置更复杂
- 可观测性:需要统一的日志、Metrics、Trace 收集方案
- CI/CD 重构:从「上传 war 包」到「构建并推送镜像」
更深一层的问题是:你的团队准备好运维容器了吗?
容器化不只是技术转型,也是运维模式的转型。从「手动部署」到「声明式部署」,从「登录机器排查问题」到「查看日志和监控面板」。培训和工具建设,同样重要。
改造完成后,记得问自己:我的团队现在能比以前更可靠地发布吗?如果答案是肯定的,改造才算真正成功。