Skip to content

Commit c2dbd48

Browse files
committed
setup_docker: make LXC resource limits visible to nested containers
Inside an LXC container, Docker gives every container it starts a fresh procfs in its own mount namespace. The lxcfs bind mounts that LXC placed on /proc/meminfo and friends live in the CT's namespace and never reach it, so each Docker container reads the physical host's totals instead of the CT's limits: CT sees: MemTotal: 8388608 kB container sees: MemTotal: 65648168 kB Enforcement was never affected, since the CT cgroup still caps every descendant. Visibility was, and anything that self-sizes from those numbers - JVM heap ergonomics, Node, Go, OpenMP thread pools - sizes against the wrong value and gets OOM-killed instead of throttling. Register a small runc wrapper as Docker's default runtime. On each container create it re-binds the lxcfs-backed files into the container and, when the container sets no limit of its own, applies the CT's memory limit to the container cgroup. Both halves are needed because runtimes read the limit two different ways: file readers use /proc, while musl and Node's uv_get_constrained_memory() consult the cgroup. CPU needs no handling - cpuset is inherited down the cgroup tree, so nested containers already observe the CT's core count. The one exception is /sys/devices/system/cpu/online, which glibc's sysconf(_SC_NPROCESSORS_ONLN) reads, so it is bound alongside the /proc files. The helper is a no-op outside an LXC container and wherever lxcfs is not mounted, so the five non-Docker installers that call setup_docker are unaffected. It fails open: no runc, no jq, or malformed JSON all fall through to the real runc unmodified, degrading to current behaviour rather than breaking the daemon. Opt out with DOCKER_LXCFS_VISIBILITY=false. Regenerate lib/API.txt for the new function.
1 parent 3eede83 commit c2dbd48

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

lib/API.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ _deploy_source_tarball
99
_deploy_unpacked_archive
1010
_diagnose_deb_failure
1111
_docker_is_noninteractive
12+
_docker_setup_lxcfs_visibility
1213
_download_source_tarball
1314
_forge_asset_urls
1415
_forge_descriptor

lib/runtime.func

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ setup_composer() {
140140
# DOCKER_PORTAINER - Install Portainer CE (optional, "true" to enable)
141141
# DOCKER_LOG_DRIVER - Log driver (optional, default: "journald")
142142
# DOCKER_SKIP_UPDATES - Skip container update check (optional, "true" to skip)
143+
# DOCKER_LXCFS_VISIBILITY - Set to "false" to skip making LXC resource limits
144+
# visible to nested containers (default: true)
143145
#
144146
# Features:
145147
# - Uses official Docker repository by default
@@ -217,6 +219,161 @@ ensure_docker() {
217219
fi
218220
}
219221

222+
# ------------------------------------------------------------------------------
223+
# LXC resource visibility for nested Docker containers
224+
#
225+
# Description:
226+
# Inside an LXC container, Docker gives every container it starts a fresh
227+
# procfs in its own mount namespace. The lxcfs bind mounts that LXC placed on
228+
# /proc/meminfo & friends live in the CT's namespace and never reach it, so
229+
# each Docker container reads the *physical host's* totals rather than the
230+
# CT's limits:
231+
#
232+
# CT sees: MemTotal: 8388608 kB
233+
# container sees: MemTotal: 65648168 kB <- the whole hypervisor
234+
#
235+
# Enforcement is not affected (the CT cgroup still caps every descendant),
236+
# but anything that self-sizes from those numbers - JVM heap ergonomics,
237+
# Node, Go, OpenMP thread pools - sizes against the wrong value and gets
238+
# OOM-killed instead of throttling.
239+
#
240+
# This registers a small runc wrapper as Docker's default runtime. On each
241+
# container create it re-binds the lxcfs-backed files into the container and,
242+
# when the container sets no limit of its own, applies the CT's memory limit
243+
# to the container cgroup. Two mechanisms are needed because runtimes read
244+
# the limit two different ways: file readers use /proc, while musl and
245+
# Node's uv_get_constrained_memory() consult the cgroup.
246+
#
247+
# CPU needs no handling - cpuset is inherited down the cgroup tree, so
248+
# nested containers already observe the CT's core count.
249+
#
250+
# Variables:
251+
# DOCKER_LXCFS_VISIBILITY - Set to "false" to skip (default: true)
252+
#
253+
# Notes:
254+
# - No-op outside an LXC container, and on any host where lxcfs is absent.
255+
# - `docker run --runtime=runc` bypasses the wrapper for a single container.
256+
# - An explicit --memory / mem_limit is never overridden.
257+
# - The wrapper falls through to the real runc unmodified on any error, so a
258+
# failure degrades to today's behaviour rather than breaking the daemon.
259+
# - The limit is written at container creation. After changing the CT's
260+
# memory with `pct set`, existing containers keep the old value until they
261+
# are recreated, or re-synced with:
262+
# docker update --memory=<new> --memory-swap=<new> <container>
263+
# - Monitoring containers are the one case that wants the opposite. lxcfs
264+
# resolves *usage* by the reading process's own cgroup, so a container that
265+
# graphs "system memory used" from /proc/meminfo will report only its own
266+
# footprint once these binds are in place. Total is correct, used is not.
267+
# Run those with `--runtime=runc` (compose: `runtime: runc`). Tools that
268+
# read the Docker API instead - Portainer's dashboard, Arcane's Docker
269+
# info panel - are unaffected, because the daemon reads the CT's values.
270+
# CPU is not affected either way: /proc/stat is not cgroup-scoped.
271+
# ------------------------------------------------------------------------------
272+
_docker_setup_lxcfs_visibility() {
273+
local enabled="${DOCKER_LXCFS_VISIBILITY:-true}"
274+
case "${enabled,,}" in
275+
false | 0 | no) return 0 ;;
276+
esac
277+
278+
# Only meaningful inside an LXC container whose /proc is served by lxcfs
279+
grep -qs 'fuse\.lxcfs' /proc/mounts || return 0
280+
281+
if ! command -v runc >/dev/null 2>&1; then
282+
msg_warn "runc not found - skipping LXC resource visibility for Docker"
283+
return 0
284+
fi
285+
286+
if ! command -v jq >/dev/null 2>&1; then
287+
$STD apt-get install -y jq || {
288+
msg_warn "jq unavailable - skipping LXC resource visibility for Docker"
289+
return 0
290+
}
291+
fi
292+
293+
msg_info "Configuring LXC resource visibility for Docker"
294+
295+
cat <<'RUNC_LXCFS' >/usr/local/bin/runc-lxcfs
296+
#!/bin/sh
297+
# Managed by community-scripts/ProxmoxVE - see setup_docker() in misc/tools.func
298+
#
299+
# runc wrapper: let Docker containers running inside this LXC container observe
300+
# the LXC container's resource limits instead of the physical host's.
301+
#
302+
# Docker mounts a fresh procfs per container, so the lxcfs binds LXC placed on
303+
# /proc/meminfo & friends never reach it. Re-bind them into the OCI bundle, and
304+
# apply the CT's memory limit to the container cgroup for runtimes that consult
305+
# cgroups rather than /proc.
306+
#
307+
# Bypass for a single container with: docker run --runtime=runc ...
308+
set -eu
309+
310+
REAL_RUNC=
311+
for candidate in /usr/bin/runc /usr/sbin/runc /usr/local/bin/runc; do
312+
if [ -x "$candidate" ]; then
313+
REAL_RUNC=$candidate
314+
break
315+
fi
316+
done
317+
[ -n "$REAL_RUNC" ] || exit 127
318+
319+
bundle=""
320+
prev=""
321+
for arg in "$@"; do
322+
case "$prev" in
323+
-b | --bundle) bundle="$arg" ;;
324+
esac
325+
case "$arg" in
326+
--bundle=*) bundle="${arg#--bundle=}" ;;
327+
esac
328+
prev="$arg"
329+
done
330+
331+
# Only "create" carries a bundle; everything else passes straight through.
332+
[ -n "$bundle" ] && [ -f "$bundle/config.json" ] || exec "$REAL_RUNC" "$@"
333+
334+
mem=$(awk '/^MemTotal:/ { print $2 * 1024 }' /proc/meminfo)
335+
[ -n "$mem" ] || exec "$REAL_RUNC" "$@"
336+
337+
config="$bundle/config.json"
338+
patched="$config.lxcfs"
339+
if jq --argjson mem "$mem" '
340+
["/proc/meminfo", "/proc/cpuinfo", "/proc/stat", "/proc/uptime",
341+
"/proc/swaps", "/proc/loadavg", "/proc/diskstats",
342+
"/sys/devices/system/cpu/online"] as $lxcfs
343+
| [.mounts[].destination] as $taken
344+
| .mounts += [
345+
$lxcfs[]
346+
| select(. as $f | $taken | index($f) | not)
347+
| {destination: ., type: "bind", source: ., options: ["rbind", "rprivate", "ro"]}
348+
]
349+
| if (.linux.resources.memory.limit // 0) <= 0
350+
then .linux.resources.memory.limit = $mem
351+
else . end
352+
' "$config" >"$patched" 2>/dev/null && [ -s "$patched" ]; then
353+
cat "$patched" >"$config"
354+
fi
355+
rm -f "$patched"
356+
357+
exec "$REAL_RUNC" "$@"
358+
RUNC_LXCFS
359+
360+
chmod 755 /usr/local/bin/runc-lxcfs
361+
362+
local tmpfile
363+
tmpfile=$(mktemp)
364+
if jq '. + {
365+
"default-runtime": "lxcfs",
366+
"runtimes": ((.runtimes // {}) + {"lxcfs": {"path": "/usr/local/bin/runc-lxcfs"}})
367+
}' /etc/docker/daemon.json >"$tmpfile" && [ -s "$tmpfile" ]; then
368+
mv "$tmpfile" /etc/docker/daemon.json
369+
msg_ok "Configured LXC resource visibility for Docker"
370+
else
371+
rm -f "$tmpfile"
372+
rm -f /usr/local/bin/runc-lxcfs
373+
msg_warn "Could not update /etc/docker/daemon.json - LXC resource visibility not enabled"
374+
fi
375+
}
376+
220377
setup_docker() {
221378
local docker_installed=false
222379
local portainer_installed=false
@@ -273,6 +430,8 @@ setup_docker() {
273430
EOF
274431
fi
275432

433+
_docker_setup_lxcfs_visibility
434+
276435
# Enable and start Docker
277436
systemctl enable -q --now docker
278437

@@ -344,6 +503,8 @@ EOF
344503
EOF
345504
fi
346505

506+
_docker_setup_lxcfs_visibility
507+
347508
# Enable and start Docker
348509
systemctl enable -q --now docker
349510
fi

0 commit comments

Comments
 (0)