|
|
老项目跑了三年,稳如老狗。结果领导开完会回来甩下一句:"这个月把咱们的系统全部容器化。"你看着那堆 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
复制代码
核心参数是,给应用一个"热身时间",这段时间内健康检查失败不计入重试次数。
Q3: 容器化后性能下降了怎么排查?
九成的性能问题出在这几个地方:
DNS 解析慢:容器内 DNS 走的是 Docker 内置 DNS,可以在 docker-compose 里配置外部 DNS
磁盘 IO:overlay2 文件系统有额外开销,频繁读写的目录用 Volume 挂载
内存限制:JVM 的要配合容器的内存限制,否则容易被 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 天的旅程马上就要画上句号了,别掉队。 |
|