Copying Docker Images to Another Machine

local registry + SSH reverse tunnel

Posted by Rico's Nerd Cluster on June 16, 2024

Why Not docker save?

docker save can deadlock or hang when the image is stored in the containerd image store (shows 0B in docker images). This is caused by futex contention in Go’s goroutine-based multi-threaded tar export.

Instead, the approach below:

  • Spins up a local ephemeral registry
  • Opens an SSH reverse tunnel to the remote
  • Has the remote host pull directly — no tar reconstruction involved

Full Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
_deploy_robot_image(){
    local remote_host="${1:-gpc1}"
    local image_name="code.hmech.us:5050/nautilus/common/dockers/toolkitt_robot:latest"
    local registry_port=5000
    local registry_name="_deploy_registry"
    local local_tag="localhost:${registry_port}/toolkitt_robot:latest"
    local orig_daemon_backup="/tmp/_deploy_daemon_orig.json"
    local tunnel_pid=""

    # ── Cleanup: always called on exit (success or failure) ──────────────────
    _deploy_cleanup() {
        [[ -n "$tunnel_pid" ]] && kill "$tunnel_pid" 2>/dev/null && wait "$tunnel_pid" 2>/dev/null
        docker stop  "$registry_name" 2>/dev/null
        docker rm    "$registry_name" 2>/dev/null
        docker rmi   "$local_tag"     2>/dev/null
        # Restore remote daemon.json if we changed it
        if [[ -f "$orig_daemon_backup" ]]; then
            cat > /tmp/_deploy_restore.sh << 'REOF'
#!/bin/bash
printf "fo'c'sle1\n" > /tmp/.sp && chmod 600 /tmp/.sp
sudo -S cp /tmp/_deploy_daemon_restore.json /etc/docker/daemon.json < /tmp/.sp
sudo -S systemctl reload docker < /tmp/.sp
rm -f /tmp/.sp /tmp/_deploy_daemon_restore.json /tmp/_deploy_restore.sh
REOF
            scp "$orig_daemon_backup"  "${remote_host}:/tmp/_deploy_daemon_restore.json" 2>/dev/null
            scp /tmp/_deploy_restore.sh "${remote_host}:/tmp/_deploy_restore.sh"          2>/dev/null
            ssh -n "$remote_host" "bash /tmp/_deploy_restore.sh" 2>/dev/null
            rm -f "$orig_daemon_backup" /tmp/_deploy_restore.sh /tmp/_deploy_daemon_new.json /tmp/_deploy_setup.sh
        fi
    }

    _print_header "Transferring $image_name to $remote_host"

    # NOTE: 'docker save' deadlocks when the image is stored in the containerd
    # image store (shows 0B in 'docker images'). We use a local registry +
    # SSH reverse tunnel instead — this is immune to that issue.

    local image_size_bytes
    image_size_bytes=$(docker image inspect --format='' "$image_name" 2>/dev/null || echo 0)
    local image_size_mb=$(( image_size_bytes / 1024 / 1024 ))
    echo "Image size:      ~${image_size_mb} MB (uncompressed)"
    echo "Transfer method: local registry → SSH reverse tunnel → remote pull"
    echo ""

    # Host gateway: the registry container is bound to host port $registry_port,
    # reachable from inside this dev container via the Docker bridge gateway IP.
    local host_gw
    host_gw=$(ip route | awk '/default/ {print $3; exit}')
    [[ -z "$host_gw" ]] && { _print_error "Could not determine host gateway IP"; return 1; }

    # ── Step 1: Start local registry ─────────────────────────────────────────
    echo "Starting local registry on port ${registry_port}..."
    docker rm -f "$registry_name" 2>/dev/null  # remove any leftover
    docker run -d --name "$registry_name" -p "${registry_port}:5000" registry:2 > /dev/null 2>&1 || {
        _print_error "Failed to start local registry (port ${registry_port} may be in use)"
        return 1
    }

    # ── Step 2: Tag and push to local registry ────────────────────────────────
    docker tag "$image_name" "$local_tag" 2>/dev/null || {
        _deploy_cleanup; _print_error "Failed to tag image"; return 1
    }
    echo "Pushing to local registry (~${image_size_mb} MB — takes a few minutes)..."
    docker push "$local_tag" | tail -1 || {
        _deploy_cleanup; _print_error "Failed to push to local registry"; return 1
    }
    echo -e "${GREEN}✓ Image staged in local registry${NC}"
    echo ""

    # ── Step 3: Configure insecure-registries on remote ──────────────────────
    echo "Configuring ${remote_host} Docker daemon (insecure-registries)..."
    ssh -n "$remote_host" "cat /etc/docker/daemon.json 2>/dev/null || echo '{}'" > "$orig_daemon_backup"

    python3 - "$registry_port" "$orig_daemon_backup" << 'PYEOF' > /tmp/_deploy_daemon_new.json
import json, sys
port = sys.argv[1]
with open(sys.argv[2]) as f:
    d = json.load(f)
regs = d.setdefault("insecure-registries", [])
entry = f"localhost:{port}"
if entry not in regs:
    regs.append(entry)
print(json.dumps(d, indent=4))
PYEOF

    cat > /tmp/_deploy_setup.sh << 'SEOF'
#!/bin/bash
printf "fo'c'sle1\n" > /tmp/.sp && chmod 600 /tmp/.sp
sudo -S cp /tmp/_deploy_daemon_new.json /etc/docker/daemon.json < /tmp/.sp
sudo -S systemctl reload docker < /tmp/.sp
rm -f /tmp/.sp /tmp/_deploy_daemon_new.json /tmp/_deploy_setup.sh
echo DAEMON_CONFIGURED
SEOF
    scp /tmp/_deploy_daemon_new.json "${remote_host}:/tmp/_deploy_daemon_new.json" 2>/dev/null
    scp /tmp/_deploy_setup.sh        "${remote_host}:/tmp/_deploy_setup.sh"        2>/dev/null
    local daemon_result
    daemon_result=$(ssh -n "$remote_host" "bash /tmp/_deploy_setup.sh" 2>/dev/null)
    [[ "$daemon_result" != *"DAEMON_CONFIGURED"* ]] && {
        _deploy_cleanup; _print_error "Failed to configure daemon on ${remote_host}"; return 1
    }
    echo -e "${GREEN}✓ Remote daemon configured${NC}"

    # ── Step 4: SSH reverse tunnel ────────────────────────────────────────────
    echo "Opening SSH reverse tunnel (${remote_host}:${registry_port}${host_gw}:${registry_port})..."
    ssh -N -R "${registry_port}:${host_gw}:${registry_port}" "$remote_host" &
    tunnel_pid=$!
    sleep 3

    ssh -n "$remote_host" "timeout 3 curl -sf http://localhost:${registry_port}/v2/ > /dev/null" || {
        _deploy_cleanup
        _print_error "Tunnel verification failed — is port ${registry_port} already in use on ${remote_host}?"
        return 1
    }
    echo -e "${GREEN}✓ Tunnel verified${NC}"
    echo ""

    # ── Step 5: Pull on remote and retag ─────────────────────────────────────
    echo "Pulling on ${remote_host}..."
    local pull_output
    pull_output=$(ssh -n "$remote_host" "
        docker pull localhost:${registry_port}/toolkitt_robot:latest 2>&1 && \
        docker tag  localhost:${registry_port}/toolkitt_robot:latest ${image_name} 2>&1 && \
        docker rmi  localhost:${registry_port}/toolkitt_robot:latest 2>/dev/null; \
        echo PULL_COMPLETE
    " 2>&1)

    _deploy_cleanup

    if [[ "$pull_output" != *"PULL_COMPLETE"* ]]; then
        _print_error "Pull failed on ${remote_host}"
        echo "$pull_output" | tail -5
        return 1
    fi

    local remote_id
    remote_id=$(ssh -n "$remote_host" "docker images ${image_name} --format ''" 2>/dev/null)
    echo -e "${GREEN}✓ Image '$image_name' loaded on $remote_host (ID: ${remote_id})${NC}"
}

Case Study: Move toolkitt_robot to a Remote Host

This is the same flow shown in the script above, reorganized as a practical checklist.

Step 0 - Resolve host gateway (when running in a dev container)

1
host_gw=$(ip route | awk '/default/ {print $3; exit}')

Inside a dev container, localhost points to the container, not the host. Since the registry is bound on the host side, the reverse tunnel must target the bridge gateway IP.

Step 1 - Start a local ephemeral registry

1
docker run -d --name "$registry_name" -p "${registry_port}:5000" registry:2

This launches a throwaway registry on localhost:5000.

Step 2 - Retag and push image into that registry

1
2
docker tag "$image_name" "$local_tag"
docker push "$local_tag"

Tag mapping:

1
2
code.hmech.us:5050/nautilus/common/dockers/toolkitt_robot:latest
    -> localhost:5000/toolkitt_robot:latest

Step 3 - Allow insecure registry on remote Docker daemon

The local registry here is plain HTTP (no TLS). The script temporarily updates remote /etc/docker/daemon.json to include:

1
"insecure-registries": ["localhost:5000"]

Then it reloads Docker, and restores the original daemon config during cleanup.

Step 4 - Open and verify SSH reverse tunnel

1
ssh -N -R "${registry_port}:${host_gw}:${registry_port}" "$remote_host" &

This creates a listening port on the remote host and forwards it back to your local registry. From the remote side, localhost:5000 now reaches your local machine.

Verification:

1
ssh -n "$remote_host" "timeout 3 curl -sf http://localhost:${registry_port}/v2/ > /dev/null"

Step 5 - Pull on remote, retag, and remove temporary tag

1
2
3
docker pull localhost:5000/toolkitt_robot:latest
docker tag  localhost:5000/toolkitt_robot:latest ${image_name}
docker rmi  localhost:5000/toolkitt_robot:latest

The remote pulls through the tunnel, keeps the original image name, and drops the temporary localhost tag.

Package and build-tool notes

1
2
3
4
git \
sudo \
pkg-config \
libapr1-dev \
  • sudo comes from an apt package. Without it, sudo ... fails with “command not found”. In many containers you are already root, so sudo is often unnecessary.
  • pkg-config is a build-time helper that reports include and linker flags.
  • libapr1-dev is the APR development package; -dev packages are typically required when compiling against that library.

Environment variables: prepend vs replace

1
2
3
4
-ENV CMAKE_PREFIX_PATH=${PCL_PREFIX}:$CMAKE_PREFIX_PATH \
-LD_LIBRARY_PATH=${PCL_PREFIX}/lib:$LD_LIBRARY_PATH
+ENV CMAKE_PREFIX_PATH=${PCL_PREFIX} \
+LD_LIBRARY_PATH=${PCL_PREFIX}/lib
  • CMAKE_PREFIX_PATH helps CMake find packages, headers, and config files.
  • LD_LIBRARY_PATH helps the runtime linker find .so files.
  • Replacing instead of prepending can hide previously configured paths, so choose intentionally.

cp -f and cp -rf

1
2
cp -f package_ROS2.xml package.xml && \
cp -rf launch_ROS2 launch && \
  • cp -f forces overwrite of destination files.
  • cp -rf recursively copies directories and force-overwrites destination entries.

Alternative Transfer Path: Stream Tar Over SSH

If docker save is healthy for your image store, this direct pipeline is simpler:

1
docker save "${IMAGE_NAME}" | ssh "${TARGET}" "docker load"
  • docker save writes a tar stream to stdout.
  • docker load on the remote reads that stream and imports the image.

Equivalent explicit workflow:

1
2
3
docker save "$IMAGE_NAME" > image.tar
scp image.tar "${TARGET}:/tmp/"
ssh "$TARGET" "docker load < /tmp/image.tar"