Run It as a Service, Not a Script

I had a working scorer image, and I called the packaging done. docker run loan-scorer --record row.json printed a default probability and exited, which was exactly what the last four lessons had been building toward. Then someone asked to point a dashboard at it, and I found that the image could not receive a request at all: it ran, it scored, and it died, so there was nothing left for a dashboard to connect to. My first fix made it worse. I left a server running but bound it to 127.0.0.1 inside the container, the deploy read healthy, and every request from the host came back connection-refused. The container was up and listening the whole time; it was listening on an address that nothing outside the container could reach.

In the last lesson you cut that scorer’s image from multi-GB to a few hundred MB by choosing a slim base and moving the build toolchain into a discarded builder stage, so the runtime stage carried only the installed packages and the classical-ML model. That image is now reproducible, config-clean, secret-free, and small. It is also still a script: its ENTRYPOINT ["python", "-m", "score"] loads the model, scores one record, prints a probability, and returns. A deployment is not a script that runs once. It is a process that stays up and answers requests that have not arrived yet. This lesson converts the score-and-exit container into a long-lived service in three moves: run a server as the container’s main process so it stays alive, publish its port and bind the right interface so something off the container can reach it, and mount the model and outputs as volumes so they survive the container being removed. By the end, curl localhost:8000/predict from the host scores a record against a container that is still running afterward. The server you run here is a generic ASGI app defined in this module — ASGI being the asynchronous server interface a Python web server uses to hand HTTP requests to your application code — a /predict route that loads the classical-ML model and returns its probability as JSON, so the running service is self-contained at this point in the track.

I had a working scorer image, and I called the packaging done. docker run loan-scorer --record row.json printed a default probability and exited, which was exactly what the last four lessons had been building toward. Then someone asked to point a dashboard at it, and I found that the image could not receive a request at all: it ran, it scored, and it died, so there was nothing left for a dashboard to connect to. My first fix made it worse. I left a server running but bound it to 127.0.0.1 inside the container, the deploy read healthy, and every request from the host came back connection-refused. The container was up and listening the whole time; it was listening on an address that nothing outside the container could reach.

In the last lesson you cut that scorer’s image from multi-GB to a few hundred MB by choosing a slim base and moving the build toolchain into a discarded builder stage, so the runtime stage carried only the installed packages and the classical-ML model. That image is now reproducible, config-clean, secret-free, and small. It is also still a script: its ENTRYPOINT ["python", "-m", "score"] loads the model, scores one record, prints a probability, and returns. A deployment is not a script that runs once. It is a process that stays up and answers requests that have not arrived yet. This lesson converts the score-and-exit container into a long-lived service in three moves: run a server as the container’s main process so it stays alive, publish its port and bind the right interface so something off the container can reach it, and mount the model and outputs as volumes so they survive the container being removed. By the end, curl localhost:8000/predict from the host scores a record against a container that is still running afterward. The server you run here is a generic ASGI app defined in this module — ASGI being the asynchronous server interface a Python web server uses to hand HTTP requests to your application code — a /predict route that loads the classical-ML model and returns its probability as JSON, so the running service is self-contained at this point in the track.

A container lives exactly as long as its main process

The mental model that breaks here is that a container is a small virtual machine: a box you boot, that stays powered on, and that you later connect to. Under that model the natural expectation is that once docker run loan-scorer succeeds, the scorer is up and waiting, the way a VM you started yesterday is still running today. A container is not that. A container is a single Linux process with a restricted view of the system: its own process table, its own filesystem mount namespace, its own network namespace, with isolation drawn around one process tree rather than a separate kernel booted in a box. The main process of that container, the one your ENTRYPOINT runs, is PID 1 inside the container, and every other process the container spawns is a child of it. There is no “machine” underneath that outlives the process. The container is the process, plus the isolation wrapped around it.

That distinction is not academic, because it decides exactly when the container stops. The container’s lifecycle is tied to its root process: when PID 1 exits, the container exits. This is Docker’s documented behavior — a container runs only as long as the root process it was started with keeps running. So the question “is my service up?” reduces to “is PID 1 still running?”, and for the M5 scorer the answer is no, almost immediately. Watch what the score-and-exit entrypoint does to a container’s lifetime when you run it expecting a service.

$ docker run loan-scorer --record row.json
0.0731
$ docker ps
CONTAINER ID   IMAGE         COMMAND                  STATUS
$                                  # empty — nothing is running
$ docker ps -a
CONTAINER ID   IMAGE         COMMAND                  STATUS
a1b2c3d4e5f6   loan-scorer   "python -m score --…"    Exited (0) 3 seconds ago

docker run started the container, PID 1 (python -m score) loaded the model, scored the record, printed 0.0731, and returned. The instant it returned, PID 1 exited 0, and the container went to Exited (0) a few milliseconds after it started. This is correct behaviour for a batch job (score one record, finish, leave nothing running) and it is exactly wrong for a service, because a service must be reachable at an arbitrary later time. The request the dashboard will send has not arrived yet when the container starts; under a score-and-exit entrypoint there is no process left alive to receive it. The container did not crash and it is not “down” in the way a failed deploy is down. It completed, which is a different thing that looks identical from the outside until you check the exit code.

The fix follows from naming what a service actually needs from PID 1: a process that does not return. A server holds an event loop: a process that blocks, waiting on a socket for connections, and dispatches each request as it arrives without ever falling off the end of its own code. The uvicorn server that runs the module’s /predict app is exactly this: an ASGI server whose process spends almost all of its time waiting on a non-blocking loop that does not return. Make that the container’s PID 1 and the container stays Up until something stops it, because PID 1 never exits on its own. The image can be byte-for-byte the same model and the same dependencies; only the ENTRYPOINT changes, and that one line decides whether the container is a task that completes or a server that waits.

# Was (batch job — PID 1 returns, container exits):
#   ENTRYPOINT ["python", "-m", "score"]

# Now (service — PID 1 blocks on the event loop, container stays Up):
ENTRYPOINT ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

One line moved, and the container’s nature changed from a task to a server. Run that image detached and check what docker ps reports now that PID 1 is a blocking loop rather than a returning script. The STATUS column is the whole point.

$ docker run -d loan-scorer
7f3a...
$ docker ps
CONTAINER ID   IMAGE         COMMAND                       STATUS
7f3a9b2c1d4e   loan-scorer   "uvicorn app:app --host …"    Up 12 seconds

The container is now Up and stays that way, because uvicorn’s loop holds PID 1 open instead of returning. There is one detail to flag before the --host 0.0.0.0 matters, which is the next section: uvicorn binding its port is not the same as the service being ready to answer. Binding happens the instant the process starts; loading the classical-ML model from disk into memory happens after, and a request that lands in that gap fails even though the container is Up. That readiness gap is what the Compose healthcheck in the next lesson exists to close. For now, the container no longer disappears the moment it starts.

There is a failure shape worth naming because it reads as the opposite of what happened. A server that crashes on startup (the model fails to deserialize, a config value is missing, a port is already taken) exits PID 1 with a non-zero code, and the container stops. Now docker ps shows nothing running, which looks identical to “it never started.” The two are distinguished only by docker ps -a and the exit code: a Up line means alive, an Exited (0) means it completed cleanly, and an Exited (1) (or any non-zero) means it started and died. Reaching for “the image is broken, it will not start” when the truth is “it started, loaded a bad model, and crashed” sends you editing the Dockerfile when the bug is in the model file. The exit code is the diagnostic that tells those two apart, and docker ps alone hides it because it only lists what is running now.

Being PID 1 is also a responsibility, not just a lifetime, and the responsibility is signals. docker stop delivers a SIGTERM to PID 1 and, if the process has not exited after a grace period (ten seconds by default on Linux), follows with an unignorable SIGKILL. A server that handles SIGTERM gets those ten seconds to finish in-flight requests and exit cleanly; one that never receives it gets hard-killed mid-request. And there is a Dockerfile spelling mistake that guarantees the second outcome: the shell form of ENTRYPOINT (ENTRYPOINT uvicorn app:app ..., no brackets) runs the command as a child of /bin/sh -c, so the shell is PID 1, the shell does not forward signals, and — in the documentation’s own words — “your executable doesn’t receive a SIGTERM from docker stop.” The symptom is a service that takes exactly the grace period to stop, every time, and drops whatever requests were in flight when the SIGKILL lands. The exec form with brackets, which this module has used throughout, is not a style preference: it is what makes your server PID 1 so the shutdown signal actually reaches it.

The scrolly traces one container’s lifetime under each entrypoint, the score-and-exit script that completes and the blocking server that stays up, so the cause of “it disappeared” is visible as PID 1 returning.

A container is one process

docker run starts the container by running the ENTRYPOINT as PID 1 inside the container’s namespaces. Every other process the container spawns is a child of that one. There is no separate machine underneath it: the container is this process plus the isolation wrapped around it.

The batch entrypoint returns

python -m score loads the classical-ML model, scores the record argument, prints the probability, and returns. The moment PID 1 returns, it exits 0, and there is nothing left for the container to be.

PID 1 exits, the container exits

The container’s lifecycle is tied to its root process. PID 1 exited, so the container transitions to Exited (0) a few milliseconds after it started. docker ps now shows nothing: it completed, it did not crash.

The service entrypoint blocks

Swap the entrypoint to uvicorn app:app. Its event loop waits on the socket for connections and never falls off the end of its own code, so PID 1 does not return. The container stays Up, waiting for a request that has not arrived yet.

Crash-on-startup is not “never started”

If the server crashes loading the model, PID 1 exits non-zero and the container stops, so docker ps shows nothing, reading as “never started.” Only docker ps -a and the exit code separate Exited (1) (started and died) from a clean completion.

The lifecycle is mechanical enough to model in plain Python without a Docker daemon: an entrypoint is a callable that either returns or blocks, and the container’s state is determined entirely by which one it is. The block below makes “PID 1 returned” the observable cause of Exited and “PID 1 blocks” the cause of Up.

python
from typing import Callable


def container_state(entrypoint: Callable[[], str], blocks: bool) -> str:
    """Return the container's state after starting PID 1.

    A container lives exactly as long as its main process. If the entrypoint
    returns, PID 1 exits and the container stops; if it blocks forever, the
    container stays Up.
    """
    if blocks:
        return "Up (PID 1 blocking on event loop)"
    exit_code = entrypoint()  # runs to completion and returns an exit code
    return f"Exited ({exit_code})"


def batch_score() -> str:
    # loads model, scores one record, prints, returns
    return "0"


def crash_on_startup() -> str:
    # model fails to deserialize, raises, process exits non-zero
    return "1"


# uvicorn never returns -- we model that as blocks=True, no callable run.
print("batch script:    ", container_state(batch_score, blocks=False))
print("uvicorn server:  ", container_state(lambda: "0", blocks=True))
print("crash on startup:", container_state(crash_on_startup, blocks=False))

The three lines are the three things docker ps -a would show. The batch script and the crash both leave Exited, and the only thing separating “it completed its job” from “it died” is the number in the parentheses, which is precisely why docker ps alone is not enough to diagnose a missing service. The uvicorn row is the one that stays Up, and it is Up for one reason: PID 1 blocks instead of returning.


Try It 1

You have the M5 score-and-exit image and you run it expecting a service, but docker ps shows nothing. Predict, for each of the three entrypoints below, what docker ps -a would show (Up, Exited (0), or Exited (N)) and which one is the container you can actually send a request to. Fill in the returned tuples (state, reachable).

python
def classify_entrypoint(name: str) -> tuple[str, bool]:
    # name is one of: "python -m score" (scores once, returns 0),
    #                 "uvicorn app:app" (blocks on the event loop),
    #                 "python -m score-bad" (model fails to load, exits 1)
    # Return (docker_ps_state, is_reachable_as_a_service).
    if name == "python -m score":
        return ("?", False)  # TODO: what state after it returns 0?
    if name == "uvicorn app:app":
        return ("?", False)  # TODO: state, and is it reachable?
    if name == "python -m score-bad":
        return ("?", False)  # TODO: non-zero exit
    return ("unknown", False)


for n in ["python -m score", "uvicorn app:app", "python -m score-bad"]:
    print(n, "->", classify_entrypoint(n))
Hint The container lives exactly as long as PID 1. Ask, for each entrypoint, whether that process *returns* or *blocks*. A process that returns leaves the container `Exited`, and the exit code distinguishes a clean finish from a crash. Only a process that blocks forever leaves a container you can connect to. Re-read "A container lives exactly as long as its main process."

Solution

Here is each entrypoint resolved by the one test that decides it, whether PID 1 returns or blocks, with the exit code carrying the difference between a clean finish and a crash. Watch that the two Exited rows are not interchangeable even though docker ps would show neither.

python
def classify_entrypoint(name: str) -> tuple[str, bool]:
    if name == "python -m score":
        # scores once, returns 0 -> PID 1 exits -> container stops, completed cleanly
        return ("Exited (0)", False)
    if name == "uvicorn app:app":
        # blocks on the event loop -> PID 1 never returns -> stays Up, reachable
        return ("Up", True)
    if name == "python -m score-bad":
        # model fails to load, exits 1 -> PID 1 exits non-zero -> container died
        return ("Exited (1)", False)
    return ("unknown", False)


for n in ["python -m score", "uvicorn app:app", "python -m score-bad"]:
    print(n, "->", classify_entrypoint(n))

Only the uvicorn entrypoint leaves a container that is Up and reachable, because it is the only one whose PID 1 blocks instead of returning. The two Exited rows look identical in docker ps (both show nothing running) and the exit code is the single fact that separates a job that finished from a server that crashed on startup.

Publishing a port: the container’s port is not the host’s port

With uvicorn holding PID 1 open, the container stays Up, and the next plausible-but-wrong model takes over: the server is listening on :8000, so curl localhost:8000/predict from the host should reach it. It does not. “The server is up” and “a caller can reach it” are two separate facts, and the gap between them is the single most common “it is running but I cannot connect” bug in container work. The reason is the network namespace from the previous section. Each container gets its own network namespace (its own loopback, its own interfaces, its own port space) so a process listening on :8000 inside the container is listening on the container’s :8000, which is a different port from the host’s :8000. By default there is no path from the host into that namespace at all.

$ docker run -d loan-scorer            # uvicorn on :8000 inside the container
$ curl localhost:8000/predict
curl: (7) Failed to connect to localhost port 8000: Connection refused

The connection is refused because nothing on the host’s :8000 is listening: the server is on the container’s :8000, behind the namespace boundary, unreachable from outside. You open that path by publishing the port with -p host:container, which installs a forwarding rule on the host that maps the host port to the container’s port. A request to localhost:8000 on the host is now rewritten and carried across the namespace boundary into the container.

$ docker run -d -p 8000:8000 loan-scorer    # publish host:8000 -> container:8000
$ curl localhost:8000/predict
curl: (7) Failed to connect to localhost port 8000: Connection refused

Still refused, and this is the part that traps competent engineers, because the obvious fix was applied and the symptom did not move. Publishing the port is necessary but not sufficient, because which address inside the container the server bound to still has to match where the forwarded traffic arrives. A server bound to 127.0.0.1:8000 accepts only connections that originate from the container’s own loopback. The forwarded request from the host does not arrive on the container’s loopback; it arrives on the container’s external interface, the one the forwarding rule delivers to. So the server, listening only on loopback, never sees a connection it will answer, and the host gets connection-refused even though the port is published and docker ps shows the container Up with the port mapped. The container is up and the port is mapped, but the server is bound to the wrong interface, so no forwarded request ever reaches it.

Binding 0.0.0.0 is the fix, and it is worth being precise about what 0.0.0.0 means rather than treating it as a magic string. 0.0.0.0 tells the server to accept connections on all of the container’s network interfaces, including the external one that forwarded host traffic arrives on, not just loopback. Binding 0.0.0.0 accepts access from other hosts, whereas binding loopback restricts to the same host (Python in a Nutshell, ch19). This is why every container server image binds 0.0.0.0 and why 127.0.0.1 is the canonical “it is running but I cannot reach it” bug: loopback is the more restrictive choice that looks identical until traffic has to cross a boundary.

$ docker run -d -p 8000:8000 loan-scorer    # ENTRYPOINT now binds 0.0.0.0
$ curl localhost:8000/predict
{"probability": 0.0731, "label": "fully_paid", "model_version": "lc-default-v3"}

The same curl, the same published port, the same container, and now it returns the /predict JSON, because the server is finally listening on the interface the request arrives on. The two changes are independent and both required: -p opens the path across the boundary, and 0.0.0.0 makes the server answer on the interface that path delivers to. Drop either one and the request is refused, but for different reasons that look identical from the host. The scrolly traces one host request across the boundary, with the loopback-bound track refusing it at the last hop and the 0.0.0.0-bound track accepting it.

The request leaves the host

curl localhost:8000/predict is a request to the host’s port 8000. Nothing has connected the host’s 8000 to the container yet, so on its own this request has nowhere to go but the host’s own loopback.

The publish rule forwards it

-p 8000:8000 installs a forwarding rule on the host that maps host:8000 to the container’s :8000. The request is rewritten and carried across the network-namespace boundary toward the container. Without -p, it stops here, refused.

It arrives on the external interface

Inside the container the forwarded request lands on the container’s external interface, not its loopback. This is the detail that decides everything next: where the request arrives must match where the server is listening.

0.0.0.0 accepts, 127.0.0.1 refuses

A server bound to 0.0.0.0 listens on all interfaces, including the external one, so it accepts the request. A server bound to 127.0.0.1 listens only on loopback, so the request arrived on the wrong interface and is refused, even though the port is published and the container is Up.

The response travels back

uvicorn dispatches the accepted request to the /predict handler, which scores the record and returns the probability JSON. The response travels back out through the same mapping to the waiting curl on the host.

The two numbers in -p host:container are not symmetric, and the asymmetry is worth holding onto because it is where the rule is reusable. The host-side number is free to differ: -p 9000:8000 exposes the same container port as 9000 on the host, so curl localhost:9000/predict reaches it and localhost:8000 does not. The container-side number is not free: it must match what the server actually binds, because that is the port the forwarding rule delivers to inside the container. There is a third, quieter position in that flag: the host interface. With no IP given, Docker publishes on all of the host’s interfaces (0.0.0.0) — the documentation is blunt that “these ports are externally accessible,” and on Linux the warning goes further: the rule holds even if you configured ufw to block that port, because Docker’s own forwarding rules divert the traffic before the host firewall sees it. So the -p 8000:8000 that made local curl work has also, on a shared network, offered the scorer to the LAN. Publishing to loopback only — -p 127.0.0.1:8000:8000 — keeps the container reachable from the host and from nothing else, which is the right default for a dev box. The lesson’s 0.0.0.0 rule is about the container-side bind and stands; the host side of the same flag is a separate exposure decision, and leaving it implicit is how a development scorer ends up answering strangers. The resolver below encodes the whole rule, that a request is reachable only when the path is published and the server binds an interface the forwarded traffic arrives on.

python
def is_reachable(
    bind_address: str, publish: tuple[int, int] | None, host_request_port: int
) -> str:
    """Decide whether a host request reaches the container's server.

    bind_address: what the server binds inside the container ('0.0.0.0' or '127.0.0.1')
    publish: (host_port, container_port) from -p, or None if not published
    host_request_port: the port the host curl hits
    """
    if publish is None:
        return "refused: port not published, no path across the namespace boundary"
    host_port, container_port = publish
    if host_request_port != host_port:
        return f"refused: host has nothing on :{host_request_port} (mapping is :{host_port})"
    # Forwarded traffic arrives on the container's external interface.
    if bind_address == "127.0.0.1":
        return "refused: server bound loopback, request arrived on external interface"
    return f"reached: forwarded to container :{container_port}, server accepted"


print(is_reachable("0.0.0.0", None, 8000))
print(is_reachable("127.0.0.1", (8000, 8000), 8000))
print(is_reachable("0.0.0.0", (8000, 8000), 8000))
print(is_reachable("0.0.0.0", (9000, 8000), 8000))
print(is_reachable("0.0.0.0", (9000, 8000), 9000))

The first three lines walk the exact debugging sequence from no-publish to loopback-bind to the working case, and only the third reaches. The last two make the host-side asymmetry concrete: with -p 9000:8000, a request to :8000 is refused and the same request to :9000 reaches, because the host number moved but the container number (and the server’s bind) did not.


Try It 2

The student’s service binds 0.0.0.0 and is published with -p 8000:8000, and curl localhost:8000/predict works. Now change the run to -p 9000:8000 without touching the image. State which host URL reaches the service afterward and which now fails, and complete the function so it returns the reachable host port for a given publish mapping.

python
def reachable_host_port(publish: tuple[int, int]) -> int:
    # publish is (host_port, container_port) from -p.
    # The server inside the container always binds 0.0.0.0:container_port.
    # Which host port does a caller hit to reach it?
    host_port, container_port = publish
    return 0  # TODO: which of the two numbers does the host caller use?


for mapping in [(8000, 8000), (9000, 8000), (80, 8000)]:
    print(
        f"-p {mapping[0]}:{mapping[1]}  ->  curl localhost:{reachable_host_port(mapping)}"
    )
Hint The two numbers in `-p host:container` play different roles. One is where the caller on the host knocks; the other is where the forwarding rule delivers inside the container, and it must match the server's bind. Ask which number the server never knew about: that is the one the host caller uses. Re-read the paragraph on the two-number asymmetry.

Solution

Here is the mapping resolved to its reachable host port: the function reads the host-side number of -p host:container, because that is the only number the caller on the host ever touches. Watch that the container-side 8000 and the server’s bind never appear in the answer; they did not move.

python
def reachable_host_port(publish: tuple[int, int]) -> int:
    host_port, _container_port = publish
    # The host-side number is the one a caller hits; it may differ from the
    # container port. The container-side number must match the server's bind,
    # but the host caller never uses it directly.
    return host_port


for mapping in [(8000, 8000), (9000, 8000), (80, 8000)]:
    print(
        f"-p {mapping[0]}:{mapping[1]}  ->  curl localhost:{reachable_host_port(mapping)}"
    )

With -p 9000:8000 the service is reached at localhost:9000 and localhost:8000 now refuses, because the host-side number moved while the container-side number (and the server’s 0.0.0.0:8000 bind) stayed put. The container did not change at all; only the host end of the forwarding rule did.

Volumes: the writable layer is discarded, so state lives outside it

The service is up and reachable, and now it needs to keep something. The plausible model is that since the container has a filesystem, writing a scoring log or an output file to a path inside it persists, the way writing to disk on a normal machine persists. It does not survive a redeploy, and the reason reaches back to the copy-on-write layer model from earlier in this module. A running container sits on a read-only stack of image layers plus exactly one writable layer on top, and every write the process makes (a new file, an edit to an existing one) lands in that writable layer via copy-on-write. The writable layer is ephemeral: as Docker’s storage documentation describes, data written to the container’s writable layer does not persist once the container is destroyed. When the container is removed, the writable layer is discarded and the read-only image is untouched, so everything the running container wrote is gone.

$ docker run -d --name scorer loan-scorer
$ curl localhost:8000/predict          # writes a line to /app/outputs/scores.log
$ docker exec scorer cat /app/outputs/scores.log
2026-06-17T14:02:11Z  row=row.json  prob=0.0731

$ docker rm -f scorer                  # next deploy recreates the container
$ docker run -d --name scorer loan-scorer
$ docker exec scorer cat /app/outputs/scores.log
cat: /app/outputs/scores.log: No such file or directory

The log was real, it was written, and docker rm discarded it along with the writable layer it lived in. For a stateless scorer that is exactly the behaviour you want: nothing should leak from one run into the next, and the container is reproducible because it carries no accumulated state. It becomes a failure the moment something genuinely must survive: scoring outputs that feed a downstream report, an audit log a regulator will ask for, or the common one for an ML service, a model artifact you want to update without rebuilding the image. None of those can live in the writable layer, because the writable layer is defined to not outlive the container.

State that must survive has to live outside the container’s filesystem, and Docker gives two mechanisms for that. A bind mount (-v /host/path:/container/path) maps a host directory into the container, so reads and writes to that path go to the host filesystem and persist across container removal and across image rebuilds: the container sees a directory that is really the host’s. A named volume (-v outputs:/app/outputs) is Docker-managed persistent storage with the same survival property but decoupled from a specific host path, which is the portable choice when the data must persist but its host location should not be hardcoded into every run command. Either one moves the write target off the discarded writable layer and onto storage the daemon keeps.

$ docker run -d --name scorer -v outputs:/app/outputs loan-scorer
$ curl localhost:8000/predict          # writes to /app/outputs/scores.log on the VOLUME
$ docker rm -f scorer
$ docker run -d --name scorer -v outputs:/app/outputs loan-scorer
$ docker exec scorer cat /app/outputs/scores.log
2026-06-17T14:02:11Z  row=row.json  prob=0.0731    # survived the rm

One flag, -v outputs:/app/outputs, moved the write off the writable layer and onto a named volume, and the log survived the container being destroyed and recreated. The decision of what to mount versus what to bake into the image follows one rule that composes the whole module: bake into the image what is part of the shipped artifact and changes only on a rebuild (the code, the locked dependencies, the default model) and mount what must persist across runs or change without a rebuild (swappable models, scoring outputs, audit logs, and datasets too large to ship in a layer at all). The tens-of-gigabytes dataset from the slim-image lesson is the canonical mounted case: data that belongs on a volume or in object storage, never COPYd into an image layer.

Getting this wrong in the other direction is a separate failure that is quick to trigger and confusing to diagnose. Mounting a volume over a path the image already populated does not merge the two: the mount shadows the image’s files at that path. This is the same Unix mount-shadowing behaviour that has always applied: after a filesystem is mounted onto a directory, pathnames resolve into the mounted filesystem, and whatever was at that path in the image is hidden behind the mount. So an empty volume mounted over /app/models, where the image baked the default model, presents an empty directory to the container: the baked-in model disappears, the load fails, and the service starts scoring against nothing.

$ docker run -d -v ./empty_dir:/app/models loan-scorer
$ docker logs $(docker ps -lq)
FileNotFoundError: [Errno 2] No such file or directory: '/app/models/model.pkl'
# the model.pkl baked into the image is still in the image — it is just
# hidden behind the empty mount at /app/models

The model is not gone from the image; the empty mount is sitting on top of /app/models and hiding it, exactly the way a whiteout hid a “deleted” file in “Make It Safe to Run Anywhere,” except here it is a live mount doing the hiding rather than a layer marker. The bake-vs-mount decision reduces to one question for each file: must it persist or change without a rebuild?

python
def placement(
    filename: str, persists_or_changes_without_rebuild: bool, size_gb: float
) -> str:
    """Decide whether a file is baked into the image or mounted at run time."""
    if size_gb > 1.0:
        # too large for a layer regardless -- datasets belong mounted or in object storage
        return f"{filename}: MOUNT (too large to ship in a layer: {size_gb} GB)"
    if persists_or_changes_without_rebuild:
        return f"{filename}: MOUNT (must persist or change without a rebuild)"
    return f"{filename}: BAKE (part of the shipped artifact, changes only on rebuild)"


print(placement("app.py", persists_or_changes_without_rebuild=False, size_gb=0.0))
print(placement("uv.lock", persists_or_changes_without_rebuild=False, size_gb=0.0))
print(
    placement(
        "default_model.pkl", persists_or_changes_without_rebuild=False, size_gb=0.2
    )
)
print(placement("scores.log", persists_or_changes_without_rebuild=True, size_gb=0.0))
print(
    placement(
        "swappable_model.pkl", persists_or_changes_without_rebuild=True, size_gb=0.2
    )
)
print(
    placement(
        "kkbox_listening.csv", persists_or_changes_without_rebuild=False, size_gb=30.0
    )
)

Code, the lock, and the default model bake, because they are the shipped artifact and change only when you rebuild. The scoring log and a swappable model mount, because they must survive or change without a rebuild, and the 30 GB dataset mounts on size alone, which is why it never belonged in a layer in the first place. The scrolly contrasts the two paths through a docker rm: the write that lived in the writable layer and vanished, against the write that lived on a volume and survived.

The container writes a file

The running service writes a scoring log to /app/outputs/scores.log. With no volume, that write lands in the container’s one writable layer via copy-on-write, on top of the read-only image stack.

The writable layer is ephemeral

The writable layer exists only for the life of this container. The read-only image layers below it are untouched by the write: the new file lives entirely in the throwaway layer on top.

docker rm discards the write

A redeploy removes the container. docker rm discards the writable layer and everything in it, leaving the read-only image intact. The scoring log is gone, not corrupted, simply destroyed with the layer it lived in.

A volume lives outside the container

Add -v outputs:/app/outputs. Now the write to that path goes to a named volume the Docker daemon manages, outside the container’s filesystem, not to the writable layer. The same code, a different write target.

The volume survives the redeploy

docker rm discards the writable layer as before, but the volume is not part of the container, so it persists. The next container mounts the same volume and finds the log still there. The inverse trap: an empty volume mounted over /app/models shadows the baked-in model, and the load fails.

The next lesson takes this one running service and stands it up alongside a small frontend container with a single docker compose up, where the same network-namespace fact from this lesson resurfaces as a new bug: localhost inside the frontend container is the frontend, not the backend.


Try It 3

The student’s service writes a scores.log to /app/outputs and bakes a default_model.pkl into the image. For a redeploy that recreates the container, complete the function so it returns, for each file, whether it survives the docker rm and the reason. Then name one file in their image that should be baked and one that should be mounted.

python
def survives_rm(path: str, on_volume: bool) -> tuple[bool, str]:
    # path: a file the running container has at this path
    # on_volume: True if path is under a mounted volume, False if in the image/writable layer
    # Return (survives_docker_rm, reason).
    if on_volume:
        return (False, "TODO")  # does volume data survive container removal?
    return (False, "TODO")  # does a writable-layer write survive?


print(
    "scores.log (run-time write, no volume):",
    survives_rm("/app/outputs/scores.log", on_volume=False),
)
print(
    "scores.log (on a volume):              ",
    survives_rm("/app/outputs/scores.log", on_volume=True),
)
print(
    "default_model.pkl (baked in image):    ",
    survives_rm("/app/default_model.pkl", on_volume=False),
)
Hint Separate two questions. First: where does a run-time write land when there is no volume? Recall the one writable layer and what `docker rm` does to it. Second: is a baked-in image file a *run-time write* at all, or is it part of the read-only image the `rm` never touches? A file the running container *created* is not in the same place as a file the image *shipped*. Re-read the writable-layer paragraph.

Solution

Here is each file resolved by separating the two questions the hint named: where a run-time write lands versus whether the file is a run-time write at all. Watch that scores.log and default_model.pkl survive for opposite reasons: one needs a volume, the other rides the read-only image the rm never touches.

python
def survives_rm(path: str, on_volume: bool) -> tuple[bool, str]:
    if on_volume:
        return (
            True,
            "lives on a volume outside the container; rm discards only the writable layer",
        )
    # No volume: a run-time write lands in the ephemeral writable layer.
    return (False, "written to the throwaway writable layer, discarded by docker rm")


# A baked-in image file is part of the read-only image, not a run-time write --
# it returns on every fresh container because the image is unchanged.
def baked_in_survives() -> tuple[bool, str]:
    return (True, "part of the read-only image; every new container starts from it")


print(
    "scores.log (run-time write, no volume):",
    survives_rm("/app/outputs/scores.log", on_volume=False),
)
print(
    "scores.log (on a volume):              ",
    survives_rm("/app/outputs/scores.log", on_volume=True),
)
print("default_model.pkl (baked in image):    ", baked_in_survives())

The run-time scores.log survives docker rm only when it is written to a volume; in the writable layer it is discarded. The baked-in default_model.pkl survives for a different reason entirely: it is part of the read-only image, so every fresh container starts with it, which is exactly why a swappable model belongs mounted (so it can change without a rebuild) while the default model belongs baked (it ships with the artifact).


Summary

  • A container lives exactly as long as its PID 1, the ENTRYPOINT process. A score-and-exit script returns, so the container goes to Exited; a service must run a process that blocks (a uvicorn event loop) so PID 1 never returns and the container stays Up.
  • docker ps lists only running containers, so a crashed-on-startup service looks identical to one that never started. docker ps -a and the exit code distinguish Exited (0) (completed cleanly) from Exited (1) (started and died).
  • A container has its own network namespace, so an internal port is unreachable from the host until it is published with -p host:container. Publishing is necessary but not sufficient: the server must also bind 0.0.0.0, because forwarded traffic arrives on the container’s external interface, not its loopback. A 127.0.0.1 bind refuses the request even with the port published and the container Up.
  • In -p host:container the two numbers are asymmetric: the host-side number is what a caller hits and may differ; the container-side number must match the server’s bind.
  • A container’s writable layer is discarded on docker rm, so anything written there is destroyed. State that must persist (outputs, logs, swappable models) lives on a bind mount or named volume; bake the shipped artifact, mount what must persist or change without a rebuild. An empty mount over a populated image path shadows the baked-in files, and the load fails.

Check your understanding:

  • Why does a python -m score container exit on its own while a uvicorn container stays Up, and which command tells a container that completed apart from one that crashed on startup?
  • A container is Up, the port is published with -p 8000:8000, and curl localhost:8000/predict is still refused. Without looking back: what is the most likely cause, and what changes to fix it?
  • A scoring log written inside a running container vanishes after a redeploy. Why, and what is the rule for deciding whether a given file belongs baked into the image or mounted as a volume?

This lesson is part of Pro

The Ship a Machine Learning Product path — every lesson, capstone, and the failure modes free tutorials skip. Sign in if you already have Pro, or unlock it below.

Unlock with Pro Sign in