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 工作目录
Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐