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 } "
}
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.
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"