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.
Why should a container process run as non-root in production, especially one that processes sensitive data or is exposed to untrusted input? Show the Dockerfile techniques to create a non-root user and switch to it, and explain how to still allow something like binding to a privileged port below 1024 without running as root.
Sample Answer
Direct answer
A container process should run as non-root because the container's process namespace boundary is not a security boundary by itself: if an attacker escapes the container (through a kernel vulnerability, a misconfigured mount, or a vulnerable dependency processing untrusted input), running as root inside the container often means they land as root on the host's view of that process too, and root inside a container frequently has more host-visible capability than people expect. For a service that parses untrusted input or handles sensitive data, that escape path is exactly the one you are trying to make as narrow as possible.
Dockerfile technique
Create a dedicated, low-privilege system user and group, then switch to it before the final CMD/ENTRYPOINT:
FROM python:3.12-slim
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["python", "app.py"]
Everything before USER app (installing packages, writing files as part of the build) still runs as root, which is fine since that happens at build time, not against untrusted input; USER app switches the actual runtime process. --chown=app:app on the COPY avoids a separate chown layer and ensures the application files are actually readable by the user that will run them.
Binding a privileged port without running as root
Ports below 1024 are privileged on Linux by default, and a non-root process cannot bind one directly. The clean fix that does not require root at all is a Linux capability: grant the specific CAP_NET_BIND_SERVICE capability to the binary itself with setcap, rather than granting the whole process root:
RUN setcap 'cap_net_bind_service=+ep' /app
USER app
Verified directly: a small Go HTTP server built with this exact technique, running as the non-root app user, still bound to port 80 successfully and served a request:
$ docker exec nonroot-demo whoami
app
$ curl http://127.0.0.1:18080/
ok
(port 18080 on the host was mapped to port 80 in the container). The far more common real-world alternative, when you do not strictly need the container's internal process to bind a low port, is simply to have the application listen on an unprivileged port (like 8080) and let Docker's own port publishing do the remapping (-p 80:8080), since the privileged-port restriction only applies inside the container's own network namespace, not to the host-side port a container is published on.
Trade-offs and gotchas
setcap requires the capability tool (libcap2-bin on Debian-based images) to be present at build time, and the capability has to be reapplied if the binary is rebuilt or replaced later in the Dockerfile, since it is a filesystem attribute on that specific binary, not a property of the user. If you only need the container to appear on port 80 externally and are not otherwise constrained (no host networking, no orchestrator requirement to bind low ports internally), prefer the simpler port-remapping approach; reach for setcap when the application specifically must bind a privileged port inside its own namespace.
A container orchestrator (the system that decides where and how containers run in production, such as Kubernetes) usually layers its own version of this same control on top of the Dockerfile: a Kubernetes pod's securityContext.runAsNonRoot: true tells the orchestrator to refuse to start the container at all if the image's own configuration would otherwise run it as root, enforcing the requirement from outside the image in addition to, not instead of, the USER instruction inside it.
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.
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.
Design a Docker Compose setup for a small local development platform: an app service, a background worker, an API, a database for metadata, and an object-storage emulator for artifacts. What services, networks, volumes, environment variables, and startup dependencies (depends_on, health-gated startup) would you define so the stack is reliable and easy for a new developer to bring up?
Sample Answer
Direct answer
Define one Compose service per moving part (app, worker, api, a database, an object-storage emulator), put them on purpose-scoped user-defined networks rather than one flat network, give the database and object-storage services their own named volumes and HEALTHCHECKs, and make everything that depends on them wait with depends_on: condition: service_healthy rather than a fixed sleep. That combination is what makes docker compose up from a clean checkout reliably reach a working state on the first try, instead of a new developer hitting a race where the api container starts before the database is actually ready to accept connections.
Structured elaboration
Services
db: the metadata store (for examplepostgres:16-alpine), with aHEALTHCHECKusingpg_isready, the client tool the postgres image ships that checks whether the server is accepting connections.objectstore: an S3-compatible emulator (for example MinIO) for artifact storage, with its own health check hitting its readiness command.api: the service developers actually call; it should not start accepting its own traffic until bothdbandobjectstorereport healthy.worker: a background job consumer; depends ondbthe same wayapidoes, but does not needobjectstoreunless it also touches artifacts.app: the front end or orchestrating service; depends onapibeing up, not on the data layer directly, since it should only ever talk toapi.
Networks
Two user-defined bridge networks, not the single default network Compose would otherwise create: a backend network carrying db, objectstore, worker, and api, and a frontend network carrying api and app. api sits on both. This means worker and the data stores are not reachable at all from app's network, which mirrors a real production boundary (only api talks to the data layer) even in a local development stack, catching accidental cross-layer calls early instead of only in a later, stricter environment.
Volumes
Named volumes for db and objectstore (db-data, objectstore-data), not bind mounts, so a developer's docker compose down (without -v) preserves data across restarts, while docker compose down -v gives a clean-slate reset on demand. Named volumes also avoid host-specific path and permission issues that a bind mount can introduce across different developers' machines and operating systems.
Environment variables
Credentials and connection settings (POSTGRES_PASSWORD, POSTGRES_DB, MINIO_ROOT_USER, MINIO_ROOT_PASSWORD) belong in a .env file that is committed with clearly fake local-only defaults (never real secrets) and referenced by Compose's variable substitution, so a new developer can bring the stack up with zero manual configuration, while production credentials are supplied a completely different way (a secrets manager, not this file).
Startup dependencies
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 2s
retries: 10
objectstore:
image: quay.io/minio/minio:latest
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 2s
retries: 10
api:
build: ./api
depends_on:
db:
condition: service_healthy
objectstore:
condition: service_healthy
worker:
build: ./worker
depends_on:
db:
condition: service_healthy
app:
build: ./app
depends_on:
- api
condition: service_healthy (not the bare, older form of depends_on, which only waits for the dependency's container to start, not for it to actually be ready) is what turns "start these in roughly the right order" into "do not start this until the thing it needs can actually answer requests."
graph LR
DB[(db: Postgres)] -->|service_healthy| API[api]
OBJ[(objectstore: MinIO)] -->|service_healthy| API
DB -->|service_healthy| WRK[worker]
API -->|starts after| APP[app]
subgraph backend network
DB
OBJ
WRK
API
end
subgraph frontend network
API
APP
end
Worked example
Bringing this stack up from a clean checkout, db and objectstore begin starting immediately (they have no depends_on of their own) and both reach healthy a few health-check cycles later; api and worker then start, roughly seven seconds after db and objectstore began, exactly the time it took both to pass their first successful probe; app starts immediately after api, since its own dependency is only "started," not "healthy" (an app service is typically a thin client with its own runtime readiness handling, so requiring the full data layer to be healthy before it even starts would be unnecessarily strict). A developer running docker compose up for the first time never sees a connection-refused error from api trying to reach db too early, because Compose itself withheld starting api until db's health check had already passed.
Trade-offs & pitfalls
condition: service_healthyrequires every dependency to actually define ahealthcheck; a service with no health check and onlycondition: service_startedgives you ordering but not readiness, which reintroduces the exact race this design exists to prevent.- Splitting networks (
backendandfrontend) adds a small amount of Compose file complexity for a benefit that is easy to skip in a "just get it running" local setup; the payoff is that it catches an app-to-database shortcut during local development instead of only in a later, stricter network policy. - Named volumes make
docker compose down -va genuinely destructive command (it deletes the database's actual data); this needs to be common knowledge on the team, since it looks identical todocker compose downat a glance and is a realistic way to lose a developer's local test data by accident.
You inspect a running production container and notice ps aux inside it shows a growing number of <defunct> zombie processes that never clear, even though the main application appears to be working fine. Explain what PID 1 is responsible for inside a container's PID namespace that it wouldn't have to worry about as an ordinary process on a normal host, why an application binary running directly as PID 1 often fails at that responsibility, and what concrete change you'd make to the container's entrypoint to fix it. What would you gain and lose if you instead solved this by running a full init system inside the container, or by having the container share the host's PID namespace?
Sample Answer
Direct answer
PID 1, the process the kernel assigns process ID 1 to, either at boot on a normal host, or as the first process started inside a container's own PID namespace (an isolated view of process IDs separate from the host's), has two duties an ordinary process never has to think about: it inherits any process whose original parent has already died (an orphan) and must call wait()/waitpid() on it to collect its exit status, and the kernel gives it special signal-handling behavior, a signal being the kernel's way of asynchronously telling a process about an event such as a shutdown request, where most signals simply aren't delivered to PID 1 unless it explicitly installs a handler for them (only SIGKILL and SIGSTOP, which force-terminate or pause a process, always work regardless). An ordinary application binary run directly as PID 1 usually does neither: it never calls wait() on children it didn't fork itself, so those orphans exit but are never reaped and pile up as <defunct> zombies, and it never registered a SIGTERM handler, so it may not even shut down cleanly on docker stop. The concrete fix is to put a tiny, purpose-built init process in front of the application: run docker run --init (which uses tini, bundled into the Docker Engine since version 1.13) or add tini/dumb-init as the image's ENTRYPOINT, so the real application runs as PID 2 and the init process handles reaping and signal forwarding on its behalf.
Structured elaboration
Why zombies accumulate specifically because of PID 1, not just "processes exiting"
A zombie is a process that has already exited but whose exit status hasn't been collected yet; the kernel keeps a small process-table entry for it until something calls wait(). On a normal host, the system init, the first process the kernel starts at boot and the one responsible for starting and supervising every other service on the machine (systemd is the most common example on modern Linux distributions), reaps any orphan automatically, all the time, as a routine part of its job. Inside a container, whatever process the container engine starts is that namespace's PID 1, whether or not it was written with that responsibility in mind. Most application binaries (a Python script, a Node server, a compiled Go binary) were never written to reap other processes; they assume some init layer is already doing that for them, true on a host, false inside a bare container. The practical trigger is usually an application that shells out to short-lived helper processes, each one that exits without being reaped adds one more zombie, and because a single zombie barely consumes resources, this can run for a long time before it becomes visible as process-table pressure or confusing monitoring output.
Comparing the three fixes
| Approach | What it does | Gains | Costs |
|---|---|---|---|
Lightweight init (tini, dumb-init, or docker run --init) | A few-kilobyte process becomes PID 1, forwards signals to the real app and reaps everything else | Minimal image and runtime overhead, predictable signal forwarding, a drop-in ENTRYPOINT change, preserves the single-main-process-per-container model orchestrators expect | Doesn't manage multiple services, no dependency ordering, no logging or cgroup management, it does exactly one job |
Full init system (systemd) inside the container | The container runs a complete OS-style init, with service units, its own logging, and cgroup management | Genuine multi-service management inside one container, familiar operational model if the team already runs systemd everywhere | Needs privileged mode or specific extra mounts to manage cgroups, a much larger image and attack surface, and it breaks the single-process-per-container assumption most orchestrators, health checks, and log collectors are built around |
Share the host's PID namespace (docker run --pid=host) | The container's processes live in the host's real PID namespace, so orphans reparent to the host's real PID 1 and get reaped there | No init process needed inside the container at all | The container can see, and in some configurations signal, every process on the host, a serious isolation break that's unacceptable on any multi-tenant or security-sensitive host, and it doesn't fit typical orchestrator scheduling models either |
sequenceDiagram
participant App as PID 1 process
participant Child as Worker process
App->>Child: fork
Child-->>Child: exits
Child->>App: SIGCHLD delivered
alt PID 1 calls waitpid
App->>Child: reap exit status
else PID 1 never calls waitpid
Child->>Child: stays a zombie, shown as defunct
end
The right default for almost every case is the lightweight init: a one-line ENTRYPOINT or flag change that doesn't meaningfully alter the container's resource footprint, and it fixes both halves of the problem, reaping and signal forwarding, without touching the container's isolation model. systemd-in-a-container earns its cost only when deliberately running something that behaves like a small VM with several cooperating services, a rare, specific need, not a general containerization pattern. Sharing the host PID namespace is a debugging tool, letting you inspect host processes from inside a container, not a production fix, because the isolation it gives up is exactly the isolation a container exists to provide.
Worked example
An engineer notices ps aux inside a long-running container shows dozens of <defunct> entries, and the container's memory climbs slowly over days despite the application's own metrics looking flat. The application is a Python service that shells out to a short-lived helper process per request, and one of those helper processes occasionally forks a further child that gets orphaned before it exits, a grandchild the application's own subprocess-handling code never sees and so never reaps. The Dockerfile's ENTRYPOINT currently runs the Python app directly as PID 1: ENTRYPOINT ["python", "app.py"]. Adding docker run --init (or, equivalently, ENTRYPOINT ["tini", "--", "python", "app.py"]) puts tini at PID 1; the Python app becomes PID 2, and any orphaned grandchild now reparents to tini, which reaps it immediately. No application code changes, no measurable image size increase, and docker stop now delivers SIGTERM to tini, which forwards it to the app, instead of the kernel silently declining to deliver it to an application that never asked to handle it.
Trade-offs & pitfalls
- A common wrong turn is treating this as "the app should just add a
SIGTERMhandler and move on," which fixes shutdown but not reaping; an app can handle its own termination signal correctly and still accumulate zombies from subprocesses it spawns and neverwait()s on. docker run --initonly takes effect atdocker runtime; if the container is launched by an orchestrator that constructs its own run configuration, the equivalent has to be configured there, or baked into the image'sENTRYPOINTinstead, which works regardless of how the container is launched and is usually the more portable choice.--pid=hostlooks like it "just works" in a quick test because reaping does start happening; that's exactly the trap, the fix appears to work while quietly removing process isolation, which won't surface as a problem until it's exploited or audited.- Running
systemdin a container without the specific cgroup and mount configuration it expects tends to fail in ways that look unrelated to init at all (units failing to start, logging silently going nowhere), which sends people debugging the wrong layer if they don't already knowsystemdneeds that container-specific setup.
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.