Portainer Local 模式部署与容器迁移实战指南
服务器 A 已有运行中的容器,部署 Portainer(local 模式)进行管理,然后将容器镜像、实例完整迁移到服务器 B,在 B 上建立挂载目录映射容器内部数据,不使用 Docker Compose,保持高还原度一致性。:4-10 个容器,部分有数据卷。
Portainer Local 模式部署与容器迁移实战指南
场景:服务器 A 已有运行中的容器,部署 Portainer(local 模式)进行管理,然后将容器镜像、实例完整迁移到服务器 B,在 B 上建立挂载目录映射容器内部数据,不使用 Docker Compose,保持高还原度一致性。
适用规模:4-10 个容器,部分有数据卷
1. 整体架构与迁移流程
1.1 迁移前后架构
【迁移前 - 服务器 A】 【迁移后 - 服务器 B】
┌──────────────────────────┐ ┌──────────────────────────┐
│ Portainer (local 模式) │ │ Portainer (local 模式) │
│ 管理本机所有容器 │ │ 管理本机所有容器 │
│ │ │ │
│ ┌─────┐ ┌─────┐ ┌────┐ │ 迁移 │ ┌─────┐ ┌─────┐ ┌────┐ │
│ │App-1│ │App-2│ │DB │ │ ──────> │ │App-1│ │App-2│ │DB │ │
│ └─────┘ └─────┘ └────┘ │ │ └─────┘ └─────┘ └────┘ │
│ ┌──────────────────────┐│ │ ┌──────────────────────┐│
│ │ Named Volumes ││ │ │ Named Volumes ││
│ │ + Bind Mounts ││ │ │ + Bind Mounts ││
│ └──────────────────────┘│ │ └──────────────────────┘│
└──────────────────────────┘ └──────────────────────────┘
1.2 迁移流程总览
阶段一(零停机 - 准备) 阶段二(停机 - 切换) 阶段三(收尾)
───────────────────── ──────────────────── ──────────────
① A 部署 Portainer ⑧ 停止 A 上的应用容器 ⑬ 运行验证脚本
② 导出 A 的容器配置 ⑨ 最终增量同步绑定挂载 ⑭ B 部署 Portainer
③ 导出并传输镜像 ⑩ 备份命名卷(最终快照) ⑮ 更新 DNS/客户端
④ 传输绑定挂载(首次) ⑪ 传输命名卷到 B ⑯ 确认后关闭 A
⑤ B 导入镜像 ⑫ B 启动所有容器
⑥ B 创建自定义网络
⑦ B 创建挂载目录
关键原则:先准备(零停机),再切换(短停机),最后验证。停机时间目标:5 分钟以内。
2. 阶段一:服务器 A 部署 Portainer Local 模式
2.1 部署 Portainer
重要:部署 Portainer 不需要停止任何现有容器,它只是一个新增的管理容器。
#拉取镜像
docker pull portainer/portainer
# 启动 Portainer Server
docker run -d -p 9110:9000 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v /data/portainer_data:/data portainer/portainer
| 参数 | 说明 |
|---|---|
-p 9443:9443 |
Web UI 的 HTTPS 端口 |
-p 8000:8000 |
TCP 隧道端口(Edge Agent 通信) |
-v /var/run/docker.sock:/var/run/docker.sock |
挂载 Docker Socket,管理本机容器 |
-v portainer_data:/data |
持久化 Portainer 数据库 |
2.2 访问 Portainer
https://<服务器A的IP>:9443
首次访问设置管理员账号密码,设置完成后即可在 Web UI 中查看和管理服务器 A 上的所有容器。
2.3 通过 Portainer 确认现有容器
登录后进入 Local 环境,在 Containers 页面查看所有运行中的容器,确认以下信息:
- 容器名称、镜像、状态
- 端口映射
- 挂载的卷和绑定目录
- 所在网络
3. 阶段二:导出服务器 A 的容器配置
3.1 生成迁移清单
在服务器 A 上执行:
#!/bin/bash
# inventory.sh - 生成完整的迁移清单
echo "=========================================="
echo " Docker 容器迁移清单"
echo " 源服务器: $(hostname) ($(hostname -I | awk '{print $1}'))"
echo " 日期: $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="
echo ""
echo "【运行中的容器】"
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}"
echo ""
echo "【所有容器(含已停止)】"
docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
echo ""
echo "【命名卷】"
docker volume ls
echo ""
echo "【自定义网络】"
docker network ls --filter type=custom
echo ""
echo "【镜像】"
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
echo ""
echo "【各容器挂载详情】"
for C in $(docker ps --format '{{.Names}}'); do
echo "--- $C ---"
docker inspect "$C" --format '{{range .Mounts}} [{{.Type}}] {{.Source}} -> {{.Destination}} ({{if .RW}}读写{{else}}只读{{end}}){{end}}'
done
3.2 导出每个容器的完整配置(docker inspect)
#!/bin/bash
# export-configs.sh - 导出所有容器的完整 inspect 配置
# 兼容主流 Linux 发行版(Debian/Ubuntu/CentOS/RHEL 等)
# 定义输出目录(确保可写)
OUTPUT_DIR="/tmp/migration"
# 创建目录(-p 避免已存在报错,-m 设置权限)
mkdir -p -m 755 "$OUTPUT_DIR" || { echo "错误:无法创建输出目录 $OUTPUT_DIR"; exit 1; }
# 检查 docker 命令是否存在
if ! command -v docker &> /dev/null; then
echo "错误:未找到 docker 命令,请先安装 Docker 并确保其在 PATH 中"
exit 1
fi
# 检查 docker 服务是否运行
if ! docker ps &> /dev/null; then
echo "错误:Docker 服务未运行或当前用户无权限访问 Docker"
exit 1
fi
# 获取运行中的容器 ID(兼容 docker 不同版本的输出格式)
CONTAINER_IDS=$(docker ps -q 2>/dev/null)
if [ -z "$CONTAINER_IDS" ]; then
echo "提示:未找到运行中的容器"
exit 0
fi
# 遍历容器并导出配置
for CONTAINER_ID in $CONTAINER_IDS; do
# 获取容器名称(处理空名称、特殊字符)
NAME=$(docker inspect --format '{{.Name}}' "$CONTAINER_ID" 2>/dev/null | sed 's/^\///')
# 处理容器名称为空的情况(用 ID 代替)
if [ -z "$NAME" ]; then
NAME="container_${CONTAINER_ID:0:12}"
fi
# 替换名称中的特殊字符(避免文件命名错误)
NAME=$(echo "$NAME" | sed 's/[^a-zA-Z0-9_-]/_/g')
echo "===== 导出配置: $NAME (ID: ${CONTAINER_ID:0:12}) ====="
# 保存完整 inspect JSON(重定向错误输出)
if docker inspect "$CONTAINER_ID" > "${OUTPUT_DIR}/${NAME}_inspect.json" 2>/dev/null; then
echo " ✅ JSON 配置已保存"
else
echo " ❌ JSON 配置保存失败"
continue
fi
# 打印关键摘要(处理空值、默认值)
echo " 📦 镜像: $(docker inspect --format '{{.Config.Image}}' "$CONTAINER_ID" 2>/dev/null || echo "未知")"
echo " 🔄 重启策略: $(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER_ID" 2>/dev/null | sed 's/^$/no/g')"
echo " 🚪 端口: $(docker inspect --format '{{json .HostConfig.PortBindings}}' "$CONTAINER_ID" 2>/dev/null | sed 's/^null$/无/')"
echo " 🌍 环境变量: $(docker inspect --format '{{len .Config.Env}}' "$CONTAINER_ID" 2>/dev/null || echo "0") 个"
echo " 📂 挂载: $(docker inspect --format '{{len .Mounts}}' "$CONTAINER_ID" 2>/dev/null || echo "0") 个"
echo " 🖧 网络: $(docker inspect --format '{{range $net, $conf := .NetworkSettings.Networks}}{{$net}} {{end}}' "$CONTAINER_ID" 2>/dev/null | sed 's/^$/无/')"
echo " 🧠 内存限制: $(docker inspect --format '{{.HostConfig.Memory}}' "$CONTAINER_ID" 2>/dev/null | sed 's/^0$/无限制/g') 字节"
echo " 📊 CPU 限制: $(docker inspect --format '{{.HostConfig.NanoCpus}}' "$CONTAINER_ID" 2>/dev/null | sed 's/^0$/无限制/g') 纳秒/CPU"
echo ""
done
# 输出最终结果
echo "========================================"
echo "配置文件已保存到: $OUTPUT_DIR/"
echo "----------------------------------------"
ls -la "$OUTPUT_DIR/"*_inspect.json 2>/dev/null || echo "未生成任何配置文件"
echo "========================================"
3.3 从 inspect 生成 docker run 命令(核心步骤)
这是保证高还原度的关键——通过 docker inspect 提取完整配置,自动生成可执行的 docker run 命令。
方法一:使用社区 Go 模板(推荐,最完整)
# 对所有运行中的容器生成 docker run 命令
docker ps -q | while read CID; do
NAME=$(docker inspect --format '{{.Name}}' "$CID" | sed 's/^\//')
docker inspect --format \
"$(curl -s https://gist.githubusercontent.com/efrecon/8ce9c75d518b6eb863f667442d7bc679/raw/run.tpl)" \
"$CID" > "/tmp/migration/${NAME}_run.sh"
chmod +x "/tmp/migration/${NAME}_run.sh"
echo "已生成: /tmp/migration/${NAME}_run.sh"
done
方法二:使用 rekcod 工具
# 通过 Docker 直接运行(无需安装)
docker run --rm -i \
-v /var/run/docker.sock:/var/run/docker.sock \
nexdrew/rekcod $(docker ps -aq) > /tmp/migration/all_run_commands.sh
方法三:使用下面的完整脚本(覆盖所有参数)
#!/bin/bash
# generate-run-commands.sh - 从 docker inspect 生成完整的 docker run 命令
# 覆盖:名称、主机名、用户、环境变量、标签、端口、卷、网络、IP、
# 重启策略、资源限制、日志驱动、入口点、命令、DNS、ExtraHosts 等
OUTPUT_DIR="/tmp/migration"
mkdir -p "$OUTPUT_DIR"
for CONTAINER_ID in $(docker ps -q); do
NAME=$(docker inspect --format '{{.Name}}' "$CONTAINER_ID" | sed 's/^\//')
echo "处理容器: $NAME"
IMAGE=$(docker inspect --format '{{.Config.Image}}' "$CONTAINER_ID")
CMD="docker run -d --name ${NAME}"
# --- 基础配置 ---
HOSTNAME=$(docker inspect --format '{{.Config.Hostname}}' "$CONTAINER_ID")
[ -n "$HOSTNAME" ] && CMD="$CMD --hostname ${HOSTNAME}"
USER=$(docker inspect --format '{{.Config.User}}' "$CONTAINER_ID")
[ -n "$USER" ] && CMD="$CMD --user ${USER}"
PRIV=$(docker inspect --format '{{.HostConfig.Privileged}}' "$CONTAINER_ID")
[ "$PRIV" = "true" ] && CMD="$CMD --privileged"
TTY=$(docker inspect --format '{{.Config.Tty}}' "$CONTAINER_ID")
[ "$TTY" = "true" ] && CMD="$CMD -t"
STDIN=$(docker inspect --format '{{.Config.OpenStdin}}' "$CONTAINER_ID")
[ "$STDIN" = "true" ] && CMD="$CMD -i"
# --- 环境变量 ---
ENV_VARS=$(docker inspect --format '{{range .Config.Env}}{{printf "-e \"%s\" " .}}{{end}}' "$CONTAINER_ID")
[ -n "$ENV_VARS" ] && CMD="$CMD $ENV_VARS"
# --- 标签 ---
LABELS=$(docker inspect --format '{{range $k, $v := .Config.Labels}}{{printf "--label \"%s=%s\" " $k $v}}{{end}}' "$CONTAINER_ID")
[ -n "$LABELS" ] && CMD="$CMD $LABELS"
# --- 端口映射 ---
PORTS=$(docker inspect --format '{{range $p, $conf := .HostConfig.PortBindings}}{{if $conf}}{{(index $conf 0).HostIp}}{{end}}{{if and (index $conf 0).HostIp}}:{{end}}{{(index $conf 0).HostPort}}:{{$p}} {{end}}' "$CONTAINER_ID")
[ -n "$PORTS" ] && CMD="$CMD $(echo $PORTS | sed 's/ / -p /g' | sed 's/^/-p /')"
# --- 卷挂载(绑定挂载 + 命名卷)---
MOUNTS=$(docker inspect --format '{{range .Mounts}}{{if eq .Type "bind"}}-v {{.Source}}:{{.Destination}}{{if .RW}}{{else}}:ro{{end}} {{else if eq .Type "volume"}}-v {{.Name}}:{{.Destination}}{{if .RW}}{{else}}:ro{{end}} {{end}}{{end}}' "$CONTAINER_ID")
[ -n "$MOUNTS" ] && CMD="$CMD $MOUNTS"
# --- 网络 ---
NETWORK=$(docker inspect --format '{{range $net, $conf := .NetworkSettings.Networks}}{{$net}} {{end}}' "$CONTAINER_ID" | awk '{print $1}')
if [ -n "$NETWORK" ] && [ "$NETWORK" != "bridge" ]; then
CMD="$CMD --network ${NETWORK}"
# 固定 IP
IP_ADDR=$(docker inspect --format "{{range \$net, \$conf := .NetworkSettings.Networks}}{{if eq \$net \"${NETWORK}\"}}{{\$conf.IPAddress}}{{end}}{{end}}" "$CONTAINER_ID")
[ -n "$IP_ADDR" ] && CMD="$CMD --ip ${IP_ADDR}"
# 网络别名
ALIASES=$(docker inspect --format "{{range \$net, \$conf := .NetworkSettings.Networks}}{{range \$conf.Aliases}}{{printf \"--network-alias %s \" .}}{{end}}{{end}}" "$CONTAINER_ID")
[ -n "$ALIASES" ] && CMD="$CMD $ALIASES"
fi
# --- 重启策略 ---
RESTART=$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER_ID")
[ -n "$RESTART" ] && [ "$RESTART" != "no" ] && CMD="$CMD --restart ${RESTART}"
# --- ExtraHosts (/etc/hosts) ---
EXTRA_HOSTS=$(docker inspect --format '{{range .HostConfig.ExtraHosts}}{{printf "--add-host \"%s\" " .}}{{end}}' "$CONTAINER_ID")
[ -n "$EXTRA_HOSTS" ] && CMD="$CMD $EXTRA_HOSTS"
# --- DNS ---
DNS=$(docker inspect --format '{{range .HostConfig.Dns}}{{printf "--dns %s " .}}{{end}}' "$CONTAINER_ID")
[ -n "$DNS" ] && CMD="$CMD $DNS"
# --- 日志驱动 ---
LOG_DRIVER=$(docker inspect --format '{{.HostConfig.LogConfig.Type}}' "$CONTAINER_ID")
if [ -n "$LOG_DRIVER" ] && [ "$LOG_DRIVER" != "json-file" ]; then
CMD="$CMD --log-driver ${LOG_DRIVER}"
LOG_OPTS=$(docker inspect --format '{{range $k, $v := .HostConfig.LogConfig.Config}}{{printf "--log-opt %s=%s " $k $v}}{{end}}' "$CONTAINER_ID")
[ -n "$LOG_OPTS" ] && CMD="$CMD $LOG_OPTS"
fi
# --- 资源限制 ---
NANOCPUS=$(docker inspect --format '{{.HostConfig.NanoCpus}}' "$CONTAINER_ID")
if [ -n "$NANOCPUS" ] && [ "$NANOCPUS" != "0" ]; then
CPUS=$(echo "scale=2; $NANOCPUS / 1000000000" | bc)
CMD="$CMD --cpus ${CPUS}"
fi
MEMORY=$(docker inspect --format '{{.HostConfig.Memory}}' "$CONTAINER_ID")
if [ -n "$MEMORY" ] && [ "$MEMORY" != "0" ]; then
MEMORY_MB=$((MEMORY / 1024 / 1024))
if [ $MEMORY_MB -ge 1024 ]; then
MEMORY_GB=$(echo "scale=1; $MEMORY_MB / 1024" | bc)
CMD="$CMD --memory ${MEMORY_GB}g"
else
CMD="$CMD --memory ${MEMORY_MB}m"
fi
fi
MEMORY_SWAP=$(docker inspect --format '{{.HostConfig.MemorySwap}}' "$CONTAINER_ID")
if [ -n "$MEMORY_SWAP" ] && [ "$MEMORY_SWAP" != "0" ]; then
SWAP_MB=$((MEMORY_SWAP / 1024 / 1024))
[ $SWAP_MB -ge 1024 ] && CMD="$CMD --memory-swap $(echo "scale=1; $SWAP_MB/1024" | bc)g" || CMD="$CMD --memory-swap ${SWAP_MB}m"
fi
MEM_RESERVATION=$(docker inspect --format '{{.HostConfig.MemoryReservation}}' "$CONTAINER_ID")
if [ -n "$MEM_RESERVATION" ] && [ "$MEM_RESERVATION" != "0" ]; then
RES_MB=$((MEM_RESERVATION / 1024 / 1024))
CMD="$CMD --memory-reservation ${RES_MB}m"
fi
# --- 工作目录 ---
WORKDIR=$(docker inspect --format '{{.Config.WorkingDir}}' "$CONTAINER_ID")
[ -n "$WORKDIR" ] && CMD="$CMD --workdir ${WORKDIR}"
# --- 入口点和命令 ---
ENTRYPOINT=$(docker inspect --format '{{json .Config.Entrypoint}}' "$CONTAINER_ID")
if [ "$ENTRYPOINT" != "null" ] && [ "$ENTRYPOINT" != "[]" ]; then
EP_CLEAN=$(echo "$ENTRYPOINT" | sed 's/\[//g; s/\]//g; s/"//g; s/,/ /g')
CMD="$CMD --entrypoint ${EP_CLEAN}"
fi
CMD_ARGS=$(docker inspect --format '{{json .Config.Cmd}}' "$CONTAINER_ID")
if [ "$CMD_ARGS" != "null" ] && [ "$CMD_ARGS" != "[]" ]; then
CMD_CLEAN=$(echo "$CMD_ARGS" | sed 's/\[//g; s/\]//g; s/"//g; s/,/ /g')
CMD="$CMD ${CMD_CLEAN}"
fi
# --- 镜像名(最后)---
CMD="$CMD ${IMAGE}"
echo "$CMD" > "${OUTPUT_DIR}/${NAME}_run.sh"
chmod +x "${OUTPUT_DIR}/${NAME}_run.sh"
echo " -> ${OUTPUT_DIR}/${NAME}_run.sh"
done
echo ""
echo "所有 docker run 命令已生成到: $OUTPUT_DIR/"
3.4 审查生成的命令
生成后务必逐个审查,确认以下关键项:
# 查看生成的命令
cat /tmp/migration/容器名_run.sh
审查清单:
| 检查项 | 说明 |
|---|---|
| 镜像名 | 确认镜像标签正确 |
| 端口映射 | -p 参数与原始一致 |
| 卷挂载 | -v 路径在 B 上是否存在或需要创建 |
| 环境变量 | -e 参数完整,无遗漏 |
| 网络 | --network 和 --ip 正确 |
| 资源限制 | --memory、--cpus 与原始一致 |
| 重启策略 | --restart 策略正确 |
| 特殊配置 | --privileged、--user、--add-host 等 |
4. 阶段三:迁移镜像
4.1 导出镜像(服务器 A)
# 获取所有运行中容器使用的唯一镜像
IMAGES=$(docker ps --format '{{.Image}}' | sort -u)
echo "需要迁移的镜像:"
echo "$IMAGES"
# 导出所有镜像到一个压缩包
docker save $IMAGES | gzip > /tmp/migration/images.tar.gz
# 查看大小
ls -lh /tmp/migration/images.tar.gz
4.2 传输镜像到服务器 B
方法一:rsync(推荐,支持断点续传)
rsync -avz --progress /tmp/migration/images.tar.gz user@server-b:/tmp/migration/
方法二:SSH 管道直接传输(无需中间文件)
docker save $IMAGES | gzip | ssh user@server-b "cat > /tmp/migration/images.tar.gz"
4.3 导入镜像(服务器 B)
# 在服务器 B 上执行
gunzip -c /tmp/migration/images.tar.gz | docker load
# 验证镜像已导入
docker images
5. 阶段四:迁移数据卷与绑定挂载
5.1 迁移命名卷(Named Volumes)
在服务器 A 上备份:
#!/bin/bash
# backup-volumes.sh - 备份所有命名卷
BACKUP_DIR="/tmp/migration/volumes"
mkdir -p "$BACKUP_DIR"
# 获取所有被容器使用的命名卷
for VOL in $(docker volume ls -q); do
# 检查卷是否被使用
CONTAINERS=$(docker ps -a --filter "volume=$VOL" --format '{{.Names}}' | tr '\n' ',')
if [ -n "$CONTAINERS" ]; then
echo "备份卷: $VOL (使用者: $CONTAINERS)"
else
echo "备份卷: $VOL (未使用)"
fi
# 使用临时容器创建压缩归档
docker run --rm \
-v "${VOL}:/source:ro" \
-v "${BACKUP_DIR}:/backup" \
alpine tar czf "/backup/${VOL}.tar.gz" -C /source .
done
echo "卷备份完成: $BACKUP_DIR/"
ls -lh "$BACKUP_DIR/"
传输到服务器 B:
rsync -avz --progress /tmp/migration/volumes/ user@server-b:/tmp/migration/volumes/
在服务器 B 上恢复:
#!/bin/bash
# restore-volumes.sh - 在服务器 B 上恢复命名卷
VOLUME_DIR="/tmp/migration/volumes"
for ARCHIVE in "$VOLUME_DIR"/*.tar.gz; do
VOL_NAME=$(basename "$ARCHIVE" .tar.gz)
echo "恢复卷: $VOL_NAME"
# 创建卷
docker volume create "$VOL_NAME"
# 恢复数据
docker run --rm \
-v "${VOL_NAME}:/target" \
-v "${VOLUME_DIR}:/backup:ro" \
alpine tar xzf "/backup/${VOL_NAME}.tar.gz" -C /target
echo " 完成: $VOL_NAME"
done
echo "所有卷已恢复"
docker volume ls
5.2 迁移绑定挂载(Bind Mounts)
绑定挂载是宿主机目录,需要用 rsync 同步。
步骤 1:在服务器 A 上识别所有绑定挂载
#!/bin/bash
# list-bind-mounts.sh - 列出所有绑定挂载
echo "=== 绑定挂载清单 ==="
for C in $(docker ps --format '{{.Names}}'); do
MOUNTS=$(docker inspect "$C" --format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}->{{.Destination}} {{end}}{{end}}')
if [ -n "$MOUNTS" ]; then
echo "容器: $C"
echo " $MOUNTS"
fi
done
步骤 2:首次同步(零停机阶段)
# 同步各个绑定挂载目录到服务器 B
# --numeric-ids 保留 UID/GID,不进行名称映射(关键!)
# 示例:根据实际路径替换
rsync -avz --progress --numeric-ids /opt/app/data/ user@server-b:/opt/app/data/
rsync -avz --progress --numeric-ids /opt/app/config/ user@server-b:/opt/app/config/
rsync -avz --progress --numeric-ids /var/lib/mysql/ user@server-b:/var/lib/mysql/
步骤 3:最终增量同步(停机阶段)
# 停止容器后,执行最终增量同步(仅传输变更部分,速度很快)
rsync -avz --progress --numeric-ids /opt/app/data/ user@server-b:/opt/app/data/
rsync -avz --progress --numeric-ids /opt/app/config/ user@server-b:/opt/app/config/
rsync -avz --progress --numeric-ids /var/lib/mysql/ user@server-b:/var/lib/mysql/
5.3 在服务器 B 上创建挂载目录
#!/bin/bash
# create-mount-dirs.sh - 在服务器 B 上创建所有需要的挂载目录
# 根据服务器 A 的绑定挂载清单,在 B 上创建对应目录
# 确保目录权限和所有者与 A 一致
mkdir -p /opt/app/data
mkdir -p /opt/app/config
mkdir -p /var/lib/mysql
mkdir -p /var/log/myapp
# ... 根据实际情况添加
# 设置正确的所有者(根据 A 上的 UID/GID)
# 查看方法:在 A 上执行 ls -lan /opt/app/data/
# 例如:UID=1000, GID=1000
chown -R 1000:1000 /opt/app/data
chown -R 1000:1000 /opt/app/config
chown -R 999:999 /var/lib/mysql # MySQL 通常使用 999
chown -R 1000:1000 /var/log/myapp
5.4 数据库容器的特殊处理
对于数据库容器(MySQL、PostgreSQL 等),需要额外确保数据一致性:
PostgreSQL:
# 在服务器 A 上(容器运行时)
docker exec postgres_db pg_dumpall -U postgres > /tmp/migration/pg_backup.sql
# 传输
scp /tmp/migration/pg_backup.sql user@server-b:/tmp/migration/
# 在服务器 B 上(容器启动后)
docker exec -i postgres_db psql -U postgres < /tmp/migration/pg_backup.sql
MySQL:
# 在服务器 A 上(容器运行时)
docker exec mysql_db mysqldump -u root -p"密码" --all-databases > /tmp/migration/mysql_backup.sql
# 传输
scp /tmp/migration/mysql_backup.sql user@server-b:/tmp/migration/
# 在服务器 B 上(容器启动后)
docker exec -i mysql_db mysql -u root -p"密码" < /tmp/migration/mysql_backup.sql
6. 阶段五:迁移网络配置
6.1 导出网络配置
#!/bin/bash
# export-networks.sh - 导出自定义网络配置
echo "=== 自定义网络 ==="
docker network ls --filter type=custom
for NET in $(docker network ls --filter type=custom --format '{{.Name}}'); do
echo ""
echo "--- 网络: $NET ---"
docker network inspect "$NET" --format '
驱动: {{.Driver}}
子网: {{range .IPAM.Config}}{{.Subnet}} {{end}}
网关: {{range .IPAM.Config}}{{.Gateway}} {{end}}
范围: {{range .IPAM.Config}}{{.IPRange}} {{end}}'
done
6.2 在服务器 B 上重建网络
# 根据导出的网络配置,在 B 上创建相同的网络
# 示例:创建自定义桥接网络
docker network create \
--driver=bridge \
--subnet=172.20.0.0/16 \
--gateway=172.20.0.1 \
my_custom_network
# 如果有多个网络,逐一创建
# docker network create --driver=bridge --subnet=172.21.0.0/16 another_network
7. 阶段六:在服务器 B 重建容器
7.1 停止服务器 A 上的容器
# 记录停机开始时间
echo "停机开始: $(date '+%Y-%m-%d %H:%M:%S')"
# 停止所有应用容器(按依赖顺序,先停应用再停数据库)
docker stop app1 app2 web_frontend
docker stop postgres_db redis_cache
# 确认全部停止
docker ps
7.2 最终数据同步
# 最终增量同步绑定挂载(此时容器已停止,数据一致)
rsync -avz --progress --numeric-ids /opt/app/data/ user@server-b:/opt/app/data/
rsync -avz --progress --numeric-ids /opt/app/config/ user@server-b:/opt/app/config/
# 最终备份命名卷
for VOL in $(docker volume ls -q); do
docker run --rm \
-v "${VOL}:/source:ro" \
-v /tmp/migration/volumes:/backup \
alpine tar czf "/backup/${VOL}.tar.gz" -C /source .
done
# 传输最终卷数据
rsync -avz --progress /tmp/migration/volumes/ user@server-b:/tmp/migration/volumes/
7.3 在服务器 B 上恢复最终数据
# 恢复命名卷(覆盖之前的预同步数据)
for ARCHIVE in /tmp/migration/volumes/*.tar.gz; do
VOL_NAME=$(basename "$ARCHIVE" .tar.gz)
docker volume create "$VOL_NAME" 2>/dev/null # 已存在则忽略
docker run --rm \
-v "${VOL_NAME}:/target" \
-v /tmp/migration/volumes:/backup:ro \
alpine sh -c "rm -rf /target/* && tar xzf /backup/${VOL_NAME}.tar.gz -C /target"
done
7.4 启动容器(按依赖顺序)
# 在服务器 B 上,按照依赖关系的逆序启动
# 先启动基础设施(数据库、缓存),再启动应用
# 1. 启动数据库
bash /tmp/migration/postgres_db_run.sh
# 2. 启动缓存
bash /tmp/migration/redis_cache_run.sh
# 3. 等待数据库就绪
sleep 5
# 4. 启动应用
bash /tmp/migration/app1_run.sh
bash /tmp/migration/app2_run.sh
# 5. 启动前端
bash /tmp/migration/web_frontend_run.sh
# 记录停机结束时间
echo "停机结束: $(date '+%Y-%m-%d %H:%M:%S')"
注意:如果生成的
docker run命令中卷路径与服务器 B 上的实际路径不一致,需要手动编辑.sh文件修正路径后再执行。
8. 阶段七:验证与收尾
8.1 运行验证脚本
在服务器 B 上执行:
#!/bin/bash
# verify-migration.sh - 迁移后全面验证
echo "=========================================="
echo " 迁移验证报告"
echo " 服务器: $(hostname) ($(hostname -I | awk '{print $1}'))"
echo " 时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="
# 1. 容器状态
echo ""
echo "【容器状态】"
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
# 2. 健康检查
echo ""
echo "【健康检查】"
for CID in $(docker ps -q); do
NAME=$(docker inspect --format '{{.Name}}' "$CID" | sed 's/^\//')
HEALTH=$(docker inspect --format '{{.State.Health.Status}}' "$CID" 2>/dev/null || echo "无健康检查")
echo " $NAME: $HEALTH"
done
# 3. 卷挂载验证
echo ""
echo "【卷挂载】"
for CID in $(docker ps -q); do
NAME=$(docker inspect --format '{{.Name}}' "$CID" | sed 's/^\//')
echo " $NAME:"
docker inspect "$CID" --format '{{range .Mounts}} [{{.Type}}] {{.Source}} -> {{.Destination}}{{end}}'
done
# 4. 网络连接
echo ""
echo "【网络】"
for CID in $(docker ps -q); do
NAME=$(docker inspect --format '{{.Name}}' "$CID" | sed 's/^\//')
INFO=$(docker inspect --format '{{range $net, $conf := .NetworkSettings.Networks}}{{$net}}: {{$conf.IPAddress}} {{end}}' "$CID")
echo " $NAME: $INFO"
done
# 5. 资源限制
echo ""
echo "【资源限制】"
for CID in $(docker ps -q); do
NAME=$(docker inspect --format '{{.Name}}' "$CID" | sed 's/^\//')
MEM=$(docker inspect --format '{{.HostConfig.Memory}}' "$CID")
CPU=$(docker inspect --format '{{.HostConfig.NanoCpus}}' "$CID")
[ "$MEM" != "0" ] && echo " $NAME: 内存=$(($MEM/1024/1024))MB"
[ "$CPU" != "0" ] && echo " $NAME: CPU=$(echo "scale=2; $CPU/1000000000" | bc)核"
done
# 6. 错误日志检查
echo ""
echo "【最近错误日志】"
for CID in $(docker ps -q); do
NAME=$(docker inspect --format '{{.Name}}' "$CID" | sed 's/^\//')
ERRORS=$(docker logs --tail 50 "$CID" 2>&1 | grep -i "error\|fatal\|panic\|exception" | tail -3)
if [ -n "$ERRORS" ]; then
echo " [!] $NAME:"
echo "$ERRORS" | sed 's/^/ /'
else
echo " [OK] $NAME: 无错误"
fi
done
# 7. 端口连通性测试
echo ""
echo "【端口测试】"
for PORT in 80 443 8080 3000 3306 5432 6379; do
RESULT=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$PORT/" 2>/dev/null || echo "N/A")
if [ "$RESULT" != "N/A" ]; then
echo " localhost:$PORT -> HTTP $RESULT"
fi
done
echo ""
echo "=========================================="
echo " 验证完成"
echo "=========================================="
8.2 在服务器 B 部署 Portainer
# 在服务器 B 上部署 Portainer(与 A 相同的配置)
docker volume create portainer_data
docker run -d \
-p 8000:8000 \
-p 9443:9443 \
--name portainer \
--restart=always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v portainer_data:/data \
portainer/portainer-ce:lts
# 访问 https://<服务器B的IP>:9443 进行管理
8.3 收尾
# 1. 确认服务器 B 一切正常后,更新 DNS/客户端配置指向 B
# 2. 观察运行 24-48 小时无异常后,关闭服务器 A 上的容器
# 3. (可选)清理服务器 A 上的旧容器和镜像
9. 附录:完整自动化迁移脚本
以下是一个端到端的自动化脚本,将上述所有步骤整合在一起。建议先在测试环境验证后再用于生产。
#!/bin/bash
# ============================================================
# Docker 容器迁移脚本(A -> B,不使用 Compose)
# 用法: ./migrate-all.sh <服务器B的IP> [SSH用户]
# ============================================================
set -euo pipefail
DEST_SERVER="${1:?用法: $0 <服务器B_IP> [SSH用户]}"
DEST_USER="${2:-root}"
MIGRATION_DIR="/tmp/migration"
SSH_OPTS="-o StrictHostKeyChecking=no"
echo "=========================================="
echo " Docker 容器迁移"
echo " 源: $(hostname) ($(hostname -I | awk '{print $1}'))"
echo " 目标: ${DEST_USER}@${DEST_SERVER}"
echo " 时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="
# ---- 准备阶段(零停机)----
echo ""
echo ">>> [1/8] 创建迁移目录..."
mkdir -p "$MIGRATION_DIR/volumes"
ssh $SSH_OPTS "${DEST_USER}@${DEST_SERVER}" "mkdir -p $MIGRATION_DIR/volumes"
echo ">>> [2/8] 导出容器配置..."
for CID in $(docker ps -q); do
NAME=$(docker inspect --format '{{.Name}}' "$CID" | sed 's/^\//')
docker inspect "$CID" > "${MIGRATION_DIR}/${NAME}_inspect.json"
echo " 导出: $NAME"
done
echo ">>> [3/8] 导出镜像..."
IMAGES=$(docker ps --format '{{.Image}}' | sort -u | tr '\n' ' ')
docker save $IMAGES | gzip > "${MIGRATION_DIR}/images.tar.gz"
echo " 镜像大小: $(du -h ${MIGRATION_DIR}/images.tar.gz | cut -f1)"
echo ">>> [4/8] 备份命名卷..."
for VOL in $(docker volume ls -q); do
docker run --rm \
-v "${VOL}:/source:ro" \
-v "${MIGRATION_DIR}/volumes:/backup" \
alpine tar czf "/backup/${VOL}.tar.gz" -C /source .
echo " 备份: $VOL"
done
echo ">>> [5/8] 首次同步绑定挂载..."
# 自动提取绑定挂载路径并同步
BIND_DIRS=$(docker ps -q | xargs docker inspect --format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}} {{end}}{{end}}' | tr ' ' '\n' | sort -u | grep -v '^$')
for DIR in $BIND_DIRS; do
echo " 同步: $DIR"
rsync -az --numeric-ids "${DIR}/" "${DEST_USER}@${DEST_SERVER}:${DIR}/" 2>/dev/null || \
echo " [警告] 同步失败: $DIR(目标目录可能不存在,将在下一步创建)"
done
echo ">>> [6/8] 传输镜像和卷数据到 B..."
rsync -az --progress "${MIGRATION_DIR}/images.tar.gz" "${DEST_USER}@${DEST_SERVER}:${MIGRATION_DIR}/"
rsync -az --progress "${MIGRATION_DIR}/volumes/" "${DEST_USER}@${DEST_SERVER}:${MIGRATION_DIR}/volumes/"
echo ">>> [7/8] 在 B 上准备环境..."
ssh $SSH_OPTS "${DEST_USER}@${DEST_SERVER}" bash << 'REMOTE_SCRIPT'
# 导入镜像
echo " 导入镜像..."
gunzip -c /tmp/migration/images.tar.gz | docker load
# 恢复命名卷
echo " 恢复命名卷..."
for ARCHIVE in /tmp/migration/volumes/*.tar.gz; do
VOL_NAME=$(basename "$ARCHIVE" .tar.gz)
docker volume create "$VOL_NAME" 2>/dev/null
docker run --rm \
-v "${VOL_NAME}:/target" \
-v /tmp/migration/volumes:/backup:ro \
alpine tar xzf "/backup/${VOL_NAME}.tar.gz" -C /target
echo " 恢复: $VOL_NAME"
done
# 创建绑定挂载目录
echo " 创建挂载目录..."
REMOTE_SCRIPT
# 在 B 上创建绑定挂载目录并设置权限
for DIR in $BIND_DIRS; do
# 获取源目录的 UID/GID
DIR_UID=$(stat -c '%u' "$DIR" 2>/dev/null || echo "0")
DIR_GID=$(stat -c '%g' "$DIR" 2>/dev/null || echo "0")
ssh $SSH_OPTS "${DEST_USER}@${DEST_SERVER}" "mkdir -p ${DIR} && chown ${DIR_UID}:${DIR_GID} ${DIR}"
echo " 创建: $DIR (UID=${DIR_UID}, GID=${DIR_GID})"
done
# ---- 切换阶段(停机)----
echo ""
echo "=========================================="
echo " 即将停机!按 Ctrl+C 取消"
echo "=========================================="
sleep 5
echo ""
echo ">>> [停机] 停止所有容器..."
echo "停机开始: $(date '+%Y-%m-%d %H:%M:%S')"
docker stop $(docker ps -q)
echo ">>> [停机] 最终增量同步绑定挂载..."
for DIR in $BIND_DIRS; do
rsync -az --numeric-ids --delete "${DIR}/" "${DEST_USER}@${DEST_SERVER}:${DIR}/"
done
echo ">>> [停机] 最终备份命名卷..."
for VOL in $(docker volume ls -q); do
docker run --rm \
-v "${VOL}:/source:ro" \
-v "${MIGRATION_DIR}/volumes:/backup" \
alpine sh -c "rm -f /backup/${VOL}.tar.gz && tar czf /backup/${VOL}.tar.gz -C /source ."
done
rsync -az "${MIGRATION_DIR}/volumes/" "${DEST_USER}@${DEST_SERVER}:${MIGRATION_DIR}/volumes/"
echo ">>> [停机] 在 B 上恢复最终数据..."
ssh $SSH_OPTS "${DEST_USER}@${DEST_SERVER}" bash << 'REMOTE_SCRIPT'
for ARCHIVE in /tmp/migration/volumes/*.tar.gz; do
VOL_NAME=$(basename "$ARCHIVE" .tar.gz)
docker run --rm \
-v "${VOL_NAME}:/target" \
-v /tmp/migration/volumes:/backup:ro \
alpine sh -c "rm -rf /target/* && tar xzf /backup/${VOL_NAME}.tar.gz -C /target"
done
REMOTE_SCRIPT
echo ">>> [停机] 在 B 上启动容器..."
echo " 请手动执行以下命令(按依赖顺序):"
echo " cd ${MIGRATION_DIR} && bash *_run.sh"
echo ""
echo " 或者使用以下命令一键启动所有容器:"
for SCRIPT in ${MIGRATION_DIR}/*_run.sh; do
NAME=$(basename "$SCRIPT" _run.sh)
echo " bash ${MIGRATION_DIR}/${NAME}_run.sh"
done
echo ""
echo "停机结束: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
echo "=========================================="
echo " 迁移数据已准备完毕"
echo " 请在服务器 B 上执行 *_run.sh 启动容器"
echo " 然后运行验证脚本确认迁移成功"
echo "=========================================="
10. 附录:故障排查
10.1 常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 容器启动后立即退出 | 绑定挂载路径不存在或权限不对 | 检查 -v 路径是否存在,ls -la 确认权限 |
| 端口冲突 | B 上端口已被占用 | ss -tlnp 检查,修改 -p 映射或停止冲突服务 |
| 容器无法连接网络 | 自定义网络未创建 | 先 docker network create 再启动容器 |
| 数据丢失 | 命名卷未正确恢复 | 检查 docker volume inspect 确认挂载点 |
| 权限错误(Permission denied) | UID/GID 不匹配 | 使用 --numeric-ids 同步,手动 chown |
| 数据库启动失败 | 数据文件损坏或版本不匹配 | 使用 pg_dump/mysqldump 逻辑备份恢复 |
| 生成的 run 命令不完整 | Go 模板未覆盖某些参数 | 手动审查并补充缺失参数 |
10.2 回滚方案
如果迁移后发现问题需要回滚:
# 1. 在服务器 B 上停止所有容器
docker stop $(docker ps -q)
# 2. 在服务器 A 上重新启动容器
docker start $(docker ps -aq -f status=exited)
# 3. 验证 A 上服务恢复正常
docker ps
提示:在迁移完成并确认稳定运行 48 小时之前,不要删除服务器 A 上的任何数据。
10.3 docker inspect 关键字段速查
| inspect JSON 路径 | docker run 参数 | 说明 |
|---|---|---|
.Name |
--name |
容器名称 |
.Config.Image |
镜像名 | 使用的镜像 |
.Config.Env |
-e |
环境变量 |
.HostConfig.PortBindings |
-p |
端口映射 |
.HostConfig.Binds |
-v |
卷挂载 |
.HostConfig.RestartPolicy.Name |
--restart |
重启策略 |
.HostConfig.NetworkMode |
--network |
网络模式 |
.Config.Hostname |
--hostname |
主机名 |
.Config.Labels |
--label |
标签 |
.Config.User |
--user |
运行用户 |
.HostConfig.Privileged |
--privileged |
特权模式 |
.HostConfig.ExtraHosts |
--add-host |
/etc/hosts 条目 |
.HostConfig.NanoCpus |
--cpus |
CPU 限制 |
.HostConfig.Memory |
--memory |
内存限制 |
.HostConfig.LogConfig.Type |
--log-driver |
日志驱动 |
.Config.Entrypoint |
--entrypoint |
入口点 |
.Config.Cmd |
命令参数 | 容器命令 |
.Config.WorkingDir |
--workdir |
工作目录 |
更多推荐

所有评论(0)