Installs docker.io + a static docker-compose v2 binary (same "curl a static binary" pattern
already used for k3s) inside the same already-Privileged Lab container that runs k3s.
scripts/entrypoint.sh starts dockerd (vfs storage driver, to avoid nesting overlay-on-overlay)
non-blockingly right after k3s's own API server is confirmed up — a Docker daemon failure only
logs a warning and never breaks the Kubernetes-only Labs that are the vast majority of usage.
This does not weaken the security boundary: the container was already Privileged (required for
k3s, the existing accepted threat model), so a second runtime inside it grants no new host
capability, and the Lab Host's own real Docker socket is never touched — the nested dockerd
listens on its own /var/run/docker.sock entirely inside the guest's filesystem namespace.
Tested empirically against the real production resource caps (2 vCPU, 4096MB, 512 PIDs,
Privileged: true) before merging: k3s + dockerd + a running nginx:alpine container (with a
published port, curled successfully) + a real docker build all together used 884MB/4096MB and
160/512 PIDs — comfortable headroom in every dimension. See docs/ROOT_FOUNDATIONS_LAB_STRATEGY.md
§3 for the full test log.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Root cause, confirmed empirically (not guessed):
1. node-gyp generates an incorrect build/config.gypi for this NodeSource-packaged
Node 20 build — it reports node_shared: true (expects a shared libnode.so),
but the actual installed Node is statically linked (no libnode.so.<abi>
exists anywhere on the system; process.config.variables.node_shared on the
real running Node is false). node-gyp trusted the wrong config and linked
node-pty's native addon against -lnode, producing a binary that fails at
require() time with "libnode.so.127: cannot open shared object file" —
confirmed by loading the compiled .node file directly, bypassing node-pty's
own path-search/error-masking logic (which collapses this into a generic
"Cannot find module .../pty.node" across every path it checks).
2. A second, independent bug compounded it: no .dockerignore existed. A
host-side `npm install` (run earlier for backend testing) left
backend/node_modules on disk, built against the host's own environment. The
Dockerfile's `COPY backend/ ./backend/` (placed after the npm-install RUN
layer, for build-cache efficiency) silently overwrote the correctly-built
node_modules with that host copy — including after a first .dockerignore
fix using the pattern `node_modules/`, which only matches a node_modules
directory at the build context ROOT in Docker's matching rules, not at any
depth (unlike .gitignore, where the same-looking pattern does match any
depth — a real, confirmed gotcha, not an assumption).
Fix:
- Dockerfile: `ENV npm_config_node_shared=false` (image-level, not a one-off
RUN prefix, so it can't be silently lost if a native module ever gets
rebuilt again for any reason) + a build-time `node -e "require(...)"` check
that fails the build loudly if this regresses, instead of shipping a silent
runtime failure.
- .dockerignore: `**/node_modules/` (recursive), not `node_modules/`.
No terminal architecture change, no fake shell, no changes to
backend/server.js's PTY handling. RootNode branding and upstream KubeKosh
attribution untouched. The single-container dev workflow
(`docker build -t rootnode-academy:test .`) is the same command as before —
it now just works.
Verified against the real image end-to-end, not a stand-in: build -> container
starts -> k3s reaches Ready -> backend starts, no crash -> nginx starts ->
browser UI 200s -> terminal WebSocket opens showing the RootNode banner ->
`kubectl get nodes` run through the terminal returns real cluster state ->
deploy-nginx scenario solved via the terminal -> all 5 validation checks pass.
Co-Authored-By: Claude Sonnet 5 <[email protected]>