Docker & ContainersXVI Β· PerformanceStartup
Container startup time β what dominates and how to measure
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
β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"]
| Phase | Typical warm | Typical cold | Dominated by |
|---|---|---|---|
| 1. Resolve | skipped | 50β500 ms | Registry latency, auth round trips |
| 2. Download | skipped | seconds to minutes | Bandwidth, image size, concurrency limit |
| 3. Extract | skipped | often longer than download | CPU (decompression) and disk writes |
| 4. Prepare + create | 50β300 ms | 50β300 ms | Snapshot mount, runc, seccomp |
| 5. Application init | 0.1 s to 60 s | same | The 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.
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=iecThe 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-downloadslayers 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.
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 dockerMeasuring 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.
docker events \
--filter 'type=container' \
--filter 'type=image' \
--format '{{.TimeNano}} {{.Type}} {{.Action}} {{.Actor.Attributes.name}}'$ 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 initIllustrative 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:
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>&1Expect 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
| Phase | What helps | What does not |
|---|---|---|
| Resolve | Registry mirror or pull-through cache on the local network | Smaller images |
| Download | Stable base layers so only the app layer changes; registry mirror | Raising concurrency when CPU-bound |
| Extract | Fewer files; faster disk; multi-stage builds that drop build artefacts | More bandwidth |
| Prepare + create | Nothing meaningful; it is already sub-second | Everything |
| App init | Lazy initialisation, deferred migrations, connection pool warm-up in background, AOT compilation | Smaller 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
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/nullPut 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
Q1. A container restarts 200 times a day on hosts that already have its image. How many image pulls does that cause?
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?
Q3. Which phase does `runc create` complete, and why does it not run the application?
Q4. Which of these reduce warm-start time β the case where the image is already on the host? Select all that apply.
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.