Mert Özen Further With Every Line
DevOps Docker — #03

The Container Lifecycle: docker run, ps, logs, exec, stop and Exit Codes

Mert Özen Nov 14, 2026 14 min 14 views
The Container Lifecycle: docker run, ps, logs, exec, stop and Exit Codes

docker stop sends SIGTERM, or the image's STOPSIGNAL, to the container's PID 1, waits 10 seconds and then sends SIGKILL; if the app was started behind a shell, the signal never arrives and the container dies with 137. With real output we cover container states, logs and exec, exit codes 0/1/137/139/143, ProcessExit no longer running on SIGTERM in .NET 10, graceful shutdown with the Generic Host, and restart policies.

Version note: The examples in this post were run with Docker Engine 29.9.3, .NET 10 (runtime 10.0.15, Microsoft.Extensions.Hosting 10.0.15), .NET 9 (runtime 9.0.23) for comparison, and nginx 1.30.

Guess First

Bookstore's catalog import tool, Bookstore.Tools, runs in a container that was started with this command: sh -c 'dotnet Bookstore.Tools.dll; echo import done'. During a deployment docker stop is called. How many seconds does the command take, and with which exit code does the container end? Second question: imagine the same tool is a plain .NET 10 console application that ignores signals entirely, with a handler attached to AppDomain.ProcessExit so it can do some work on the way out. Does that handler run on docker stop? On .NET 9 it did.

docker run: The First Container and Its Flags

We have used docker run a few times in the previous posts; now let's separate what it does in one go. docker run is really two steps: docker create prepares the container, meaning the file system layer, the settings and the network attachment, and docker start starts the process. Running them separately shows the state in between:

docker create --name dockerseries-web -p 8080:80 nginx:1.30-alpine
docker ps -a --format "table {{.Names}}\t{{.Status}}"
docker start dockerseries-web
docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
NAMES              STATUS
dockerseries-web   Created
NAMES              STATUS                  PORTS
dockerseries-web   Up Less than a second   0.0.0.0:8080->80/tcp, [::]:8080->80/tcp

In daily use you pass most flags to docker run. -d starts the container in the background and prints its ID; without it your terminal stays attached to the container's output. --name gives it a fixed name; without it Docker generates a random one, and you cannot create a second container with the same name. -p 8080:80 forwards the host's port 8080 to the container's port 80, with the details in post 12. -e ConnectionStrings__Default=... sets an environment variable, which .NET's configuration system reads. --rm removes the container when it stops. -it is two flags: -i keeps standard input open and -t allocates a terminal; docker run -it --rm alpine:3 sh gives you an interactive shell inside a container, and everything is removed when you type exit.

curl -s localhost:8080 | grep -o "<title>.*</title>"
docker logs dockerseries-web 2>&1 | grep -E "GET /"
<title>Welcome to nginx!</title>
172.18.0.1 - - [14/Nov/2026:10:34:10 +0000] "GET / HTTP/1.1" 200 896 "-" "curl/8.23.0" "-"

Container States

A container moves between a few states during its life, and the .State field of docker inspect records them:

StateHow you get thereWhat it means
createddocker createThe container is ready, the process has never started
runningdocker start, docker runThe main process is running
pauseddocker pauseThe processes are frozen with the cgroup freezer
restartingA restart policy kicked inThe process ended and Docker is starting it again
exitedThe main process ended, stop, killFile system and logs are kept, start runs it again
deadA removal was interruptedRare; usually removed by hand

The key point: a container lives exactly as long as its main process. When the main process ends, the container becomes exited, even if other processes are still running inside it. An exited container keeps its writable layer and its logs on disk until it is removed.

logs and exec

docker logs shows the standard output and standard error of the container's main process. If the application writes its logs to a file, docker logs does not see them; that is why logging to stdout is the rule in containers. -f follows live, --tail 50 limits to the last lines, --since 10m filters by time and -t adds Docker's timestamp to each line. Logs can still be read after the container has stopped, which will help with the exit code analysis below.

docker exec starts an additional process inside a running container: docker exec dockerseries-web nginx -v runs a single command, docker exec -it dockerseries-web sh opens an interactive shell. A process started with exec shares the same namespaces and cgroup, but it is not the container's main process: leaving the shell does not stop the container. exec only works on a running container; to look into a container that crashed, you start with its logs and exit code.

stop and kill: Signals and 10 Seconds

docker stop sends a stop signal to the container's main process, waits for a while, and kills it with SIGKILL if it is still alive. According to Docker's documentation the signal comes from the image's STOPSIGNAL and defaults to SIGTERM; the wait is 10 seconds for Linux containers and 30 seconds for Windows containers. The nginx image has chosen a different signal:

docker inspect -f "StopSignal={{.Config.StopSignal}}" dockerseries-web
docker stop dockerseries-web
docker logs dockerseries-web 2>&1 | grep -E "SIGQUIT|gracefully" | head -3
docker inspect -f "Status={{.State.Status}} ExitCode={{.State.ExitCode}}" dockerseries-web
StopSignal=SIGQUIT
2026/11/14 10:33:49 [notice] 1#1: signal 3 (SIGQUIT) received, shutting down
2026/11/14 10:33:49 [notice] 30#30: gracefully shutting down
2026/11/14 10:33:49 [notice] 31#31: gracefully shutting down
Status=exited ExitCode=0

For nginx, SIGQUIT means "finish open connections, then shut down", while SIGTERM is a fast shutdown. The image's maintainers picked the right signal with STOPSIGNAL, so you get a graceful shutdown without doing anything. docker kill, on the other hand, sends SIGKILL straight away by default:

docker start dockerseries-web
docker kill dockerseries-web
docker ps -a --format "table {{.Names}}\t{{.Status}}"
NAMES              STATUS
dockerseries-web   Exited (137) Less than a second ago

You change the wait once with docker stop -t 30 or per container with docker run --stop-timeout 30; since Engine 29.7 the daemon also has a default-stop-timeout setting. The usual reason to change it: if your application takes longer than 10 seconds to shut down, you either make it faster or set the wait slightly longer than the application's shutdown time.

Reading Exit Codes

A container's exit code is that of its main process. If the process exited on its own you see the code it chose; if a signal killed it you see 128 plus the signal number. Let's produce a few cases on purpose:

docker run --name dockerseries-exit0 alpine:3 sh -c "exit 0"
docker run --name dockerseries-exit1 alpine:3 sh -c "echo connection failed >&2; exit 1"
docker run --name dockerseries-oom --memory 64m alpine:3 sh -c "tail /dev/zero"
docker ps -a --filter name=dockerseries- --format "table {{.Names}}\t{{.Status}}"
docker inspect -f "{{.Name}} ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}" dockerseries-exit1 dockerseries-oom
connection failed
NAMES                STATUS
dockerseries-oom     Exited (137) Less than a second ago
dockerseries-exit1   Exited (1) Less than a second ago
dockerseries-exit0   Exited (0) Less than a second ago
/dockerseries-exit1 ExitCode=1 OOMKilled=false
/dockerseries-oom ExitCode=137 OOMKilled=true

tail /dev/zero never finds a line ending, so it fills memory; when it hit the 64 MB limit, the kernel's OOM killer ended it with SIGKILL. 137 on its own does not mean "OOM": docker kill and the SIGKILL that docker stop sends after the timeout also produce 137. The OOMKilled field tells them apart. The codes you will see most often:

  • 0: the process ended normally.
  • 1: an application error; an unhandled exception or a code the application chose.
  • 137, 128 + 9, SIGKILL: docker kill, an expired stop timeout or OOMKilled=true.
  • 139, 128 + 11, SIGSEGV: a memory error in native code.
  • 143, 128 + 15, SIGTERM: the process died from the signal's default action.

Trying to produce 139 reveals the first surprise of PID 1:

docker run --name dockerseries-segv alpine:3 sh -c "kill -SEGV \$\$"
docker run --init --name dockerseries-segv-init alpine:3 sh -c "kill -SEGV \$\$"
docker ps -a --filter name=dockerseries-segv --format "table {{.Names}}\t{{.Status}}"
NAMES                    STATUS
dockerseries-segv-init   Exited (139) Less than a second ago
dockerseries-segv        Exited (0) Less than a second ago

The shell sent itself SIGSEGV, yet nothing happened in the first container and the shell exited with 0. The reason is that the kernel treats the first process of a PID namespace, PID 1, specially: a signal sent to PID 1 is ignored unless the process has registered a handler for it, with SIGKILL and SIGSTOP from outside the namespace as the exception. In the second container, started with --init, PID 1 is Docker's small init process, docker-init; the shell is an ordinary process and the signal's default action killed it. This rule also determines how .NET applications behave on docker stop.

The PID 1 Problem and .NET 10

The first version of the import tool is a plain console application that ignores signals. A handler on ProcessExit is supposed to save the import state on the way out:

// First version: no signal handling at all.
AppDomain.CurrentDomain.ProcessExit += (_, _) => Console.WriteLine("ProcessExit: flushing import state");

const int BatchCount = 30;
for (int batch = 1; batch <= BatchCount; batch++)
{
    Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss} batch {batch}/{BatchCount} imported");
    Thread.Sleep(1000);
}

Console.WriteLine("Import finished");

We have not written a Dockerfile yet, that is the subject of post 5, so we publish the application on the host and mount the output into the runtime image. I also built the same code with a net9.0 target and ran it on the .NET 9 runtime image:

dotnet publish -c Release -o out
docker run -d --name dockerseries-tools -v "${PWD}/out:/app" -w /app mcr.microsoft.com/dotnet/runtime:10.0 dotnet Bookstore.Tools.dll
docker stop dockerseries-tools
docker logs --tail 2 dockerseries-tools
docker inspect -f "ExitCode={{.State.ExitCode}}" dockerseries-tools
# .NET 10
10:32:05 batch 3/30 imported
10:32:06 batch 4/30 imported
ExitCode=143
# .NET 9
10:32:22 batch 3/30 imported
ProcessExit: flushing import state
ExitCode=143

The answer to the second question: on .NET 10 the handler does not run. This is a documented .NET 10 breaking change. According to Microsoft's page, the runtime no longer registers a default SIGTERM handler on Unix; the operating system's default behaviour terminates the process immediately and the ProcessExit and AssemblyLoadContext.Unloading events are not raised. On .NET 9 the runtime's own handler raised those events and then exited. The exit code is 143 in both cases; the difference is whether your shutdown code ran. The signal was not ignored even though dotnet is PID 1 here, because the runtime's native layer catches the signal first and, on .NET 10, then applies the operating system's default behaviour. Microsoft recommends two paths: applications using ASP.NET Core or the Generic Host already register their own handlers; an application without a host sets up its own handler with PosixSignalRegistration.Create(PosixSignal.SIGTERM, ...).

Now to the first question: what happens if the same application is started through a shell?

docker run -d --name dockerseries-tools -v "${PWD}/out:/app" -w /app mcr.microsoft.com/dotnet/runtime:10.0 sh -c 'dotnet Bookstore.Tools.dll; echo import done'
docker top dockerseries-tools -o pid,args
time docker stop dockerseries-tools
docker inspect -f "ExitCode={{.State.ExitCode}}" dockerseries-tools
PID                 COMMAND
892                 sh -c dotnet Bookstore.Tools.dll; echo import done
907                 dotnet Bookstore.Tools.dll
dockerseries-tools
real	0m 10.11s
user	0m 0.01s
sys	0m 0.00s
ExitCode=137

The container's PID 1 is now sh, not dotnet. The SIGTERM from docker stop goes to PID 1, the shell; the shell has no handler for it, so the kernel ignores the signal and dotnet never sees it. Docker waits 10 seconds and then sends SIGKILL: the answer is "10 seconds and 137". You lose 10 seconds per container on every deployment and the application never shuts down cleanly.

There are three fixes, each with a different limit:

# 1. exec: the shell replaces itself with dotnet, dotnet becomes PID 1
sh -c 'exec dotnet Bookstore.Tools.dll'
# 2. exec form in the Dockerfile: no shell in between at all (post 5)
ENTRYPOINT ["dotnet", "Bookstore.Tools.dll"]
# 3. --init: docker-init becomes PID 1 and forwards the signal to its direct child
docker run --init ...

With exec, dotnet became PID 1 and docker stop finished in 0.11 seconds, with exit code 143. When I tried --init with the shell command, the stop also took 0.12 seconds, but note what happened: docker-init forwarded the signal only to its own child, the shell; the shell died, and as the container shut down the dotnet process disappeared with the namespace through SIGKILL. So --init does not carry the signal to the application behind the shell; it reaps zombie processes and removes the PID 1 protection, and that is all. The right fix is to make the application itself PID 1, with the exec form, and to have the application respond to SIGTERM.

Graceful Shutdown with the Generic Host

Let's move the import tool onto the Generic Host. The host's default lifetime is ConsoleLifetime; according to the documentation it listens for SIGINT, SIGQUIT and SIGTERM and tries to stop the host gracefully. That is also what compensates for the .NET 10 change on our behalf:

using Bookstore.Tools;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddSimpleConsole(options =>
{
    options.SingleLine = true;
    options.TimestampFormat = "HH:mm:ss ";
});
builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = TimeSpan.FromSeconds(8));
builder.Services.AddHostedService<ImportWorker>();

using IHost host = builder.Build();
await host.RunAsync();
namespace Bookstore.Tools;

public sealed class ImportWorker(ILogger<ImportWorker> logger, IHostApplicationLifetime lifetime) : BackgroundService
{
    private const int BatchCount = 30;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        int batch = 0;
        try
        {
            while (batch < BatchCount)
            {
                batch++;
                await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
                logger.LogInformation("Batch {Batch}/{Total} imported", batch, BatchCount);
            }

            logger.LogInformation("Import finished");
        }
        catch (OperationCanceledException)
        {
            logger.LogWarning("Import stopped during batch {Batch}; next run resumes from batch {Batch}", batch, batch);
        }
        finally
        {
            lifetime.StopApplication();
        }
    }
}

The project uses the Microsoft.Extensions.Hosting 10.0.15 package; the Microsoft.Extensions.DependencyInjection, Hosting and Logging namespaces are in GlobalUsings.cs. When the import finishes, StopApplication shuts the host down, so the tool also works as a one-off job. The same docker run and docker stop:

10:33:11 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
10:33:11 info: Microsoft.Hosting.Lifetime[0] Hosting environment: Production
10:33:11 info: Microsoft.Hosting.Lifetime[0] Content root path: /app
10:33:12 info: Bookstore.Tools.ImportWorker[0] Batch 1/30 imported
10:33:13 info: Bookstore.Tools.ImportWorker[0] Batch 2/30 imported
10:33:14 info: Bookstore.Tools.ImportWorker[0] Batch 3/30 imported
10:33:15 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
10:33:15 warn: Bookstore.Tools.ImportWorker[0] Import stopped during batch 4; next run resumes from batch 4
ExitCode=0

docker stop finished in 0.13 seconds, the worker saw the cancellation, logged the unfinished batch and the process exited with 0. I lowered ShutdownTimeout to 8 seconds for this reason: its default is 30 seconds, while Docker waits 10. If the host's shutdown time is longer than Docker's, Docker cuts a slowly stopping hosted service short with SIGKILL, and your shutdown code is left half done again. The same arithmetic applies to terminationGracePeriodSeconds in Kubernetes; the Kubernetes series post "Graceful Shutdown: SIGTERM, terminationGracePeriodSeconds, preStop and .NET HostOptions" covers it in detail. Trying this code with the shell wrapper gave the result we expected: 10.12 seconds and 137, with no Application is shutting down line at all.

Restart Policies

Docker can restart a crashed container on its own. There are four policies: no, the default; on-failure[:N], on a non-zero exit code and at most N times; always; and unless-stopped. Let's see on-failure with a job that always fails:

docker run -d --name dockerseries-retry --restart on-failure:3 alpine:3 sh -c "echo \"\$(date +%T) started\"; exit 1"
docker logs dockerseries-retry
docker inspect -f "RestartCount={{.RestartCount}} Status={{.State.Status}} ExitCode={{.State.ExitCode}}" dockerseries-retry
10:34:17 started
10:34:17 started
10:34:17 started
10:34:18 started
RestartCount=3 Status=exited ExitCode=1

One initial run and three retries; the gaps grow with Docker's increasing delay. The difference between always and unless-stopped only shows when the daemon restarts. I started two nginx containers with these two policies, stopped both with docker stop and then restarted the daemon:

docker run -d --name dockerseries-always --restart always nginx:1.30-alpine
docker run -d --name dockerseries-unless --restart unless-stopped nginx:1.30-alpine
docker stop dockerseries-always dockerseries-unless
# the daemon was restarted (on Linux: sudo systemctl restart docker)
docker ps -a --filter name=dockerseries- --format "table {{.Names}}\t{{.Status}}"
NAMES                 STATUS
dockerseries-unless   Exited (0) 26 seconds ago
dockerseries-always   Up 9 seconds

always starts the container when the daemon comes up even if it was stopped by hand; unless-stopped respects your decision to stop it. For services that must come back after a server reboot but that you can stop by hand for maintenance, unless-stopped is usually the better choice. A restart policy works on a single machine and only looks at whether the process died; it does not know whether the application responds. Orchestration that decides based on health checks is covered in the Docker Compose and Kubernetes series.

In Production

Measure how many seconds each application takes to shut down on docker stop and put that into your deployment budget. Every deployment that runs into the 10 seconds means interrupted requests and logs that end in 137. For ASP.NET Core, Kestrel finishes open requests within ShutdownTimeout; keep that below Docker's or your orchestrator's wait.

Watch exit codes. A crashed container's exit code and its OOMKilled field are the first answer to "why did it stop?" 137 with OOMKilled=true points to the memory limit, 137 with false to an outside kill or an expired stop timeout, and 143 to an application that does not catch the signal.

Do not use a restart policy to hide failures. A container that keeps restarting with always looks innocent in docker ps, something like Up 3 seconds; unless you look at RestartCount, you will not notice it has been crashing and coming back for hours.

Common Pitfalls

Starting the application through a shell script or a shell-form command and swallowing the signal. If you need a script, make its last line exec dotnet Bookstore.Tools.dll; in the Dockerfile, use the exec form.

Shutdown code silently stopping to run in host-less tools after moving to .NET 10. If you have cleanup code attached to ProcessExit, either move to the Generic Host or set up a SIGTERM handler with PosixSignalRegistration.

Running docker run over and over without --rm. Every run leaves a container on disk. Running a job that produces a 50 MB report file three times:

docker ps -a -s --filter name=dockerseries-job --format "table {{.Names}}\t{{.Status}}\t{{.Size}}"
docker system df
NAMES               STATUS                              SIZE
dockerseries-job3   Exited (0) Less than a second ago   52.4MB (virtual 61.8MB)
dockerseries-job2   Exited (0) Less than a second ago   52.4MB (virtual 61.8MB)
dockerseries-job1   Exited (0) Less than a second ago   52.4MB (virtual 61.8MB)
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Containers      3         0         157.3MB   157.3MB (100%)

Use --rm for one-off jobs and remove the rest by name with docker rm. There is also docker container prune, which removes all stopped containers, but on a shared machine it also removes containers someone else left behind to investigate; use it knowing what it deletes.

Assuming a change made inside a container with docker exec is permanent. When the container is removed and recreated, it goes back to what is in the image.

A Small Experiment

★☆☆ Start a container from nginx:1.30-alpine, stop it once with docker stop and once with docker kill, and each time look at the last lines of docker logs and the exit code. What changes in the log when you use docker stop -s SIGTERM?

★★☆ Add a handler to the first version of the import tool with PosixSignalRegistration.Create(PosixSignal.SIGTERM, ...): the handler should set context.Cancel = true and tell the loop to stop. How do the exit code and the logs change after docker stop on the .NET 10 runtime image?

★★★ In the Generic Host version, raise ShutdownTimeout to 30 seconds and make the worker do a cleanup that takes 15 seconds when cancelled. Compare the results of docker stop and docker stop -t 20; write one sentence on how you would choose which timeout.

We have seen how a container starts and how it dies. In the next post we look inside the image the container comes from: layers, the difference between tags and digests, registries, Docker Hub limits, and multi-platform manifests that carry amd64 and arm64 images behind a single name.