Containerization and Docker Fundamentals Questions
Packaging applications into containers: images and layers, Dockerfiles, registries, image optimization and security, container networking and storage, and the container runtime model. Covers how containers differ from virtual machines, image build and management, and the fundamentals that underpin any orchestration platform. The container primitive before orchestration.
Using docker inspect and docker stats, how would you diagnose whether a containerized service's latency problem is CPU-bound, network-bound, or I/O-bound? Which fields and metrics would you look at, and how would you interpret them?
Sample Answer
Direct answer
docker stats gives the live signal (current CPU percentage, memory usage against its limit, cumulative network and disk I/O) to see which resource is actually saturated, and docker inspect gives the static configuration (the configured limits, network mode, and mount setup) needed to know whether what stats is showing is a real ceiling the container is hitting or just normal, unconstrained usage. Triage means reading both together, not either alone.
Which fields point to which bottleneck
- CPU-bound. In
docker stats, sustainedCPU %at or near the container's configured ceiling, not just a high absolute number, since a container capped to two CPUs can legitimately show up to 200% and that is not a problem by itself. I confirmed the ceiling calculation directly: a container started with--cpus=1reportsNanoCpus=1000000000underdocker inspect --format='{{.HostConfig.NanoCpus}}'(NanoCpus expresses the CPU allocation in billionths of a CPU, so one billion nanocpus equals one full CPU), and running a tight busy loop inside it,docker statsshowedCPU %pinned at 99.38%, essentially its full allotment. Latency that scales with concurrent request volume while CPU sits pinned at its configured ceiling is the CPU-bound signature. - Network-bound. The
NET I/Ocolumn indocker statsis a cumulative byte counter since the container started, not a rate, so a single snapshot tells you very little; the standard method is to sample it twice with a known interval apart (docker stats --no-stream <container>at time zero and again five seconds later) and divide the difference in bytes by the elapsed time to get an actual throughput rate. A rate that plateaus near a known link, proxy, or upstream service limit, while CPU usage stays comparatively idle, points to network as the bottleneck rather than compute.docker inspect --format='{{.HostConfig.NetworkMode}}'is worth checking alongside this, since a bridge network's NAT (Network Address Translation) overhead is a different kind of network cost than host networking, and ruling out the virtual network layer itself matters before blaming the remote endpoint. - I/O-bound (disk). The
BLOCK I/Ocolumn has the same cumulative-counter caveat as network I/O and needs the same sample-twice-and-divide approach. Correlate a climbing block I/O rate with low CPU usage anddocker inspect's mount configuration: writes landing on the container's own writable layer (the union filesystem, typicallyoverlay2, that backs every container's top layer) are slower for write-heavy workloads than writes to a bind-mounted host path or a dedicated named volume, which bypass that union filesystem layer for the hot path. If a workload is doing many small writes directly inside the container's own filesystem rather than a mounted volume, that is frequently the actual root cause, not the disk hardware itself.
Worked example
The rate-from-cumulative-counter pattern, computed generically: if docker stats --no-stream reports a cumulative byte count of B0 at time T0 and B1 at time T1, the average throughput over that window is (B1 - B0) / (T1 - T0) bytes per second. This is the correct way to read both the NET I/O and BLOCK I/O columns; reading either as an instantaneous rate from a single sample systematically misdiagnoses a bursty workload as either far busier or far quieter than it actually is, depending on when the single sample happened to land.
Plugged in with real numbers: I measured a container's cumulative write-byte counter directly (the same cgroup accounting the BLOCK I/O column is built from) at B0 = 1,048,576 bytes at T0; after writing 20 MB to a file inside the container, it read B1 = 22,024,192 bytes at T1, with T1 - T0 measured at 0.2225 seconds. (B1 - B0) / (T1 - T0) = (22,024,192 - 1,048,576) / 0.2225 = 20,975,616 / 0.2225 ≈ 94,272,431 bytes per second, roughly 94.3 MB/s for that write. Sampling docker stats --no-stream twice, a known number of seconds apart, and doing that same subtraction and division on its reported NET I/O or BLOCK I/O column is exactly this calculation, just read off the human-readable CLI output instead of the raw cgroup counter.
Trade-offs and pitfalls
The most common mistake is treating a single docker stats snapshot as sufficient evidence: both I/O columns are cumulative and CPU percentage can be a brief spike unrelated to the actual sustained bottleneck, so a triage based on one snapshot is little better than a guess. A second pitfall is checking docker stats without also checking docker inspect's configured limits: a container legitimately pinned at 100% of a --cpus=0.5 allocation looks identical in stats to a container genuinely starved for CPU on an unconstrained host, and only the inspect output tells you which situation you are actually in.
Compare strategies for managing Python dependencies inside a container: conda environments versus pip with virtualenv or Poetry, including how you'd handle compiled dependencies like CUDA wheels or custom native extensions. How do you keep dependency resolution reproducible and upgrades safe across CI runs?
Sample Answer
Direct answer
For a service running in a container, pip with a lockfile-generating tool (Poetry, or in 2026 increasingly uv) is the better default: it produces small, fast-to-build images and plays well with Docker's layer caching. Reach for conda only when you specifically need conda-forge's precompiled, ABI-matched native stack (BLAS/CUDA combinations that don't ship as pip wheels) and are willing to pay for a larger image and a slower dependency solve. Whatever you pick, compiled dependencies (CUDA wheels, custom C/C++ or Rust extensions) get built once in a dedicated build stage and shipped as an artifact, never compiled fresh inside the runtime image or at container start.
Structured elaboration
Terms first: a wheel is Python's prebuilt binary package format (.whl); manylinux is the tagging convention that marks a wheel as portable across Linux distributions with a compatible glibc, which is what lets pip install torch fetch a working binary instead of compiling from source; the ABI (application binary interface) is the low-level contract, function layouts, calling conventions, that a compiled extension and the interpreter or CUDA runtime it links against must agree on (mismatch it and you get an import-time crash, not a helpful error message); CUDA is NVIDIA's GPU programming toolkit and runtime, and BLAS is a standard, low-level library interface for linear-algebra operations like matrix multiplication; a lockfile pins every resolved dependency, direct and transitive, to an exact version and ideally a hash, so the same input always resolves to the same set of packages.
| conda | pip + virtualenv | Poetry | |
|---|---|---|---|
| What it manages | Python interpreter, native libraries (BLAS, CUDA runtime pieces), and Python packages, in one solver | Just Python packages; the interpreter and system libraries are the base image's job | Same scope as pip, but adds a resolver and a lockfile on top of pyproject.toml |
| Best fit for compiled deps | Strong: conda-forge ships prebuilt, cross-linked native binaries (MKL, CUDA-aware NumPy/SciPy) that are painful to build from source | Fine if manylinux/CUDA wheels exist for your platform; weak if they don't | Same as pip since it installs from the same wheel index; no native-package solver of its own |
| Reproducibility | Depends on pinning exact build strings, not just versions; conda-forge channel drift can change results on a re-solve | Reproducible with pip-compile --generate-hashes or equivalent; a bare requirements.txt with loose versions is not | poetry.lock pins exact versions and hashes by default |
| Image size / build speed | Larger images, slower dependency solve | Small, fast | Small, fast; resolution can be slow on large dependency graphs |
| CI ergonomics | Needs conda-lock (or a pinned, exported environment.yml) to get lockfile behavior at all | Native fit for pip install -r requirements.lock.txt --require-hashes | Native fit: poetry install --no-root against a committed lockfile |
By 2026 a fourth option is common enough to name even though the question didn't ask for it: uv, a Rust-based, pip-compatible installer and resolver that produces a single cross-platform lockfile (uv.lock) and resolves noticeably faster than Poetry on large dependency graphs. It doesn't change the underlying trade-off above (it's still "pip-family," so the conda-vs-pip split still applies), it's simply a faster, more current implementation of the pip and Poetry side of that split, and it's a reasonable default for new projects.
Handling compiled dependencies (CUDA wheels, custom native extensions)
- Match the wheel's CUDA build to your runtime, not the other way around. A wheel like
torch==2.x+cu124is compiled against CUDA 12.4's ABI; installing it on an image whose CUDA driver or toolkit is older will fail at import or, worse, silently fall back to running on CPU. Pin the CUDA version in your base image tag (for examplenvidia/cuda:12.4.1-runtime-ubuntu22.04) and the wheel's CUDA suffix together, and treat that pair as one unit to upgrade, not two independent version bumps. - For a custom native extension (a C, C++, or Rust module wrapped for Python), use a multi-stage Dockerfile: a builder stage on the CUDA devel image (which has the compiler toolchain and headers) builds the extension into a wheel; the runtime stage, based on the much smaller CUDA runtime image (no compiler, no headers), only ever installs that already-built wheel. This keeps compilers and headers out of the production image, shrinking it and reducing its attack surface, and it turns the compiled artifact into something you can cache and reuse across builds instead of recompiling every time.
flowchart LR
A[Builder stage: CUDA devel image plus compilers] --> B[Compile native extension into a wheel]
B --> C[Runtime stage: CUDA runtime image, no compilers]
D[Locked requirements file: poetry.lock or uv.lock] --> C
C --> E[Slim production image]
Keeping resolution reproducible and upgrades safe in CI
- Never let a CI build re-resolve dependencies from loose version ranges. Resolve once (locally, or in a dedicated update job), commit the lockfile, and have every ordinary CI run simply install from that lockfile with hash verification. That turns "does this build reproducibly" into a solved problem: the lockfile is the single source of truth for what "this build" means.
- Pin the base image by digest, not just a mutable tag, so a tag repoint upstream can't silently change your build.
- Separate the update path from the build path: a scheduled job (Renovate, Dependabot, or a manual
poetry updateoruv lock --upgrade) opens a pull request that bumps the lockfile and nothing else, that pull request runs the full test matrix, including any CUDA-dependent tests on a GPU runner, and a human or a required check approves the merge. Ordinary feature-branch CI never touches dependency versions at all.
Worked example
A team ships a FastAPI inference service with a custom CUDA kernel wrapped via pybind11. The Dockerfile has two stages: FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 AS builder installs the CUDA toolkit headers and a compiler, then runs pip wheel . -w /wheels to produce custom_op-1.0.0-cp311-cp311-linux_x86_64.whl. The second stage, FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04, copies only /wheels/*.whl and the project's uv.lock, then runs uv pip install --no-cache /wheels/*.whl -r requirements.lock.txt. The devel image's compiler toolchain and headers never make it into the shipped image; the runtime stage only carries the CUDA runtime libraries the compiled kernel links against at inference time, plus the pure-Python dependencies pinned in the lockfile. A nightly job runs uv lock --upgrade on a separate branch, and only that branch's CI is allowed to change what the lockfile pins.
Trade-offs & pitfalls
- Mixing
conda installandpip installin the same environment is a common wrong turn: conda's solver doesn't know about packages pip installed afterward, so a laterconda updatecan silently break binary linkage that pip had put in place (a NumPy build expecting conda's BLAS, next to a pip-installed package expecting a different one). - A
requirements.txtwith unpinned or loosely pinned transitive dependencies isn't reproducible even if the top-level packages are pinned; a transitive C-extension dependency can float to a new version with a different ABI and only fail on the CUDA-enabled CI runner, not a developer's laptop. - Recompiling a native extension inside the final production build step, instead of shipping a pre-built wheel from a builder stage, means what CI tested and what got deployed are two different compiled artifacts even if the source was identical, because compiler flags, library versions, or the timing of an upstream release can differ between the two builds.
- Both
conda-lockand hash-pinned pip files solve reproducibility, but neither protects against a conda channel or PyPI index quietly yanking a version; vendoring or mirroring critical wheels internally is the fix once that has caused an outage.
Your organization builds dozens of services from a monorepo on distributed CI runners with poor cache locality, so Docker layer caches rarely hit and builds are slow. Design a build-cache strategy: shared base images, BuildKit remote cache export/import (--cache-from/--cache-to), cache namespacing per service, and how you'd secure and store the remote cache.
Sample Answer
Direct answer
On distributed CI (continuous integration) runners, the local build cache that normally makes instruction-ordering discipline work does not help, because a given job can land on a different, cold runner every time with no prior build history at all. The fix is to stop relying on any one runner's local cache and make the cache itself a portable artifact, using the remote cache export and import built into BuildKit (Docker's modern build engine, driven from the command line through the docker buildx plugin) against a registry, so a fresh runner can pull down another runner's cache before it builds anything.
The design, piece by piece
Shared base images. Build and publish one pinned internal base image, with the OS, language runtime, and common system packages already installed, that every service's Dockerfile starts FROM, instead of forty services each independently installing the same packages. This collapses the expensive, rarely-changing part of every build into one artifact that only needs rebuilding when the base itself changes.
BuildKit remote cache export and import. docker buildx build --cache-to=type=registry,ref=<registry>/<service>-cache,mode=max ... pushes the build's layer cache to a registry as its own artifact after every build. --cache-from=type=registry,ref=<registry>/<service>-cache on the next build, on any runner, cold or warm, pulls that cache down before building starts, so a fresh runner behaves as if it had just built this service minutes ago. mode=max, versus the default mode=min, exports cache for every intermediate layer, including ones that never end up in the final image, which matters for multi-stage Dockerfiles where a builder stage's cache would otherwise be discarded.
Cache namespacing per service. One shared cache reference for all forty services in the monorepo causes constant cross-service cache thrashing, where one service's build evicts or overwrites cache entries another service needed. Namespace the cache reference per service, and typically per branch too (<registry>/cache/<service>:<branch>, with a fallback import from :main when a feature branch has no cache of its own yet), so each service's cache import only ever competes with its own history, never the whole monorepo's.
Securing and storing the remote cache. The cache registry needs the same access control as the image registry, because a poisoned cache blob is later reused as literal layer content in a real build, making it a genuine supply-chain risk, not a throwaway artifact. Use a private repository with the CI system's own short-lived, narrowly scoped credentials rather than a long-lived shared token. Plan for retention: cache blobs accumulate fast under mode=max across many services and branches, so a time-to-live or a periodic prune job on the cache repository is needed, since unlike the production image registry, cache artifacts have no long-term retention requirement.
flowchart LR
RunnerA["cold CI runner A"] -->|"pull"| Base["shared base image"]
RunnerA -->|"--cache-from"| CacheReg["cache registry (per-service namespace)"]
RunnerA -->|"build"| Image["service image"]
Image -->|"--cache-to"| CacheReg
RunnerB["cold CI runner B"] -->|"--cache-from"| CacheReg
Worked example
A Node.js service in the monorepo starts FROM internal/node-base:20.3-slim (the shared base), then COPY package*.json . followed by RUN npm ci (the dependency layer, ordered so the most rarely changing inputs come first), then COPY . .. The CI job runs docker buildx build --cache-from=type=registry,ref=registry.internal/cache/checkout-service:main --cache-to=type=registry,ref=registry.internal/cache/checkout-service:main,mode=max -t checkout-service:$SHA .. The first build on a brand-new runner still pulls the npm ci layer from the registry cache blob instead of recomputing it, because the cache import happens before the build starts, entirely independent of anything that specific runner has ever built before.
Trade-offs and pitfalls
mode=max cache exports are noticeably larger to push and pull than mode=min, a bandwidth-versus-hit-rate trade-off that gets worse the more services share one CI cluster's egress (its outbound network capacity to the registry). Per-branch namespacing needs a sane fallback chain, branch cache miss falling back to the main branch's cache, or every new feature branch pays a full cold build. A shared base image becomes a coordination point in its own right: bumping it now needs a rollout plan across every dependent service, rather than each service upgrading on its own schedule, a real cost worth naming rather than treating this design as a pure win.
What role do image registries (Docker Hub, AWS ECR, GCR, Azure ACR) play in the container workflow, and why might an organization run a private registry instead of only using a public one? Cover tagging conventions (semantic versions vs. commit SHAs), and authentication/access control for pushing and pulling in production.
Sample Answer
Direct answer
An image registry stores, versions, and serves container images the way a package repository serves libraries: docker push uploads layers and a manifest to it, docker pull (or a deployment step) downloads them. Docker Hub is the default public registry; AWS ECR (Elastic Container Registry), Azure Container Registry (ACR), and Google Artifact Registry are the major cloud-managed alternatives. An organization runs a private registry, rather than relying only on a public one, mainly for access control, reliability guarantees it does not get from a shared public service, and to keep proprietary images out of a public namespace entirely.
Naming note: Google Container Registry is retired
The question's third option, GCR (Google Container Registry, gcr.io), is retired for new use: Google shut down write access on March 18, 2025, so docker push to a Container Registry-hosted repository no longer works. Read access was not cut off on a fixed date the way write access was; Google migrated existing gcr.io image references to be served through Artifact Registry under that same hostname, so images already published there keep pulling successfully rather than disappearing. Google's current offering for new work is Artifact Registry, which also supports legacy gcr.io-style hostnames for images that were already there. A candidate who has used the older name should recognize it, but new work should target Artifact Registry directly, and the operational risk to plan for is push/CI credentials configured against Container Registry breaking, not already-published images becoming unreachable.
Why run a private registry
A public registry like Docker Hub is a fine default for open-source or non-sensitive images, but production teams usually want: access control (who can push a new version of a production image, and who can pull it, tied into the same identity system as everything else); rate-limit and reliability guarantees a shared public service does not promise for free-tier pulls at production scale; and simply not publishing proprietary application code as an image anyone on the internet can pull. Cloud-managed private registries (ECR, ACR, Artifact Registry) add tight integration with each cloud's identity and access management (IAM) system, so a compute service can be granted pull access without embedding long-lived credentials.
Tagging conventions
Two conventions dominate, and most mature pipelines use both for different purposes: semantic version tags (v1.4.2) are meant for humans and for deliberate promotion between environments (staging tests v1.4.2, then production is told to run v1.4.2), while commit-SHA tags (myapp:a1b2c3d) are generated automatically on every build and give perfect traceability back to the exact source commit, which a semantic version alone does not (two builds of "the same" v1.4.2 from a hotfixed branch would otherwise be indistinguishable). Many pipelines push both tags to the same underlying image so a human-readable release name and an exact build provenance trail both exist.
Authentication and access control in production
Pushing and pulling from a private registry requires authenticating first (docker login, or a cloud CLI's registry-login helper that exchanges cloud credentials for a short-lived registry token). In production, the strong pattern is to avoid long-lived, shared credentials entirely: a compute service (a CI runner, a Kubernetes node, a serverless container platform) is granted an IAM role or workload identity scoped to pull (and, for CI, push) specific repositories, and the registry issues short-lived tokens against that identity rather than a static username and password baked into a config file.
Write the docker run command to start a container that maps host port 5000 to container port 8080, mounts a host directory into the container read-only, sets an environment variable, names the container, restarts automatically, and runs detached. Explain what each flag does.
Sample Answer
Direct answer
docker run -d \
--name my-app \
-p 5000:8080 \
-v /host/data:/data:ro \
-e APP_ENV=production \
--restart unless-stopped \
myimage:latest
Each flag configures one distinct piece of container behavior: networking, storage, configuration, identity, resilience, and foreground/background mode.
What each flag does
-d(detached): runs the container in the background and returns your terminal immediately, instead of attaching to the container's stdout/stderr and blocking.--name my-app: gives the container a fixed, human-readable name (my-app) instead of Docker's randomly generated one, so later commands (docker logs my-app,docker stop my-app) can reference it directly.-p 5000:8080: publishes container port 8080 to host port 5000 (host:containerorder), so traffic arriving at the host's port 5000 gets routed into the container's port 8080.-v /host/data:/data:ro: bind-mounts the host directory/host/datainto the container at/data, and:romakes it read-only from inside the container, so the application can read files placed there but cannot modify or delete them.-e APP_ENV=production: sets an environment variable inside the container, visible to the running process, without baking it into the image.--restart unless-stopped: tells the Docker daemon to automatically restart this container if it exits or the host reboots, unless someone explicitly randocker stopon it (which is what distinguishes it fromalways, which would restart it even after an explicit stop).
Worked example, verified
Every flag above was tested directly against a running container (a small Python HTTP server standing in for myimage, listening on 8080 and serving files from /data):
$ docker run -d --name demo-app -p 5000:8080 -v /tmp/hostdir:/data:ro \
-e APP_ENV=production --restart unless-stopped \
python:3.12-slim python3 -m http.server 8080 --directory /data
$ curl http://127.0.0.1:5000/hello.txt
hello from host
$ docker exec demo-app env | grep APP_ENV
APP_ENV=production
$ docker exec demo-app touch /data/newfile
touch: /data/newfile: Read-only file system
$ docker inspect --format='{{.HostConfig.RestartPolicy.Name}}' demo-app
unless-stopped
The host-port-5000-to-container-port-8080 mapping worked end to end (the file was reachable through the mapped port), the environment variable was visible inside the container, the read-only mount genuinely refused a write from inside the container, and the restart policy was recorded exactly as specified.
Edge cases and trade-offs
-p host:container order is easy to get backwards; reversing it means the container listens on a port nobody is publishing and publishes a port nothing is listening on, which typically manifests as connection-refused with no obvious error pointing at the flag itself. A read-only bind mount (:ro) protects the mounted path specifically; it does nothing for the rest of the container's filesystem, so it is not a substitute for running as non-root or locking down the whole filesystem if that is the actual goal. --restart unless-stopped will keep restarting a container that is crash-looping due to a real bug, which can mask the underlying failure in monitoring that only watches for "container down" rather than "container has restarted 40 times in the last minute."
Unlock Full Question Bank
Get access to all Containerization and Docker Fundamentals interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.