Skip to main content
RunBook Academy

Docker & ContainersXVI Β· PerformanceStartup

Container startup time β€” what dominates and how to measure

Intermediate⏱ ~24 mindocker

What you'll learn

  • Decompose container startup into its five measurable phases
  • Measure each phase independently with docker events and timed runs
  • Explain why layer extraction often costs more than layer download
  • Identify which phases a smaller image actually improves
  • Tune healthcheck timing so readiness is reported when it is true

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12

Not yet marked complete on this device.

β€œContainer startup is slow” is not a diagnosis. It is five different problems wearing the same symptom, and the one you are actually experiencing determines whether the fix is a smaller image, a different registry, a faster disk, or a change to the application that has nothing to do with containers at all.

The five phases

flowchart LR
  P1["1 - resolve<br/>registry auth, manifest"] --> P2["2 - download<br/>compressed blobs"]
  P2 --> P3["3 - extract<br/>decompress, write layers"]
  P3 --> P4["4 - prepare + create<br/>snapshot, runc, namespaces"]
  P4 --> P5["5 - app init<br/>execve to ready"]
PhaseTypical warmTypical coldDominated by
1. Resolveskipped50–500 msRegistry latency, auth round trips
2. Downloadskippedseconds to minutesBandwidth, image size, concurrency limit
3. Extractskippedoften longer than downloadCPU (decompression) and disk writes
4. Prepare + create50–300 ms50–300 msSnapshot mount, runc, seccomp
5. Application init0.1 s to 60 ssameThe application. Nothing Docker does.

Phases 1 to 3 happen once per image per host, not once per container start. This is the single most commonly miscounted fact in startup capacity planning. A container restarting on a host that already has the image skips straight to phase 4, and the whole thing takes a few hundred milliseconds plus whatever the application needs.

Extraction is the phase nobody measures

docker pull reports download progress in a way that makes downloading look like the whole job. Watch the output carefully and you will see each layer go through Downloading and then Extracting, and on a fast network the second bar is the slow one.

The reason is that image layers ship as gzip-compressed tar archives. Getting them onto disk means decompressing them β€” which is CPU-bound, largely single-threaded per layer β€” and then writing every file individually to the backing filesystem, which for a layer with 40,000 small files is 40,000 create and write syscalls plus the metadata updates.

Read-only / Safeseparate download from extract
IMAGE=python:3.12-slim

docker image rm -f "$IMAGE" 2>/dev/null || true

# Compressed size on the wire, from the registry manifest.
docker manifest inspect "$IMAGE" \
| awk '/"size"/ {gsub(/[^0-9]/,"",$2); s+=$2} END {print "compressed bytes: " s}'

time docker pull "$IMAGE"

# Uncompressed size on disk.
docker image inspect "$IMAGE" --format '{{.Size}}' | numfmt --to=iec

The ratio between those two numbers is your decompression workload. A typical Debian-based image is 2.5 to 3 times larger on disk than on the wire, so a 300 MB pull writes close to a gigabyte.

Two consequences worth acting on:

  • A slow disk hurts cold start more than a slow network past about 200 Mbit/s. If your nodes pull from a local registry mirror over 10 GbE and cold start is still 40 seconds, stop looking at the network.
  • Layer count matters independently of size. The daemon downloads up to max-concurrent-downloads layers at once β€” default 3 β€” so an image with 30 thin layers pulls with more parallelism available than one with 3 fat ones. Raising the limit helps when latency-bound, and helps not at all when the bottleneck is decompression CPU.
Configuration changepull concurrency
sudo tee /etc/docker/daemon.json.new > /dev/null <<'EOF'
{
"max-concurrent-downloads": 6,
"max-download-attempts": 5
}
EOF

sudo python3 -m json.tool /etc/docker/daemon.json.new > /dev/null \
&& sudo mv /etc/docker/daemon.json.new /etc/docker/daemon.json \
&& sudo systemctl reload docker

Measuring the phases, not the total

time docker run gives you one number that hides five. docker events gives you the transitions, with timestamps, and that is what lets you attribute the time.

Read-only / Safeevent timeline
docker events \
--filter 'type=container' \
--filter 'type=image' \
--format '{{.TimeNano}} {{.Type}} {{.Action}} {{.Actor.Attributes.name}}'
Read-only / Safea cold start, decomposed
$ docker events --format '{{.TimeNano}} {{.Type}} {{.Action}}'
+0.000s  image      pull       # phases 1-3 begin
+8.412s  image      pull       # complete: 8.4s of download + extract
+8.431s  container  create     # phase 4: snapshot prepared, config written
+8.502s  container  start      # runc create + start: 71ms
+8.505s  container  exec_start
+9.870s  container  health_status: healthy   # phase 5: 1.37s of app init

Illustrative output

That decomposition is actionable in a way a single 9.9-second figure is not: 8.4 seconds of pull, 71 milliseconds of runtime work, and 1.4 seconds of application init. Nothing you do to the runtime will help. Making the image smaller helps the 8.4 seconds, which only occurs on hosts that do not have the image.

For the warm case, measure directly:

Read-only / Safewarm start floor
IMAGE=alpine:3.20
docker pull -q "$IMAGE" > /dev/null

# Warm the page cache with a throwaway run first.
docker run --rm "$IMAGE" true

for i in 1 2 3 4 5; do
/usr/bin/time -f '%e s' docker run --rm "$IMAGE" true
done 2>&1

Expect something in the 200–500 ms range, of which a large part is the CLI talking to the daemon over the socket rather than anything the kernel does. That is your floor. No application will ever start faster than this, and if your measured startup is 30 seconds, 0.3 of them belong to Docker.

Phase 5 is usually the one that matters

On a warm host, application init is everything. Docker contributes 300 ms and the application contributes the rest.

The honest list of what is actually happening in those seconds:

  • Runtime startup: JVM class loading and JIT warm-up, Python imports, .NET assembly loading. Frequently 2–20 seconds for a large JVM service.
  • Configuration and secret fetch, each of which is a network round trip with its own timeout.
  • Database connection pool establishment β€” often serialised, often with a handshake per connection.
  • Schema migrations run at boot. This is the one that turns a 3-second start into a 4-minute one, unpredictably, and it also makes rolling deploys unsafe.
  • Cache warming and index loading.

Reducing startup time, by phase

PhaseWhat helpsWhat does not
ResolveRegistry mirror or pull-through cache on the local networkSmaller images
DownloadStable base layers so only the app layer changes; registry mirrorRaising concurrency when CPU-bound
ExtractFewer files; faster disk; multi-stage builds that drop build artefactsMore bandwidth
Prepare + createNothing meaningful; it is already sub-secondEverything
App initLazy initialisation, deferred migrations, connection pool warm-up in background, AOT compilationSmaller images

Two techniques worth naming specifically:

Pre-pull on host provisioning. Bake the base images into the VM template or AMI, or run a docker pull in the node bootstrap. Phases 1 to 3 then never appear on the critical path of a scale-out event, which is exactly when you cannot afford them.

Lazy-loading snapshotters. stargz and overlaybd start the container before the image has fully arrived, fetching file content on demand. They genuinely eliminate most of phases 2 and 3 for large images. They also add a component to your storage stack, change your build pipeline, and make cold I/O inside the container unpredictable. Real technology, real complexity, worth it only when cold start is measured and dominant.

Verification that can fail

Read-only / Safestartup budget check
IMAGE=myapp:1.4.2
BUDGET=10

docker pull -q "$IMAGE" > /dev/null
CID=$(docker run -d --rm "$IMAGE")
START=$(date +%s)

while :; do
STATE=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID")
NOW=$(date +%s)
ELAPSED=$(( NOW - START ))
[ "$STATE" = healthy ] && { echo "PASS: healthy in $ELAPSED s"; break; }
[ "$ELAPSED" -ge "$BUDGET" ] && { echo "FAIL: still $STATE after $ELAPSED s"; docker rm -f "$CID"; exit 1; }
sleep 1
done

docker rm -f "$CID" > /dev/null

Put a number on it, in CI, against the warm case. A startup budget that is asserted is a startup budget that stays met; one that is only observed drifts upward by a second per release until somebody notices during an incident.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. A container restarts 200 times a day on hosts that already have its image. How many image pulls does that cause?

  2. Q2. On a 10 GbE link to a local registry mirror, a cold start still takes 40 seconds for a large image. What should you investigate first?

  3. Q3. Which phase does `runc create` complete, and why does it not run the application?

  4. Q4. Which of these reduce warm-start time β€” the case where the image is already on the host? Select all that apply.

  5. Q5. With the default HEALTHCHECK settings, a container that takes 60 seconds to initialise will be marked unhealthy before it finishes starting.

Passing score: 75%. Answers are checked in this browser.