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.
Compare three ways to deliver runtime secrets (API keys, database credentials) to a running container: plain environment variables, a mounted secret file, and fetching from an external secrets provider at startup. Discuss the security and operational trade-offs of each.
Sample Answer
Direct answer
All three approaches get a secret into a running container, and they trade off along the same two axes every time: how easily the secret leaks to somewhere it should not, process listings, logs, crash dumps, and how much operational machinery, rotation, an external dependency at startup, the approach requires. Environment variables are the easiest to wire up and the easiest to leak. A mounted file is a meaningfully better default for most services. An external secrets provider fetched at startup is the strongest option once rotation and centralized audit actually matter enough to justify the added dependency.
Comparison
| Method | How it works | Leak surface | Rotation | Operational cost |
|---|---|---|---|---|
| Environment variable | Set via a run flag or the orchestrator's own secret-as-env-var feature | Visible in docker inspect, often readable from the process's own environment file under /proc, frequently and accidentally logged by frameworks that dump their configuration on startup or in a crash report, inherited automatically by every child process the application spawns | Requires a full container restart with a new value | Lowest; no extra tooling |
| Mounted secret file | Secret content written to a file, for example /run/secrets/db_password, via a volume, a tmpfs mount, or the orchestrator's native secrets object | Confined to a filesystem path with normal file permissions, not exposed in docker inspect or the process environment, not automatically inherited by child processes, but still readable in plaintext by anything with filesystem access inside the container | The orchestrator can often update the file's content in place without restarting the container, if the application re-reads the file | Low; most orchestrators provide this as a first-class primitive |
| External secrets provider fetched at startup | The application, or an init process, calls a secrets manager over the network at startup and holds the value only in memory | Never touches disk or a layer at all if handled carefully; centralizes access logging, who fetched which secret and when, at the provider itself; a bug that dumps process memory is still a real risk | Best: can rotate centrally and have the application re-fetch on an interval or a signal, with no container restart at all | Highest; a network dependency at container startup, plus its own availability and authentication concerns |
The decision
For most services, a mounted file beats a plain environment variable for the same delivery need, because it closes off the two most common accidental-leak paths, inspect output and inherited child-process environment, for close to zero extra operational cost; most orchestrators already support this as a first-class primitive. Paying for an external provider makes sense once rotation genuinely matters operationally, credentials that must rotate on a schedule or be revoked immediately after an incident, or once there are enough services that centralized access auditing is worth more than the added startup dependency. For a small number of low-change-frequency secrets, an external provider is often more machinery than the risk actually justifies.
What none of these three replace
Choosing among them is never a substitute for restricting who can execute into the container or read its filesystem and inspect output in the first place. All three assume some baseline of access control around the host and orchestrator; the delivery mechanism narrows the leak surface, it does not eliminate the need for that baseline.
Worked example
Take one database password, CorrectHorseBattery9, delivered three ways to a running container; the environment-variable and mounted-file cases below were built and run directly to confirm the claims above.
As a plain environment variable (docker run -e DB_PASSWORD=CorrectHorseBattery9 ...): docker inspect <container> --format='{{json .Config.Env}}' prints it straight back in cleartext, ["DB_PASSWORD=CorrectHorseBattery9", ...]. A shell spawned inside that same container inherits it with no extra step: running sh -c "env | grep DB_PASSWORD" from inside the container shows the child shell already has it, which is the concrete evidence behind "inherited automatically by every child process" in the table above.
As a mounted file instead (docker run -v ./db_password:/run/secrets/db_password:ro ...): the identical docker inspect --format='{{json .Config.Env}}' on this container comes back with no trace of the secret at all, and docker inspect's Mounts entry shows only the path, /run/secrets/db_password, never the file's content. Inside the container, ls -l /run/secrets/db_password shows whatever permission the source file had on the host, -rw-r--r-- for a file created with a plain shell redirect under a default umask; a bind mount does not tighten this on its own, so getting a genuinely restrictive -rw------- needs an explicit chmod 600 on the host file before mounting it, or a secrets primitive that manages file modes itself. Only an explicit cat of that exact path reveals the value regardless of the exact mode bits; a spawned child shell's own env | grep db_password comes back empty, confirming the file is not inherited the way an environment variable is.
An external secrets provider fetched at startup does not appear in docker inspect output or on any container filesystem path at all; the value exists only in the application's own process memory after it calls out over the network at startup, which is exactly why it closes off the two leak paths the other options cannot, at the cost of a real network dependency the moment the container starts.
Trade-offs and pitfalls
An external provider fetched at startup adds a genuine availability coupling: if the secrets service is unreachable when the container tries to start, the container fails to start too, which needs its own retry and alerting story, exactly the kind of failure mode that stays invisible until the one time it matters. A mounted file is only as good as the orchestrator's own storage of it before mounting; if the orchestrator's own secret store is itself an unencrypted value at rest, mounting it into the container is a real improvement over an environment variable, but not a complete solution on its own.
Your team has inconsistent Docker practices: different base image choices, ad hoc Python or Node versions, and frequent 'works on my machine' problems. As the person driving standardization, how would you define a team-wide container strategy, roll it out without blocking delivery, and measure whether it's actually improving reproducibility?
Sample Answer
Direct answer
Standardizing container practices across a team with inconsistent base images and ad hoc language versions works best as a small set of shared building blocks people opt into because they are genuinely easier, not a mandate enforced all at once. Define one or two approved base images per language, publish them somewhere central, migrate the highest-friction or most-broken services first as visible proof it works, and measure success by concrete, checkable signals (fewer "works on my machine" incidents, a shrinking count of services still on the old pattern), not by a one-time announcement that a policy now exists.
Structured elaboration
Defining the strategy
- Start from what is actually causing pain today, not from an abstract ideal: pull specific recent incidents (an image that broke because of an unpinned base image update, a new hire who lost half a day to a subtly wrong local toolchain version) and use them as the concrete justification for standardizing, rather than "best practice" as an argument on its own.
- Pick a small number of golden, versioned base images per language (one for Python, one for Node, and so on), each pinned to a specific, deliberately chosen version, published to an internal registry, and owned by a specific person or small group responsible for updating them on a known cadence.
- Write down the standard as a short, concrete checklist (which base image, how dependencies get pinned, what the multi-stage pattern should look like) rather than a long policy document; a checklist gets followed, a policy document gets skimmed once and forgotten.
Rolling it out without blocking delivery
- Never require every existing service to migrate before the standard is considered "live." Make the new base images available immediately, require them only for new services going forward, and migrate existing ones opportunistically, attached to work already happening on them (a routine dependency bump, a feature that touches the Dockerfile anyway), rather than as a dedicated, disruptive migration project competing with everything else the team is shipping.
- Pick one or two of the most painful existing services to migrate first, deliberately, as an early, visible example, ideally ones whose current state already causes recognizable pain, so the migration itself demonstrably fixes something people already wanted fixed, not just something that satisfies a new policy.
- Provide a clear, low-friction path for the exception case: a service with a genuine reason it cannot use the standard base image yet should have an explicit, time-bound way to say so, rather than silently ignoring the standard or being blocked outright; a standardization effort that has no accommodation for real exceptions tends to get quietly worked around instead of followed.
Measuring whether it is actually improving reproducibility
- Track a concrete, falling count: the number of services still on a non-standard base image or an unpinned dependency set, visible on a dashboard everyone can see, not just reported occasionally in a meeting.
- Track "works on my machine" incidents specifically (however the team already reports bugs or friction) before and after, since this is the exact symptom the standardization is meant to fix; a real reduction here is a much stronger signal than adoption count alone.
- Track new-hire or new-service onboarding time (how long it takes someone unfamiliar with a given service to get it running locally), since a genuinely standardized set of practices should make this measurably faster and more consistent across services, not just theoretically cleaner.
Worked example
A team of six services has four different Python base images in active use, two of which are no longer receiving security updates upstream, and a recurring pattern of new hires losing time to subtly different local dependency versions. The first move is not a team-wide announcement; it is publishing one pinned, actively maintained internal Python base image and migrating the single service that has caused the two most recent "works on my machine" incidents, as a visible fix to a problem people already recognized. Over the following quarter, three more services adopt the new base image as part of routine work already scheduled on them, and new services are required to start from it from day one. A dashboard tracking "services on the old pattern" drops from five to one over that period, and reported onboarding friction for a new service drops in the team's own retrospective notes, which together are the actual evidence the standardization worked, rather than the initial announcement alone.
Trade-offs & pitfalls
- Mandating an immediate, blanket migration of every existing service tends to either stall (competing with every other priority the team has) or get rushed and done poorly; opportunistic migration attached to work already happening is slower in wall-clock time but far more likely to actually finish.
- Measuring success only by "did we publish the standard" or "how many services adopted it" misses whether it is actually solving the underlying problem; track the specific pain (onboarding friction, "works on my machine" incidents) the standardization was meant to fix, not just adoption as an end in itself.
- No accommodation for genuine exceptions turns a well-intentioned standard into something people quietly route around, which produces worse long-term consistency than a standard with an explicit, visible exception process would have.
Describe how Docker's default bridge network (docker0) works on a developer machine, and how port mapping like -p 8080:80 routes traffic. What's the difference between container-to-container communication on the same network and exposing a service to external clients, and what are three common networking pitfalls developers run into?
Sample Answer
Direct answer
On a developer machine, Docker creates a default bridge network (visible on the host as the docker0 interface) and gives every container on it its own virtual network interface, connected to that bridge, with a private IP address. -p 8080:80 sets up a rule (implemented via the host's packet filtering) that forwards traffic arriving at the host's port 8080 to port 80 inside a specific container. Containers on the same network can already reach each other directly over that bridge; port publishing exists specifically to let something outside the Docker host reach in.
Container-to-container versus external access
Two containers on the same bridge network can talk to each other directly over their private IPs (and, on a user-defined bridge specifically, by container name via Docker's built-in DNS) without any port publishing at all; -p plays no role in that path. Port publishing exists for the opposite direction: letting a client that is not itself a container on that network (a browser on your laptop, a request from another host) reach a service running inside one. Conflating the two is exactly where the pitfalls below come from.
Worked example: the default-bridge naming pitfall, verified directly
$ docker network create appnet
$ docker run -d --name web --network appnet nginx:1.27-alpine
$ docker run -d --name web-default nginx:1.27-alpine # no --network, lands on the default bridge
$ docker run --rm --network appnet alpine:3.20 sh -c "apk add -q curl && curl -s -o /dev/null -w 'status:%{http_code}\n' http://web/"
status:200
$ docker run --rm alpine:3.20 sh -c "apk add -q curl && curl -s -o /dev/null -w 'status:%{http_code}\n' --max-time 2 http://web-default/"
status:000 # name resolution failed
The apk add -q curl has to run first in each throwaway container: plain alpine does not ship curl in its base image, and skipping that step produces a curl: not found error that has nothing to do with networking and would be mistaken for the point this example is making.
On the user-defined network appnet, one container reached another by its container name (http://web/) successfully. A container on the default bridge network could not resolve web-default by name at all: the default bridge has no built-in DNS between containers, only user-defined networks do, so containers there can only reach each other by IP address, and that IP is not guaranteed stable across restarts.
Three common networking pitfalls
- Relying on the default bridge's container-to-container DNS, which does not exist: developers who never explicitly create a network get placed on the default bridge, where inter-container communication only works by IP (not by name), and that IP can change on restart. The fix is simply creating and using a user-defined bridge network (which Compose does automatically for you).
- Confusing
EXPOSEin a Dockerfile with actually publishing a port:EXPOSEis documentation; only-pondocker run(orports:in Compose) creates the actual host-to-container forwarding rule. A container with onlyEXPOSE 80and no-pis completely unreachable from outside the Docker host. - Binding the application inside the container to
127.0.0.1instead of0.0.0.0: if the application only listens on its own loopback interface, no traffic arriving from the container's external interface (which is what-proutes traffic through) ever reaches it, even though the port mapping itself is configured correctly; this looks identical to a broken-pflag from the outside; but the container's own bind address is the actual cause.
In Docker, what's the difference between an image and a container? Walk through what happens from docker build to docker run, and explain how an image lets you avoid 'it works on my machine' problems.
Sample Answer
Direct answer
An image is a read-only, layered template: application code plus everything it needs to run, stored as a stack of filesystem diffs. A container is a running (or stopped) instance of that image, with one thin writable layer added on top for anything the process changes at runtime. docker build produces the image; docker run starts a container from it. Because the image itself never changes, every container started from it begins from the exact same starting point, which is what kills "it works on my machine."
From docker build to docker run
docker build reads a Dockerfile top to bottom. Each instruction that changes the filesystem (RUN, COPY, ADD) produces a new, immutable layer stacked on the previous one; Docker's build engine (BuildKit) content-addresses each layer so identical inputs can be cached and reused. The final result is an image: an ordered list of layers plus metadata (the default command, exposed ports, environment variables).
docker run takes that image and does three things: it creates a new writable layer on top of the image's read-only layers (this is the container's own filesystem), it sets up isolated namespaces (its own process tree, network stack, mounts) and cgroup limits for the new process, and then it executes the image's configured entrypoint/command as the container's first process. Nothing you do inside that running container (write a temp file, install a package on the fly) touches the underlying image; it only touches that container's writable layer.
Worked example
$ docker run -d --name a alpine:3.20 sleep 3600
$ docker run -d --name b alpine:3.20 sleep 3600
$ docker exec a sh -c "echo hi > /tmp/only-in-a.txt"
$ docker diff a
A /tmp/only-in-a.txt
$ docker diff b
(no output)
$ docker inspect --format='{{.Image}}' a
sha256:d243c3e9...
$ docker inspect --format='{{.Image}}' b
sha256:d243c3e9...
Both containers were started from the same image (their .Image field is identical), but writing a file in container a shows up only in a's docker diff, not b's. That is the image/container split made concrete: one shared, immutable image; two independent writable layers.
How this avoids "it works on my machine"
Because the image is the literal artifact that gets deployed (not a description of how to reproduce a machine, like a setup guide or a requirements.txt), a developer's laptop, a CI runner, and a production server are all running byte-identical layers. There is no "well, it's installed slightly differently on the server" gap, because nothing is reinstalled per environment; the same image is just started as a new container in each place.
Trade-offs and pitfalls
The most common mistake is treating the container's writable layer as durable storage: since it is discarded when the container is removed, anything that needs to survive a restart (databases, uploaded files, logs you care about) belongs in a named volume or bind mount, not in the container's own filesystem.
Given this Dockerfile, the build cache keeps invalidating on almost every CI run even though only application code changes:
FROM python:3.9-slim
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
COPY model /app/model
CMD ["python", "server.py"]
Explain exactly which Docker rules decide whether a RUN, COPY, or ADD layer is reused (including what changing an ARG or ENV does), then reorder or rewrite the Dockerfile so dependency installation is cached across builds.
Sample Answer
Direct answer
Docker builds a Dockerfile layer by layer and reuses a cached layer only while every earlier layer's cache key is unchanged and the current instruction's own inputs are unchanged. Because COPY . /app copies the entire build context, including the application source, before RUN pip install -r requirements.txt runs, any source-code edit changes that COPY layer's content hash, which invalidates every layer after it, including the pip install, even though requirements.txt itself never changed. The fix is pure reordering: copy only the dependency manifest first, install dependencies, then copy the rest of the source.
The exact cache rules
- COPY and ADD: the cache key is a checksum of every file matched by the source pattern, not a name or modification-time check, plus the destination path and file metadata (owner, permissions).
ADDfollows the same layer-caching rule asCOPYfor this purpose; its extra behavior (fetching a remote URL, auto-extracting an archive) does not change how its cache key is computed. Any byte difference in any matched file busts that layer's cache, and every layer positioned after it in the Dockerfile. - RUN: the cache key is the exact command text, compared literally, plus the filesystem state of the layer it runs on top of. Docker does not know what the command actually does internally, for example that pip only cares about the contents of
requirements.txt; it only reuses a layer if the command string and everything above it are byte-identical to a previous build. - ARG: changing a build ARG's value invalidates every instruction from the point that ARG is declared onward, but only if a later instruction actually references it. An ARG that no instruction uses has no effect on caching.
- ENV: setting or changing an ENV value invalidates the layer that sets it and everything after it, for the same reason as a RUN: it changes the image's declared state.
- General rule: the cache is checked top to bottom and stops at the first miss. Nothing below a miss can be a hit, even if that later instruction's own inputs never changed.
Why the given Dockerfile fails
COPY . /app sits before RUN pip install -r requirements.txt, so it is upstream of the install in the cache chain. Touching server.py, or any file in the build context not excluded by a .dockerignore, reruns that COPY's checksum, which reruns pip install on every single build regardless of whether requirements.txt changed at all.
The fix
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "server.py"]
This was built and verified directly: two Dockerfiles, one matching the original ordering and one matching this fix, were each built twice with only server.py changed in between the two builds. On the original ordering, RUN pip install -r requirements.txt re-executed on the second build with no CACHED marker at all. On the reordered version, the identical RUN pip install -r requirements.txt layer showed CACHED on the second build, because COPY requirements.txt . never changed. The COPY model /app/model line is fine left after the install either way, since large, rarely-changing binary assets do not need to sit ahead of the dependency install; what matters is that anything expected to change on nearly every commit (the application source) comes last.
Trade-offs and pitfalls
.dockerignore still matters even after this fix: if .git or a large node_modules directory sits in the build context unignored, COPY . . still has to checksum all of it on every build, which is slow even when it does not bust a downstream layer that was already cached. A BuildKit (Docker's modern build engine) cache mount, RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt, goes one step further by persisting the pip download cache itself, so even a genuine requirements.txt change only re-downloads and re-resolves the difference instead of every package from zero. Worth knowing for larger, multi-stage Dockerfiles: an ARG used only inside a stage that a later stage never references does not invalidate that later stage at all.
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.