共计 11893 个字符,预计需要花费 30 分钟才能阅读完成。
文章目录 [显示]
老项目跑了三年,稳如老狗。结果领导开完会回来甩下一句:" 这个月把咱们的系统全部容器化。" 你看着那堆 crontab、手动部署脚本和写死在代码里的本地路径,感觉头皮一阵发麻。别慌,存量应用容器化这事儿,说难不难,说简单也没那么简单——关键是要有章法。
本文你将学到
- 如何评估一个存量应用是否适合容器化(以及优先级排序)
- 12-Factor App 改造的核心要点,哪些必须改、哪些可以缓
- 数据层迁移的几种实战方案
- 灰度切换策略,让你不用半夜提心吊胆
阅读时间 : 约 12 分钟
实操时间 : 约 40 分钟
难度等级 : 中高级(需要有基础的 Docker 和运维经验)
一、容器化评估:不是所有应用都要第一批上车
很多人一上来就动手写 Dockerfile,这就好比装修房子不量尺寸直接买家具——大概率翻车。
容器化评估清单
在动手之前,先给每个应用做个 " 体检 ":
+------------------+--------+--------+--------+--------+
| 评估维度 | 应用 A | 应用 B | 应用 C | 应用 D |
+------------------+--------+--------+--------+--------+
| 无状态程度 | 高 | 低 | 中 | 高 |
| 外部依赖复杂度 | 低 | 高 | 中 | 低 |
| 配置硬编码程度 | 无 | 严重 | 少量 | 无 |
| 本地文件依赖 | 无 | 大量 | 少量 | 无 |
| 定时任务依赖 | 无 | 有 | 有 | 无 |
| 业务重要性 | 核心 | 核心 | 边缘 | 边缘 |
| 容器化优先级 | P0 | P2 | P1 | P0 |
+------------------+--------+--------+--------+--------+
优先级排序原则 :先易后难,先边缘后核心。就像搬家,你肯定先搬不怕摔的东西,传家宝最后搬。
- P0(立即容器化):无状态、少依赖、配置规范的应用
- P1(改造后容器化):需要少量改造就能上容器的应用
- P2(规划后容器化):需要较大重构才能容器化的应用
- P3(暂不容器化):强依赖本地硬件、特殊驱动等,容器化收益不大
快速评估脚本
写一个脚本快速扫描应用的 " 容器化友好度 ":
#!/bin/bash
# check-containerize-readiness.sh
# 容器化就绪度检查脚本
APP_DIR=${1:-.}
SCORE=100
echo "=============================="
echo "容器化就绪度检查"
echo "目标目录: $APP_DIR"
echo "=============================="
# 检查硬编码路径
echo ""echo"[检查] 硬编码路径..."HARDCODED=$(grep -rn'/home/\|/usr/local/\|/opt/\|C:\\'"$APP_DIR" \
--include="*.py" --include="*.java" --include="*.js" \
--include="*.go" --include="*.php" --include="*.rb" 2>/dev/null | wc -l)
if ["$HARDCODED" -gt 0]; then
echo "发现 $HARDCODED 处硬编码路径(扣 10 分)"
SCORE=$((SCORE - 10))
else
echo "未发现硬编码路径"
fi
# 检查硬编码端口
echo ""echo"[检查] 硬编码端口..."PORTS=$(grep -rn'listen.*:[0-9]\{4,5\}\|bind.*:[0-9]\{4,5\}'"$APP_DIR" \
--include="*.py" --include="*.java" --include="*.js" \
--include="*.go" --include="*.conf" 2>/dev/null | wc -l)
if ["$PORTS" -gt 0]; then
echo "发现 $PORTS 处硬编码端口(扣 5 分)"
SCORE=$((SCORE - 5))
else
echo "未发现硬编码端口"
fi
# 检查环境变量使用
echo ""echo"[检查] 环境变量使用情况..."ENV_USAGE=$(grep -rn'os.environ\|os.Getenv\|process.env\|getenv\|ENV\['"$APP_DIR" \
--include="*.py" --include="*.java" --include="*.js" \
--include="*.go" --include="*.php" --include="*.rb" 2>/dev/null | wc -l)
if ["$ENV_USAGE" -gt 5]; then
echo "环境变量使用良好($ENV_USAGE 处)"
elif ["$ENV_USAGE" -gt 0]; then
echo "环境变量使用较少($ENV_USAGE 处,扣 5 分)"
SCORE=$((SCORE - 5))
else
echo "未使用环境变量(扣 15 分)"
SCORE=$((SCORE - 15))
fi
# 检查本地文件写入
echo ""echo"[检查] 本地文件写入..."FILE_WRITES=$(grep -rn'open.*w\|fwrite\|writeFile\|os.Create\|file_put_contents'"$APP_DIR" \
--include="*.py" --include="*.java" --include="*.js" \
--include="*.go" --include="*.php" 2>/dev/null | wc -l)
if ["$FILE_WRITES" -gt 10]; then
echo "大量文件写入操作($FILE_WRITES 处,扣 15 分)"
SCORE=$((SCORE - 15))
elif ["$FILE_WRITES" -gt 0]; then
echo "少量文件写入操作($FILE_WRITES 处,扣 5 分)"
SCORE=$((SCORE - 5))
else
echo "未发现本地文件写入"
fi
# 检查 crontab 依赖
echo ""echo"[检查] 定时任务依赖..."if [-f /etc/crontab] && grep -q"$APP_DIR" /etc/crontab 2>/dev/null; then
echo "发现 crontab 依赖(扣 10 分)"
SCORE=$((SCORE - 10))
else
echo "未发现 crontab 依赖"
fi
echo ""echo"=============================="echo" 容器化就绪度评分: $SCORE / 100"echo"=============================="if ["$SCORE" -ge 80]; then
echo "评级: P0 - 可直接容器化"
elif ["$SCORE" -ge 60]; then
echo "评级: P1 - 少量改造后可容器化"
elif ["$SCORE" -ge 40]; then
echo "评级: P2 - 需要较大改造"
else
echo "评级: P3 - 建议暂缓容器化"
fi
运行效果:
==============================
容器化就绪度检查
目标目录: ./my-legacy-app
==============================
[检查] 硬编码路径...
发现 3 处硬编码路径(扣 10 分)[检查] 硬编码端口...
未发现硬编码端口
[检查] 环境变量使用情况...
环境变量使用较少(2 处,扣 5 分)[检查] 本地文件写入...
少量文件写入操作(4 处,扣 5 分)[检查] 定时任务依赖...
未发现 crontab 依赖
==============================
容器化就绪度评分: 80 / 100
==============================
评级: P0 - 可直接容器化
二、12-Factor 改造:给老应用做个 " 微整形 "
12-Factor App 是 Heroku 团队总结的云原生应用方法论。你不需要一步到位全部满足,但有几条是容器化的硬性前提。可以把它想象成体检报告——有些指标飘红必须治,有些偏高观察就行。
必须改造项(容器化前置条件)
迁移前(裸机部署)迁移后(容器化部署)+---------------------------+ +---------------------------+
| /opt/myapp/ | | Docker Container |
| ├── config.properties | ──> | ├── ENV 环境变量注入 |
| │ (硬编码数据库地址) | | │ DATABASE_URL=... |
| ├── logs/ | | ├── stdout/stderr 日志 |
| │ (本地日志文件) | ──> | │ (日志收集器采集) |
| ├── upload/ | | ├── Volume 挂载 |
| │ (用户上传文件) | ──> | │ /data/upload -> NFS |
| └── crontab | | └── K8s CronJob |
| (定时任务) | ──> | (独立调度) |
+---------------------------+ +---------------------------+
第三条:配置存储在环境变量中
这是最常见的问题。老项目喜欢把配置写在文件里、写在代码里,甚至写在数据库里。
# 改造前:硬编码配置
DB_HOST = "192.168.1.100"
DB_PORT = 3306
DB_NAME = "myapp"
REDIS_URL = "redis://192.168.1.101:6379"
# 改造后:环境变量注入
import os
DB_HOST = os.environ.get("DB_HOST", "localhost")
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
DB_NAME = os.environ.get("DB_NAME", "myapp")
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379")
第十一条:日志作为事件流输出到 stdout
# 改造前:写本地文件
import logging
handler = logging.FileHandler('/var/log/myapp/app.log')
logger.addHandler(handler)
# 改造后:输出到 stdout
import logging
import sys
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s'
))
logger.addHandler(handler)
第六条:以无状态进程运行
这条最 " 伤筋动骨 "。如果你的应用把 Session 存在本地内存、把上传文件存在本地磁盘,就需要改造:
# docker-compose.yml 中的状态外置方案
services:
myapp:
image: myapp:latest
environment:
- SESSION_STORE=redis
- SESSION_REDIS_URL=redis://redis:6379
- UPLOAD_STORAGE=s3
- UPLOAD_S3_BUCKET=myapp-uploads
volumes:
# 临时方案:挂载共享存储
- nfs-uploads:/app/upload
redis:
image: redis:7-alpine
volumes:
nfs-uploads:
driver: local
driver_opts:
type: nfs
o: addr=192.168.1.200,rw
device: ":/exports/myapp-uploads"
可以后续改造的项(不阻塞容器化)
| Factor | 说明 | 紧急程度 |
|---|---|---|
| 第一条:基准代码 | 一份代码多份部署 | 低 |
| 第五条:构建、发布、运行 | 严格分离三个阶段 | 中 |
| 第八条:并发 | 通过进程模型扩展 | 低 |
| 第十条:开发环境与线上环境等价 | 环境一致性 | 中 |
三、实战:一个 Spring Boot 老项目的容器化
来看一个真实场景:一个跑了两年的 Spring Boot 项目,有本地配置文件、本地日志、本地上传目录。
第一步:创建多阶段 Dockerfile
# ============= 构建阶段 =============
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
# 先复制依赖文件,利用缓存
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 再复制源码并构建
COPY src ./src
RUN mvn package -DskipTests -B
# ============= 运行阶段 =============
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# 安全:不用 root 运行
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# 从构建阶段复制产物
COPY --from=builder /build/target/*.jar app.jar
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
# 切换用户
USER appuser
# JVM 参数通过环境变量控制
ENV JAVA_OPTS="-Xms256m -Xmx512m"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
第二步:改造配置文件
# application.yml - 改造后
spring:
datasource:
url: ${DATABASE_URL:jdbc:mysql://localhost:3306/myapp}
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:}
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
servlet:
multipart:
location: ${UPLOAD_DIR:/tmp/uploads}
# 日志输出到 stdout
logging:
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: "" # 禁用文件日志
第三步:编写 docker-compose.yml
services:
myapp:
build: .
ports:
- "8080:8080"
environment:
- DATABASE_URL=jdbc:mysql://db:3306/myapp?useSSL=false
- DB_USERNAME=myapp
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
- UPLOAD_DIR=/data/uploads
- JAVA_OPTS=-Xms512m -Xmx1024m
volumes:
- upload-data:/data/uploads
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
db:
image: mysql:8.0
environment:
- MYSQL_DATABASE=myapp
- MYSQL_USER=myapp
- MYSQL_PASSWORD=${DB_PASSWORD}
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes:
upload-data:
db-data:
redis-data:
第四步:构建并验证
# 构建镜像
docker compose build
# 预期输出:# [+] Building 45.2s (12/12) FINISHED
# => [builder 1/4] FROM maven:3.9-eclipse-temurin-17
# => [builder 2/4] COPY pom.xml .
# => [builder 3/4] RUN mvn dependency:go-offline -B
# => [builder 4/4] COPY src ./src
# => [builder 5/4] RUN mvn package -DskipTests -B
# => [stage-1 1/3] FROM eclipse-temurin:17-jre-alpine
# => [stage-1 2/3] COPY --from=builder /build/target/*.jar app.jar
# 启动服务
docker compose up -d
# 检查状态
docker compose ps
# 预期输出:# NAME IMAGE STATUS PORTS
# myapp-1 myapp:latest Up 30s (healthy) 0.0.0.0:8080->8080/tcp
# db-1 mysql:8.0 Up 32s (healthy) 3306/tcp
# redis-1 redis:7 Up 32s 6379/tcp
# 查看应用日志
docker compose logs myapp --tail=20
# 测试健康检查
curl http://localhost:8080/actuator/health
# {"status":"UP"}
四、数据迁移:最让人睡不着觉的部分
容器化最棘手的不是应用本身,而是数据。就像搬家最难搬的不是家具,而是那些装满东西的柜子。
数据库迁移方案对比
方案一:直连原库(最简单,过渡期推荐)+-------------+ +-----------------+
| 容器应用 | -------> | 原有数据库实例 |
+-------------+ +-----------------+
优点:零数据迁移 缺点:网络延迟、单点风险
方案二:主从同步(平滑迁移推荐)+----------+ 同步 +----------+
| 原主库 | --------> | 容器内从库 |
+----------+ +----------+
|
应用切换到从库
|
从库提升为主库
优点:平滑切换 缺点:需要同步窗口
方案三:导出导入(小数据量推荐)+----------+ dump +--------+ import +----------+
| 原数据库 | ------> | SQL 文件 | -------> | 容器数据库 |
+----------+ +--------+ +----------+
优点:简单直接 缺点:有停机窗口
实操:MySQL 数据迁移
# 方案三实操:适合数据量在 10GB 以内的场景
# 1. 从原库导出
mysqldump -h 192.168.1.100 -u root -p \
--single-transaction \
--routines \
--triggers \
--databases myapp > myapp_backup.sql
echo "导出完成,文件大小:"
ls -lh myapp_backup.sql
# 2. 启动容器数据库
docker compose up -d db
sleep 10 # 等待 MySQL 初始化完成
# 3. 导入到容器数据库
docker compose exec -T db mysql -u root -p${DB_ROOT_PASSWORD} < myapp_backup.sql
# 4. 验证数据完整性
docker compose exec db mysql -u root -p${DB_ROOT_PASSWORD} -e "
SELECT table_name, table_rows
FROM information_schema.tables
WHERE table_schema = 'myapp'
ORDER BY table_rows DESC;"
# 预期输出:# +------------------+------------+
# | table_name | table_rows |
# +------------------+------------+
# | orders | 125000 |
# | users | 50000 |
# | products | 8000 |
# | order_items | 380000 |
# +------------------+------------+
文件存储迁移
# 上传文件从本地目录迁移到 Docker Volume
# 1. 查看原始文件
echo "原始文件统计:"
find /opt/myapp/upload -type f | wc -l
du -sh /opt/myapp/upload
# 2. 创建并挂载 Volume
docker volume create myapp-uploads
# 3. 用临时容器复制文件
docker run --rm \
-v /opt/myapp/upload:/source:ro \
-v myapp-uploads:/dest \
alpine sh -c "cp -a /source/. /dest/ && echo' 复制完成 '"
# 4. 验证
docker run --rm -v myapp-uploads:/data alpine sh -c "echo' 文件数量:' && find /data -type f | wc -l
echo '总大小:' && du -sh /data
"
五、灰度切换:别一刀切,要像温水煮青蛙
这是整个容器化过程中最关键的一步。一刀切全量切换就像高速公路上换轮胎——理论上可以但没人敢。
灰度切换策略
阶段 1:并行运行(1-2 周)+--------+ +----------+ +---------+
| Nginx | --> | 原应用 | | 容器应用 | (仅内部测试)
+--------+ +----------+ +---------+
阶段 2:金丝雀发布(1 周)+--------+ +----------+
| Nginx | --> | 原应用 | 95% 流量
| | +----------+
| | +---------+
| | --> | 容器应用 | 5% 流量
+--------+ +---------+
阶段 3:逐步放量
+--------+ +----------+
| Nginx | --> | 原应用 | 50% 流量
| | +----------+
| | +---------+
| | --> | 容器应用 | 50% 流量
+--------+ +---------+
阶段 4:全量切换
+--------+ +---------+
| Nginx | --> | 容器应用 | 100% 流量
+--------+ +---------+
+----------+
| 原应用 | 保留 1 周可回滚
+----------+
Nginx 灰度配置
# /etc/nginx/conf.d/myapp.conf
upstream legacy {server 192.168.1.10:8080;}
upstream container {server 127.0.0.1:8080;}
# 灰度分流(基于权重)split_clients "$request_id" $backend {
5% container;
* legacy;
}
server {
listen 80;
server_name myapp.example.com;
location / {
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Backend $backend; # 方便排查
}
# 健康检查端点直接打到容器
location /health {proxy_pass http://container/actuator/health;}
}
监控对比脚本
灰度期间,你需要对比两个环境的关键指标:
#!/bin/bash
# compare-metrics.sh - 灰度期间指标对比
echo "========== 灰度监控对比 =========="
echo "时间: $(date'+%Y-%m-%d %H:%M:%S')"
echo ""
# 响应时间对比
echo "[响应时间]"
LEGACY_RT=$(curl -s -o /dev/null -w "%{time_total}" http://192.168.1.10:8080/api/health)
CONTAINER_RT=$(curl -s -o /dev/null -w "%{time_total}" http://127.0.0.1:8080/api/health)
echo "原应用: ${LEGACY_RT}s"
echo "容器应用: ${CONTAINER_RT}s"
# 错误率对比(从 Nginx access log 统计)echo ""echo"[最近 5 分钟错误率]"LEGACY_ERR=$(awk -v d="$(date -d '5 minutes ago' '+%d/%b/%Y:%H:%M')"\'$4 > "["d && $9 ~ /^5/' /var/log/nginx/access.log | \
grep "legacy" | wc -l)
CONTAINER_ERR=$(awk -v d="$(date -d'5 minutes ago''+%d/%b/%Y:%H:%M')"\'$4 > "["d && $9 ~ /^5/' /var/log/nginx/access.log | \
grep "container" | wc -l)
echo "原应用: ${LEGACY_ERR} 个 5xx"
echo "容器应用: ${CONTAINER_ERR} 个 5xx"
# 内存使用对比
echo ""echo"[内存使用]"echo" 容器应用:"docker stats --no-stream --format" {{.Name}}: {{.MemUsage}}" myapp-1
echo ""echo"=================================="
常见问题 Q&A
Q1: 应用依赖 crontab 定时任务,容器化后怎么办?
有三种方案,推荐程度递减:
- 如果用 K8s,直接用 CronJob 资源,最优雅
- 用独立的定时容器,通过 curl 触发主应用的 API 端点
- 在容器内跑 crond(不推荐,违反单进程原则,但作为过渡方案可以接受)
# 方案 2 示例:docker-compose 中的定时触发容器
services:
scheduler:
image: alpine:3.19
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo "0 2 * * * wget -qO- http://myapp:8080/api/tasks/cleanup" | crontab -
crond -f
depends_on:
- myapp
Q2: 应用启动要 2 分钟,健康检查老是失败怎么调?
Java 应用尤其常见这个问题。关键是调整健康检查参数:
HEALTHCHECK \
--interval=30s \
--timeout=5s \
--start-period=120s \
--retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
核心参数是 --start-period,给应用一个 " 热身时间 ",这段时间内健康检查失败不计入重试次数。
Q3: 容器化后性能下降了怎么排查?
九成的性能问题出在这几个地方:
- DNS 解析慢 :容器内 DNS 走的是 Docker 内置 DNS,可以在 docker-compose 里配置外部 DNS
- 磁盘 IO:overlay2 文件系统有额外开销,频繁读写的目录用 Volume 挂载
- 内存限制 :JVM 的
-Xmx要配合容器的内存限制,否则容易被 OOM Kill - 网络模式 :默认 bridge 模式有 NAT 开销,性能敏感场景可以用 host 模式
# docker-compose 性能调优示例
services:
myapp:
# ...
deploy:
resources:
limits:
memory: 2G
cpus: "2.0"
reservations:
memory: 1G
cpus: "1.0"
dns:
- 223.5.5.5
- 8.8.8.8
小结
今天我们走完了存量应用容器化的全流程:
- 评估先行 :用评估清单给每个应用打分排序,先易后难
- 12-Factor 改造 :重点搞定配置外置、日志标准化、状态外置这三板斧
- 数据迁移 :根据数据量选择合适方案,小数据导出导入、大数据主从同步
- 灰度切换 :从 5% 流量开始,逐步放量,全程监控对比
记住一句话:容器化不是一蹴而就的事,而是一个渐进式的过程。不要追求一步到位,先让最简单的应用跑起来,积累经验后再攻克复杂的。就像学游泳,先在浅水区扑腾几下,别一上来就往深水区跳。
明天是我们 Docker 30 天实战系列的最后一天(Day 30),我们会做一个全系列的总结回顾,并给出从 Docker 到 K8s 的进阶学习路线。30 天的旅程马上就要画上句号了,别掉队。