Let the worker write to its own volumes

A named volume whose mount point is absent from the image is created owned by
root, so the worker - which drops root - could not have written a single copy
into /instances. The failure would have surfaced at the first start_service, as
a refused execution far from the line that caused it. Creating the two
directories in the image and giving them to the runtime user is what makes the
volume inherit that ownership.

The user id follows as a build argument, because one number has to satisfy three
things at once: who the process is, who owns the volumes, and who owns the
workspace directory on the host. Left hardcoded, changing MCP_UID to match a
deployment's own user would have brought the same fault back. The guard around
useradd is for the ordinary case of pointing it at an id the base image already
uses - 1000 is the node user - which is otherwise a build failure.

Verified by building both ways and writing into a fresh volume as 10001 and as
1000, and by checking the image carries node 24, Python 3.11 and OpenJDK 25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-22 12:04:36 +02:00
parent b76642a09b
commit d19fa7ba4e
2 changed files with 28 additions and 9 deletions

View File

@ -23,15 +23,28 @@ RUN apt-get update \
# No Maven. A Spring project carries ./mvnw, which pins the Maven version the project is built
# with; installing a second one here would only give an agent a way to use the wrong one.
RUN groupadd -g 10001 mcp \
&& useradd -r -u 10001 -g mcp -M -d /nonexistent mcp \
&& mkdir -p /tmp/dev-server \
&& chown -R mcp:mcp /tmp/dev-server
# One number decides three things that must agree: who the process is, who owns the volumes, and
# who owns the workspace directory on the host. Passing it in as a build argument is what keeps
# them from drifting apart - a mismatch here is only discovered when an execution cannot write.
ARG MCP_UID=10001
ARG MCP_GID=10001
RUN set -eux; \
if ! getent group "${MCP_GID}" >/dev/null; then groupadd -g "${MCP_GID}" mcp; fi; \
if ! getent passwd "${MCP_UID}" >/dev/null; then useradd -r -u "${MCP_UID}" -g "${MCP_GID}" -M -d /nonexistent mcp; fi; \
mkdir -p /tmp/dev-server /instances /npm-cache; \
chown -R "${MCP_UID}:${MCP_GID}" /tmp/dev-server /instances /npm-cache
# /instances and /npm-cache exist here only so the named volumes mounted over them inherit this
# ownership. A volume whose mount point is absent from the image is created root-owned, and a
# container that drops root then cannot write a single byte into it - which surfaces as a refused
# start on the first execution, far from the line that caused it.
WORKDIR /app
COPY --chown=mcp:mcp package.json ./
COPY --chown=mcp:mcp src ./src
# By number, not by name: with MCP_UID set to an id the base image already uses, the mcp user
# is never created and a copy chowned to it would fail the build.
COPY --chown=${MCP_UID}:${MCP_GID} package.json ./
COPY --chown=${MCP_UID}:${MCP_GID} src ./src
ENV NODE_ENV=production
USER mcp
USER ${MCP_UID}:${MCP_GID}
CMD ["node", "src/index.js"]

View File

@ -29,12 +29,15 @@ services:
dev-server-mcp:
build:
context: ./dev-server-mcp
args:
MCP_UID: ${MCP_UID:-10001}
MCP_GID: ${MCP_GID:-10001}
command: ["node", "src/index.js"]
environment:
DEV_SERVER_MCP_API_KEYS: ${DEV_SERVER_MCP_API_KEYS}
DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN}
DEV_SERVER_WORKER_URL: http://dev-server-worker:4000
user: "10001:10001"
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
@ -52,6 +55,9 @@ services:
dev-server-worker:
build:
context: ./dev-server-mcp
args:
MCP_UID: ${MCP_UID:-10001}
MCP_GID: ${MCP_GID:-10001}
command: ["node", "src/worker-index.js"]
environment:
DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN}
@ -155,7 +161,7 @@ services:
mcp-gateway:
image: caddy:2
user: "10001:10001"
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
read_only: true
tmpfs:
# /config only. /data is a volume instead, because Caddy keeps the certificate and its ACME