冷蟊初退 孤灯野澜 志起鸡鸣 墓不悲秋 技术交流 软件开发 商业合作 加Q:411239339

Day 29 存量应用容器化

浏览:23次阅读
没有评论

共计 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 定时任务,容器化后怎么办?

有三种方案,推荐程度递减:

  1. 如果用 K8s,直接用 CronJob 资源,最优雅
  2. 用独立的定时容器,通过 curl 触发主应用的 API 端点
  3. 在容器内跑 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: 容器化后性能下降了怎么排查?

九成的性能问题出在这几个地方:

  1. DNS 解析慢 :容器内 DNS 走的是 Docker 内置 DNS,可以在 docker-compose 里配置外部 DNS
  2. 磁盘 IO:overlay2 文件系统有额外开销,频繁读写的目录用 Volume 挂载
  3. 内存限制 :JVM 的 -Xmx 要配合容器的内存限制,否则容易被 OOM Kill
  4. 网络模式 :默认 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

小结

今天我们走完了存量应用容器化的全流程:

  1. 评估先行 :用评估清单给每个应用打分排序,先易后难
  2. 12-Factor 改造 :重点搞定配置外置、日志标准化、状态外置这三板斧
  3. 数据迁移 :根据数据量选择合适方案,小数据导出导入、大数据主从同步
  4. 灰度切换 :从 5% 流量开始,逐步放量,全程监控对比

记住一句话:容器化不是一蹴而就的事,而是一个渐进式的过程。不要追求一步到位,先让最简单的应用跑起来,积累经验后再攻克复杂的。就像学游泳,先在浅水区扑腾几下,别一上来就往深水区跳。

明天是我们 Docker 30 天实战系列的最后一天(Day 30),我们会做一个全系列的总结回顾,并给出从 Docker 到 K8s 的进阶学习路线。30 天的旅程马上就要画上句号了,别掉队。

正文完
创作不易,扫码加点动力
post-qrcode
 0
果较瘦
版权声明:本站原创文章,由 果较瘦 于2026-03-29发表,共计11893字。
转载说明:除特殊说明外本站文章皆由果较瘦原创发布,转载请注明出处。