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.
How does a container differ from a virtual machine? Cover kernel sharing versus a hypervisor and guest OS, how namespaces and cgroups provide isolation, startup time, density, and image versus VM-image size. Give two example workloads where you'd prefer VMs and two where you'd prefer containers, and name one operational pitfall teams hit when they migrate a workload from VMs to containers.
Sample Answer
Direct answer
A container shares the host machine's kernel and uses two kernel features, namespaces and control groups (cgroups), to make one process tree look like it has its own machine. A virtual machine (VM) runs a full guest operating system, including its own kernel, on top of a hypervisor that emulates hardware. That single difference (shared kernel versus emulated hardware plus a second kernel) is what drives every other difference: startup time, density, and image size.
What namespaces and cgroups actually isolate
Namespaces control what a process can see. Each namespace type hides a different resource: the PID namespace gives a container its own process-ID tree (so the first process inside is PID 1, even though it is really PID 4831 on the host), the network namespace gives it its own network interfaces and routing table, the mount namespace gives it its own filesystem view, and the UTS namespace gives it its own hostname. Cgroups control what a process can use: CPU shares, memory ceilings, and I/O bandwidth, enforced by the kernel scheduler rather than by a second layer of virtual hardware.
A VM instead runs a hypervisor (Type 1, like a bare-metal Xen or KVM host, or Type 2, like a hypervisor running as a process on a host OS) that presents virtual CPUs, virtual disks, and a virtual NIC to a completely separate guest kernel. The guest has no visibility into the host's process table at all; it boots its own.
Startup time, density, and image size, and why
Starting a container is close to forking and exec-ing a process on an already-running kernel: the kernel is already up, so "starting" mostly means setting up namespaces and a writable layer, which is why containers typically come up in well under a second. Booting a VM means running through a virtual BIOS/UEFI, a bootloader, and a full kernel and init sequence for the guest OS, which is why VMs typically take tens of seconds. That same gap shows up in density: because containers do not carry a second kernel, a host can usually run many more containers than VMs in the same memory footprint, since VMs each reserve memory for their own kernel and OS services. Image size follows the same logic: a container image is just an application plus its user-space dependencies (often tens to a few hundred megabytes), while a VM image bundles an entire OS, kernel, drivers, and init system (commonly several gigabytes).
Where the boundary gets fuzzy: sandboxed runtimes
Plain containers (runc) trust the shared kernel completely: a kernel exploit inside one container can, in principle, affect the host and every other container. When you need container-like density and speed but do not trust the workload's code as much as your own, there is a middle ground: Kata Containers runs each "container" inside its own lightweight, purpose-built micro-VM (so it gets a real hypervisor boundary with near-container startup times), and gVisor takes a different approach, intercepting a workload's syscalls in a user-space kernel so the host kernel never sees them directly. Both add overhead versus runc, but far less than a full traditional VM, and both are the standard answer to "how do you run less-trusted container workloads more safely."
Two workloads you would keep on VMs
- A multi-tenant platform that runs arbitrary, untrusted customer-supplied code (for example, a build-and-test SaaS that executes strangers' scripts): a VM's hypervisor boundary is a much harder wall for a kernel-level exploit to cross than a shared-kernel namespace boundary, so it is worth paying the extra boot time and memory for that isolation, absent adopting a sandboxed runtime like the ones above.
- A legacy application that is tightly coupled to a specific kernel version, kernel module, or even a different OS family than the host (for example, a Windows-only service running alongside a Linux fleet): it needs its own kernel, full stop, so a VM is the only option.
Two workloads you would put in containers
- A stateless HTTP microservice that needs to autoscale quickly and pack densely onto nodes: fast startup and small images mean you can add replicas in seconds and fit far more of them per host.
- Short-lived CI/CD (continuous integration and continuous delivery) build or test jobs: each pipeline run spins up a fresh, disposable environment, and the sub-second startup means you are not paying VM boot time on every single job.
The migration pitfall
The most common operational pitfall when a team moves a workload from VMs to containers is treating the container like a small VM: writing state to the container's local filesystem and assuming it survives restarts, redeploys, or rescheduling onto a different node the way it would on a long-lived VM. A container's writable layer is meant to be disposable; when the orchestrator replaces the container (a new deploy, a node drain, a crash restart), anything not persisted to a volume or external store is silently gone. Teams that "lift and shift" a VM-era app without externalizing its state discover this the first time a routine redeploy quietly erases data or resets configuration they thought was durable.
You need to integrate automated image vulnerability scanning (a tool like Trivy or Clair) into the CI pipeline. Where in the build/test/publish stages would you run it, what severity threshold would block a release, and how would you handle false positives without training the team to ignore every alert? In a regulated environment, what would you specifically look for in a scan result, and how would you prioritize findings?
Sample Answer
Direct answer
Run image vulnerability scanning right after the image is built but before it is pushed anywhere a deploy step can reach it, so a failing scan blocks the artifact from ever becoming pullable, not just from ever reaching production. Gate on severity combined with whether a fix actually exists, rather than a blanket "zero vulnerabilities," and treat false positives as a reviewed, time-boxed exception, not a blanket ignore list, so the team is never trained to click past every alert.
Where in the pipeline
Build stage produces the image, the scan stage runs immediately against that exact image by digest, not a floating tag, because a digest names exactly one immutable image while a tag can be repointed after the scan, and only a passing scan proceeds to push or publish. Scanning after the build but before the push, rather than after the push, means a failed scan never creates a pullable artifact at all, a stronger guarantee than scanning post-deploy and opening a ticket afterward.
Severity threshold
A defensible baseline: block on CRITICAL and HIGH severity findings that have a known available fix. Allow, with a tracked, time-boxed exception, HIGH or CRITICAL findings with no fix available yet, since blocking on those permanently reds the pipeline for something the team cannot act on. MEDIUM and LOW findings typically report to a dashboard rather than gate the release.
Handling false positives without training the team to ignore alerts
Use an explicit, reviewed suppression naming the specific vulnerability identifier and the specific package, with a required justification and an expiry date, never a permanent blanket ignore. A suppression with no expiry quietly becomes technical debt nobody revisits when the "false positive" later becomes exploitable, for example after a refactor makes a previously unreachable code path reachable. Track the suppression count as its own metric: a gate that always passes because everything got suppressed has stopped doing its job.
In a regulated environment
What changes is less the scanning mechanism and more what has to be provable afterward. Retain scan results per released artifact, tied to its digest, for the required audit retention period. Be able to show who approved any exception and when. Be able to answer quickly whether a specific vulnerable component is running anywhere in production right now, which is exactly why linking a scan result to the exact deployed digest matters here specifically: an auditor's question is almost always what is running right now, not what was scanned once. Prioritize findings by a combination of severity, whether the vulnerable component is actually reachable or exercised by the running application, not every vulnerability in an unused code path is equally urgent, and whether the component sits in a path that touches regulated data, for example anything handling payment data under a standard like PCI-DSS (the Payment Card Industry Data Security Standard) or health data under HIPAA (the Health Insurance Portability and Accountability Act), over one buried in a build-only tool that never ships in the runtime image.
Worked example
A CI stage added right after the build:
docker build -t registry.example.com/app:${SHA} .
trivy image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed registry.example.com/app:${SHA}
# only on success, push by tag and capture the digest the registry actually assigned:
docker push registry.example.com/app:${SHA} | tee /tmp/push.log
DIGEST=$(grep -oE 'sha256:[0-9a-f]{64}' /tmp/push.log | tail -1)
echo "published as registry.example.com/app@${DIGEST}"
--exit-code 1 fails the CI step, and therefore blocks the push, on any matching finding. --ignore-unfixed implements the "do not block on findings with no available fix" policy directly at the tool level, while those findings still get reported elsewhere for tracking. Note the push step: docker inspect --format='{{index .RepoDigests 0}}' already returns a full repo@sha256:... reference, not a bare digest, so wrapping it inside another registry.example.com/app@$(...) template produces a garbled, invalid reference (confirmed directly: Docker rejects that construction with "invalid reference format"). Capturing the digest from the docker push output itself, which always prints its own digest: sha256:... line, avoids that entirely and is the reliable way to record exactly what was published.
Trade-offs and pitfalls
Scanning too early, source-only or dependency-manifest-only, misses vulnerabilities introduced by the base image itself, so image-level scanning after the build is necessary even when a separate dependency scan also runs earlier in the pipeline. Over-blocking on every MEDIUM finding is the single most common way teams end up routing around the gate entirely, a merge-and-suppress culture that defeats the point far more thoroughly than a slightly looser threshold would.
Write an efficient multi-stage Dockerfile for a Node.js application with a package.json build script: use npm ci for reproducible installs, build static assets in one stage, and produce a minimal runtime image in the final stage. Order instructions so dependency installation is cached across builds when only source code changes, and set a sensible HEALTHCHECK and non-root user.
Sample Answer
Direct answer
An efficient multi-stage Node.js Dockerfile separates a build stage (install all dependencies with npm ci, build static assets) from a minimal runtime stage (install only production dependencies, copy in the built output), ordered so dependency installation is cached across builds when only source changes. Below is a Dockerfile that was built, run, and specifically re-built after a source-only change to confirm the caching claim, not just written to look right.
The Dockerfile
# Stage 1: install deps and build static assets
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: minimal runtime image
FROM node:20-slim AS runtime
RUN groupadd --system app && useradd --system --gid app app
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY server.js ./
RUN chown -R app:app /app
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:3000/', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"
CMD ["node", "server.js"]
npm ci (not npm install) is used in both stages because it installs exactly what package-lock.json specifies and fails if the lockfile and package.json disagree, which is what makes the install reproducible across machines and CI runs. Both stages copy package.json/package-lock.json and run their own npm ci before copying the rest of the source, for the same cache-ordering reason as any other language: the dependency list changes far less often than the source. The runtime stage also creates a dedicated app system user and group and switches to it with USER app before the process starts, so the running container is non-root, and it declares a HEALTHCHECK that opens a real connection to the service rather than only checking the process is alive.
Verified build, cache behavior, and run
$ docker build -t node-app .
$ docker run -d --name napp -p 13000:3000 node-app
$ docker exec napp whoami # confirms the container is running as the non-root app user, not root
app
$ curl http://127.0.0.1:13000/
<h1>built asset</h1>
$ docker images node-app --format '{{.Size}}'
310MB
$ docker inspect --format='{{.State.Health.Status}}' napp
healthy
Then, changing only server.js (not package.json or package-lock.json) and rebuilding:
=> CACHED [build 4/6] RUN npm ci
=> CACHED [runtime 5/8] RUN npm ci --omit=dev
=> [runtime 7/8] COPY server.js ./
Both npm ci steps, in both stages, stayed CACHED; only the layer copying the changed server.js file re-ran. That confirms the ordering requirement is actually met, not just plausible-looking in the Dockerfile.
Edge cases and trade-offs
Running npm ci twice, once per stage, means the runtime stage re-downloads production dependencies rather than reusing the build stage's node_modules directly; this is deliberate (it guarantees the runtime stage never accidentally ships a dev-only dependency that leaked into the build stage's tree) but it does mean two separate installs to keep in sync, and both must read the same lockfile or they can drift. If npm run build needs devDependencies to run (bundlers, TypeScript) but the build fails silently because a dependency was pruned too early, that is a sign the --omit=dev install happened in the wrong stage; it must stay in the runtime stage only. Finally, a package-lock.json that is missing or out of sync with package.json makes npm ci fail outright (by design), which is the correct, loud failure mode compared to npm install's tendency to silently resolve a different dependency tree than what was tested.
Design a safe image-promotion pattern so the same artifact moves from development to staging to production with minimal drift: how tags, digests, and registry permissions ensure the exact image that was tested is the one that gets deployed, and how you'd prevent accidental rebuilds or tag-drift surprises along the way.
Sample Answer
Direct answer
The core principle is build once, promote everywhere: an artifact is built and given an immutable identity exactly once, and every later environment, staging and then production, references that same identity rather than triggering a second build from source. Drift, and the class of bug where something works in staging but not in production, overwhelmingly comes from an environment quietly building its own slightly different copy of "the same" release instead of promoting the one that was already tested.
The design
Build once. CI builds the image a single time per release candidate, tags it immutably and uniquely (a commit hash or a semantic release version), and records its digest (a digest is a cryptographic hash of the image's exact bytes; two images with the same digest are guaranteed to be byte-for-byte identical, unlike a tag, which can later be moved to point at different bytes) at that moment. No later pipeline stage is allowed to rebuild this artifact; staging and production only ever pull or reference it.
Promotion is a metadata operation, not a build. Promoting an artifact from staging to production re-tags the same digest for the next environment, for example adding app:v1.4.2-prod-approved alongside the existing app:v1.4.2, or a promotion tool simply records that this digest is now the production candidate. In a digest-pinned deploy configuration, promotion can be as simple as updating production's deploy manifest to reference the already-tested digest directly.
Registry permissions enforce the guardrail. CI's build identity gets write access to push new images and tags. Production's deploy identity gets read-only access, able to pull, never to push. If the registry supports tag immutability or protected tags, lock release tags so nothing, accidental or malicious, can silently repoint a tag after staging has already tested it. A human or an approval step gets permission to move a "promoted" pointer or write a deployment record, never to trigger a rebuild.
Preventing accidental rebuilds. The biggest practical source of drift is a Dockerfile or CI configuration that is not fully deterministic: a base image referenced by a floating tag like python:3.12-slim instead of a pinned digest, or an install step with no version pins, meaning two builds from "the same" source a day apart can legitimately produce different bytes. Pin base images by digest, pin dependency versions, and treat anything environment-specific, a database URL, a feature flag, as runtime configuration injected into the same image, never as a build-time difference that forces separate, environment-specific images.
flowchart LR
Commit["source commit"] --> Build["CI build (once)"]
Build --> Registry["registry: immutable digest"]
Registry --> Staging["staging deploy (pull-only)"]
Staging -->|"tests pass, promote"| Prod["production deploy (pull-only, same digest)"]
Worked example
A release pipeline for a checkout service: CI builds on merge to the main branch, tags checkout-service:2026.09.23-abc1234, and records the digest in a release manifest. Staging's deploy configuration is templated to always deploy checkout-service@<digest from the latest release manifest>. After staging's smoke tests pass, a promotion job, not a rebuild, copies that exact digest reference into the production deploy configuration and opens the standard change-approval process. Production's deploy identity has pull-only registry permissions, so even a compromised production deploy credential cannot push a different image under that same tag.
Trade-offs and pitfalls
Locking down write access this tightly adds real friction for a legitimate emergency change: a hotfix now has to go through the same build-once pipeline rather than a quick local build pushed straight to production, a deliberate trade of speed for guaranteed provenance. "Build once" is easy to violate by accident the moment any Dockerfile step reaches for something mutable, an unpinned base image tag, an unpinned package install, or a dependency resolved with no lockfile, so this whole design only holds if the Dockerfile itself is actually reproducible; otherwise the promotion discipline is being built on top of a build that was never deterministic to begin with.
Why is tagging an image latest risky in production, and how would you eliminate tag drift in a pipeline where developers currently deploy app:latest? Describe how you'd publish, promote, and reference images so that, months later, an audit can reconstruct exactly what was running in production at a given time.
Sample Answer
Direct answer
latest is not "the newest version"; it is simply the tag Docker applies by default when none is specified, and many teams separately choose to also push it as a rolling "current" alias by convention, not by anything Docker enforces. The real risk is that a tag, including latest, is a mutable pointer: deploying app:latest on two different days can pull two genuinely different images, and nothing about the tag itself records which one actually ran. That gap is exactly what an audit hits months later. The fix is to make every deploy reference an image by its immutable content digest, and to record that reference durably outside the registry, rather than trusting that latest happened to point at the right thing at deploy time.
Why tags drift
A tag is just a mutable pointer in the registry to a manifest digest. Re-pushing to the same tag repoints it, with no built-in history of what it used to point to, and no guarantee that two docker pull app:latest calls a minute apart even resolve to the same bytes if a push landed in between.
The fix, layered
- Immutable, unique tags at build time. Every CI build tags the image uniquely, most commonly by version control commit hash (
app:sha-abc1234) or a monotonically increasing build number, and never re-pushes over an existing unique tag. - Deploys reference the digest, not the tag. Wherever deploy tooling supports it, reference
app@sha256:...directly. A digest is cryptographically bound to the exact bytes of that manifest; it cannot silently repoint the way a tag can. - Promotion re-tags the same digest; it never rebuilds. Moving an artifact from development to staging to production repoints or re-references the same digest, guaranteeing that the artifact tested in staging is bit-for-bit the one that reaches production.
- The deployment record is durable and independent of the registry. The pipeline's own deployment log stores which digest went to which environment at which time. If the registry later garbage-collects an old tag, or someone force-pushes over one, the audit trail still exists elsewhere.
Worked example
A service currently deployed as app:latest migrates as follows: CI starts also pushing app:sha-<commit> alongside latest (keeping latest purely as a convenience for local development, never referenced by any deploy pipeline). The deploy pipeline changes to resolve the digest for the just-built SHA tag, for example via docker inspect --format='{{index .RepoDigests 0}}' right after the push, and passes that digest to the deploy step. The deployment system logs a record such as {service: app, digest: sha256:..., deployed_at: ..., deployed_by: pipeline_run_id} to a durable store, even a simple append-only table is enough. Months later, an audit queries that log for the timestamp in question, gets the digest, and pulls the exact same image bytes that ran that day, regardless of whatever app:latest currently points to.
Trade-offs and pitfalls
A unique tag per build is an improvement, but not a complete fix on its own, unless the registry also enforces tag immutability, since a careless or compromised process could still repoint even a "unique" tag if nothing prevents it. Pure digest references are correct but unreadable to a human scanning a deploy manifest, so most real setups keep both: a readable unique tag for the log line, resolved to its digest for the actual deploy reference and audit record. Retention matters too: if the registry garbage-collects layers behind a tag that later gets deleted, an audit six months out needs both the digest and a registry retention policy long enough to still have it, or the audit trail points at bytes that no longer exist anywhere.
Unlock Full Question Bank
Get access to all 17 Containerization and Docker Fundamentals interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.