Skip to content
Kumar Chandrachooda
Microservices

A Pedagogy Ladder of Dockerfiles

DShop.Api ships three Dockerfiles - plain, multistage, and multistage.advanced - as a teaching ladder. The top rung is broken, and all three share an ENTRYPOINT that quietly defeats graceful shutdown.

By Kumar Chandrachooda 20 Oct 2025 5 min read
Three stacked Docker layers as rungs of a ladder, the top one cracked

Most repositories have one Dockerfile. DShop.Api has three — Dockerfile, Dockerfile.multistage, and Dockerfile.multistage.advanced — and reading them in order is clearly meant to be a lesson: here is the naive way, here is the better way, here is the production way. It is a good teaching device, and it teaches two things the author did not intend. The top rung of the ladder is broken and cannot build. And all three rungs share a single line that silently disables graceful shutdown across the entire estate. This part climbs the ladder and reads what each rung actually does versus what it means to demonstrate.

Rung one: publish on the host

The plain Dockerfile is the “get it running” version:

FROM mcr.microsoft.com/dotnet/core/aspnet:2.2
WORKDIR /app
COPY ./src/DShop.Api/bin/docker .
ENV ASPNETCORE_URLS http://*:5000
ENV ASPNETCORE_ENVIRONMENT docker
EXPOSE 5000
ENTRYPOINT dotnet DShop.Api.dll

It copies an already-published app — bin/docker — into a runtime image. The build happened on the host (that is what dotnet-publish-all.sh in the umbrella is for); the container is just a shipping crate. Simple, small runtime image, and it works — as long as everyone remembers to publish first and the host's SDK matches the target. The lesson of rung one is “a container is a runtime, not a build.”

Note also the base image: mcr.microsoft.com/.... Earlier in the estate's history these Dockerfiles used microsoft/dotnet — the pre-MCR registry — and the monolith's Dockerfiles still do. Watching the base image migrate from microsoft/dotnet to mcr.microsoft.com/dotnet/core across the estate is a small piece of 2018–2019 archaeology sitting in plain sight.

And the monolith made a different foundational choice worth contrasting here. Its three Dockerfiles are identical except for one baked-in environment variable — ASPNETCORE_ENVIRONMENT set to development, docker, or production — so the environment is compiled into the image, and you ship a different image per environment. The gateway's ladder instead builds one image and expects the environment to be supplied at run time (the compose files pass CONSUL_ENABLED/VAULT_ENABLED as variables). The gateway's approach is the one that scales: an image should be an immutable artefact you promote unchanged from staging to production, its environment injected, not an image-per-environment matrix you rebuild for each stage. The two builds disagree on this quietly, and the gateway is right.

Rung two: build inside the image

The .multistage version moves the build into the image, so the host no longer needs an SDK:

FROM mcr.microsoft.com/dotnet/core/sdk AS build
WORKDIR /app
COPY . .
RUN dotnet publish src/DShop.Api -c Release -o out

FROM mcr.microsoft.com/dotnet/core/aspnet:2.2
WORKDIR /app
COPY --from=build /app/src/DShop.Api/out .
ENTRYPOINT dotnet DShop.Api.dll

A build stage on the SDK image, a runtime stage on the slim aspnet image, and COPY --from=build to carry the published output across. This is the correct multistage pattern, and the lesson of rung two is “build fat, ship thin.” It builds and runs.

But there is a time bomb in line one: the SDK image is untagged. mcr.microsoft.com/dotnet/core/sdk with no version pulls whatever latest is, while the runtime stage pins aspnet:2.2. On the day it was written those agreed. When the SDK's latest rolled to 3.0, this Dockerfile would fetch a 3.0 SDK, build the app, and copy the output into a 2.2 runtime — a mismatch that either fails to build or produces an image that won't start. An untagged build image is not “always up to date”; it is "guaranteed to drift away from your pinned runtime." Pin both, or pin neither, but never one.

Rung three: the broken production version

The .multistage.advanced Dockerfile is the ambitious one — parameterised with ARGs for the solution, project, configuration and NuGet feed, restoring before copying the full source so Docker caches the restore layer:

FROM mcr.microsoft.com/dotnet/core/sdk AS build
...
RUN dotnet publish ${api} -c ${configuration} -o out ${feed}

FROM mcr.microsoft.com/dotnet/core/aspnet:2.2
WORKDIR /app
ARG api=src/DShop.Api
COPY --from=builder /app/${api}/out/ .
ENTRYPOINT dotnet DShop.Api.dll

Everything about the intent is right — layer-cached restore, configurable feed, arg-driven paths. And it cannot build, because of one word. The build stage is named AS build. The copy reads COPY --from=builder. There is no stage named builder; Docker fails with “invalid from flag value builder.” The most sophisticated Dockerfile in the estate — the one meant to be the production exemplar — has never successfully produced an image. It is the buy path that never worked in a different file: the most advanced version is the broken one, because it was written last and run least. A one-word typo, build versus builder, and the top rung of the ladder holds no weight.

The line all three share

Look at the last line of every one of them: ENTRYPOINT dotnet DShop.Api.dllshell form, no brackets. Shell-form ENTRYPOINT runs the command via /bin/sh -c, which means PID 1 in the container is /bin/sh, not the .NET process. When Docker (or Kubernetes, or Consul-driven orchestration) sends SIGTERM to stop the container, it goes to PID 1 — the shell — which does not forward it to its child. So the .NET application never receives the shutdown signal. It is killed by the follow-up SIGKILL after the grace period, with no chance to run its shutdown hooks.

That matters more here than usual, because the gateway's shutdown hook is load-bearing:

applicationLifetime.ApplicationStopped.Register(() =>
{
    client.Agent.ServiceDeregister(consulServiceId);
    Container.Dispose();
});

On a clean stop, the service deregisters itself from Consul. But shell-form ENTRYPOINT means ApplicationStopped never fires on a container stop, so the service never deregisters. Consul is left holding a registration for an instance that is gone, routing traffic to a dead address until its health check finally times it out. The fix is exec form — ENTRYPOINT ["dotnet", "DShop.Api.dll"] — so the .NET process is PID 1 and receives the signal. One pair of brackets stands between “graceful deregistration” and “phantom instances in the registry,” and all three Dockerfiles are on the wrong side of it.

The honest ledger

To be fair to the ladder: a repository that ships three Dockerfiles as a progression is a good teaching instinct, and rungs one and two genuinely work and genuinely demonstrate their lesson. This is course material; showing the naive version next to the better one is exactly right for a learner. The broken advanced rung and the shell-form entrypoint are the kind of defects that only bite in production, and this estate never reached production — so they were never felt.

But felt-or-not, the durable lessons stack neatly:

  • Pin every stage's base image, not just the runtime — an untagged builder drifts off your pinned target and breaks silently later.
  • Exec-form ENTRYPOINT or forfeit your shutdown hooks — shell form makes /bin/sh PID 1, and your app never sees SIGTERM.
  • The most advanced artefact is the least exercised — the fancy Dockerfile, like the fancy code path, is where the untested typo hides.

Next, the machinery that would have noticed a phantom Consul registration, if it had been watched: the observability stack of 2018.