docker image automatically uses only 1 alpine (1 light weight OS)
many alpine like go alpine or python alpine all come with a light weight linux OS,
the way to achieve is u use go alpine to build and export the finished binary
use python alpine to build and export finished dir
then copy both to new runtime alpine such as python aline(cause python need an interprotr)
# --- Stage 1: Go builder (offline: deps are vendored) ----------------------
FROM myhub.com/golang:1.26.2-alpine3.22 AS go-builder
WORKDIR /build
COPY srv/ ./srv/
COPY vendor/ ./vendor/
COPY go.mod go.sum ./
# CGO_ENABLED=0 -> static binary; -mod=vendor -> no network needed
RUN CGO_ENABLED=0 GOOS=linux go build -mod=vendor -o /edge ./srv/cmd/server
# --- Stage 2: Python builder — venv from lockfile, then strip source -------
FROM myhub.com/python:3.12-alpine AS py-builder
WORKDIR /app
COPY app/pyproject.toml app/uv.lock ./
RUN pip install --no-cache-dir uv \
&& uv sync --frozen --no-dev --no-install-project
COPY app/server.py ./
# Bytecode-only: compile server.py (and the whole venv) to .pyc next to the
# source, then delete every .py. Python imports .pyc seamlessly, so
# `uvicorn server:app` works unchanged; the app's source never ships.
RUN python -m compileall -b server.py && rm server.py \
&& python -m compileall -b -q .venv \
&& find .venv -name '*.py' -delete
# --- Runtime: same python image as the pyc builder -------------------------
# Using python:3.12-alpine as runtime (not bare alpine + apk python3) makes
# the venv's interpreter path (/usr/local/bin/python3) and the pyc bytecode
# format structurally identical to the builder — no version-skew bugs.
FROM myhub.com//python:3.12-alpine
# tzdata: logger pst_time needs America/Los_Angeles; ca-certificates: the
# edge and uvicorn call upstream Mantis over TLS.
RUN sed -i "s#dl-cdn.alpinelinux.org/alpine#jfrog.corp.fortinet.com/artifactory/apk_proxy#g" /etc/apk/repositories \
&& apk add --no-cache tzdata ca-certificates \
&& adduser -D -u 10001 mantis \
&& mkdir -p /app/logs /certs && chown -R mantis:mantis /app/logs /certs
COPY --from=go-builder /edge /edge
COPY --from=py-builder /app /app
# Files from the builders are root-owned; the runtime user (10001) needs
# read access to /app (server.pyc, venv). a+rX = readable, dirs traversable.
RUN chmod -R a+rX /app
USER 10001
WORKDIR /app
EXPOSE 8443
ENTRYPOINT ["/edge"]
---------------
ur image has three Alpines — and only one survives
FROM golang:1.26.2-alpine3.22 (stage 1: go-builder) ──► discarded (only /edge extracted)
FROM python:3.12-alpine (stage 2: py-builder) ──► discarded (only /app copied out)
FROM python:3.12-alpine (stage 3: runtime) ──► THIS becomes the container
COPY --from=py-builder /app /app is the only thing crossing the boundary — it plucks the finished pyc-only venv out of that stage's filesystem. The go-builder's Go toolchain, its alpine userland, its whole filesystem: none of it exists in the final image. Not merged, not layered-on — discarded.
So "which alpine am I in" when you exec into the container
The last stage's one — verified just now by poking the running image:
/etc/os-release → Alpine Linux
python3 → /usr/local/bin/python3 (the python:3.12-alpine layout — apk's would be /usr/bin/python3)
/bin/busybox → present (alpine userland)
That path detail (/usr/local/bin vs /usr/bin) is actually the fingerprint distinguishing "python image's alpine" from "plain alpine + apk python" — and it was the exact bug from the first build: the venv symlinked to /usr/local/bin/python3, which the bare-alpine runtime didn't have. Choosing the same image for stage 2 and 3 made the builder/runtime OS boundary disappear entirely.
The rules that make this coherent
One runtime filesystem, period. A container = exactly one image's final layer stack. FROM stages before the last exist only at build time; docker exec / docker run can never see them.
COPY --from is the only door between stages. Files cross explicitly; nothing else carries over — not env vars, not users (hence the re-created mantis user in the runtime stage), not the OS's package database (the runtime stage doesn't "know" about the go-builder's apk installs).
They can even be different distros. A totally legal pattern: build in golang:alpine, run in debian:slim or ubuntu — or the reverse (build on glibc's debian, run on alpine musl = breakage). Nothing forces the bases to match. Ours deliberately match (alpine→alpine, same python image twice) to keep musl/pyc/symlinks consistent.
"Alpine" is not one monolithic thing. Each stage's FROM pins a different alpine snapshot: go-builder = alpine 3.22, python stages = whatever alpine 3.12-python image is built on (also 3.22-ish here). They coexist at build time, independent and unrelated at run time.
The practical checks when you're confused inside a container
cat /etc/os-release # which distro userland this is
which python3 # /usr/local/bin → python image; /usr/bin → apk-installed
head -1 /app/.venv/bin/uvicorn # shebang: which interpreter the venv expects
TL;DR: multiple alpines can coexist inside a Dockerfile (we have three), but the container is only the last one — verified above: the running container is the runtime-stage alpine with the python-image layout, and the other two alpines died when their build stages ended.
ENTRYPOINT vs CMD — the full picture
ENTRYPOINT CMD
Role The container's main process — what it is Default arguments to that process — how it's typically invoked
exec-form ENTRYPOINT ["/edge"] CMD ["--port", "8443"]
Shell-form ENTRYPOINT /edge (wraps in sh -c, breaks PID 1 signal semantics — avoid) CMD --port 8443 (same wrapping)
Overridden at runtime by docker run ... --entrypoint X (rare) docker run image <args> (common)
With both set runs as ENTRYPOINT + CMD concatenated
The four combinations, concretely for our image (ENTRYPOINT ["/edge"], no CMD):
What you run What actually executes
docker run mantis-mcp:latest /edge (entrypoint alone) — our case
docker run mantis-mcp:latest foo bar /edge foo bar — CLI args appended to the entrypoint
docker run --entrypoint /bin/sh mantis-mcp:latest /bin/sh — entrypoint replaced (our debugging trick all session: --entrypoint /bin/sh -c '...')
hypothetical ENTRYPOINT ["/edge"] + CMD ["--port","9000"] /edge --port 9000
Yes — it works, and you already have a perfect example on this box: the alpine image has no ENTRYPOINT, only CMD ["/bin/sh"] (verified above). docker run alpine starts /bin/sh as PID 1, which immediately hits EOF (no stdin attached, not interactive), exits 0, container ends.
CMD-only behavior
Without ENTRYPOINT, CMD alone defines the process:
CMD ["python", "server.py"] # image with no ENTRYPOINT
What you run What executes as PID 1
docker run img python server.py (the CMD)
docker run img bash bash — CMD fully replaced
docker run img -c "echo hi" python -c "echo hi" — CMD's first word replaced, rest kept (the footgun)
So a CMD-only image is a perfectly normal container — certgen could have been written CMD-only and would run the same way.
Then why does ENTRYPOINT exist?
The distinction is override semantics — what survives when the user appends arguments:
user runs img args args become
CMD-only CMD replaced the whole new command — the program itself can be swapped
ENTRYPOINT+CMD ENTRYPOINT + args parameters to a fixed program — the program can't be swapped, only instructed
COPY --from=go-builder /edge /edge — both paths here are the binary file itself:
COPY --from=<stage> <SRC path in that stage> <DEST path in this stage>
│ │ │
py-builder/go-builder /edge (the file /edge (destination
(named "go-builder" that `go build FILE in the runtime
via `AS go-builder`) -o /edge` made) stage's filesystem)
The name is just what we chose: go build -o /edge ./srv/cmd/server in stage 1 wrote the compiled binary to a file called /edge; the COPY then plucks that one file into the runtime stage at the same path. If we'd written -o /edge/mcp-server, then /edge would be a directory containing a binary — but we didn't, so it's a single executable file at the filesystem root. (Compare: COPY --from=py-builder /app /app copies a directory — COPY copies whatever the source is, file or tree.)
Why the odd-looking syntax X X (same path twice)? The two /edges live in different filesystems: the first is a path inside the go-builder stage, the second is a path inside the runtime stage. They happen to be spelled the same; COPY --from=py-builder /app /app shows the identical pattern. A more descriptive spelling that means exactly the same thing:
COPY --from=go-builder /edge /usr/local/bin/edge # dest is the file path, wherever you like
2. It's not run "with CMD" — it's run via ENTRYPOINT
Small but important, from the last discussion: the image has no CMD. What launches /edge is:
ENTRYPOINT ["/edge"] ← names /edge as the container's main process (PID 1)
So the complete story across the three Dockerfile stages:
stage 1 (go-builder): go build -o /edge ./srv/cmd/server # COMPILE → /edge is created as a file
stage 3 (runtime): COPY --from=go-builder /edge /edge # TRANSPORT the file into the final image
ENTRYPOINT ["/edge"] # DECLARE: this file is what `docker run` execs
/edge is a file
The chain you described, confirmed
/edge (PID 1) starts
→ config.Load() reads CHILD_CMD (among others)
→ childArgv = strings.Fields(CHILD_CMD) ["…/uvicorn", "server:app", "--host", "127.0.0.1", …]
→ supervisor.Spawn(childArgv, CHILD_DIR) fork + exec → uvicorn runs as child of /edge
So /app's Python stuff only exists on disk because of the COPY — it only runs because /edge reads CHILD_CMD and execs it. No env → no spawn → edge exits at startup (the fail-fast we mapped). And uvicorn's cwd is CHILD_DIR (default /app), which is why bare server:app resolves to /app/server.pyc — the module file sitting right there.
What happens with no CHILD_CMD — the default kicks in
config.go:
const defaultChildCmd = "/app/.venv/bin/uvicorn server:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips 127.0.0.1"
ChildCmd: getEnv("CHILD_CMD", defaultChildCmd), // ← .env value if present, else this