🐳 Section 14 · Question #3

What is Dockerfile

A Dockerfile is a declarative, plain-text configuration file containing an ordered sequence of instructions used by the Docker engine / BuildKit to assemble an immutable contain...

🟢 Junior Level

30-Second Interview Answer

A Dockerfile is a declarative, plain-text configuration file containing an ordered sequence of instructions used by the Docker engine / BuildKit to assemble an immutable container image.

Each instruction in a Dockerfile represents an explicit build step:

  1. Selecting the base operating system and runtime (FROM eclipse-temurin:21-jre-jammy).
  2. Setting an isolated working directory (WORKDIR /app).
  3. Copying compiled artifacts or application assets into the image (COPY target/app.jar app.jar).
  4. Declaring network listening ports (EXPOSE 8080).
  5. Specifying the default process execution command in Exec Form (ENTRYPOINT ["java", "-jar", "app.jar"]).

Real-World Analogy

A Dockerfile is an industrial baking recipe:

  • FROM: Choosing the pre-made base dough (e.g., standard Italian pizza crust).
  • COPY: Adding your specific toppings and sauce (your compiled code and configs).
  • RUN: Baking in the oven at 450°C (compilation, package installation).
  • ENTRYPOINT: The instructions given to the waiter on how to slice and serve the dish to the customer.

Just as a master recipe produces hundreds of identical pizzas regardless of which kitchen cooks them, a Dockerfile guarantees that the resulting container image runs identically across a developer laptop, a CI/CD runner, and a multi-node Kubernetes cluster.


Basic Production Example: Java 21 / Spring Boot 3

# 1. Base official JRE 21 runtime on Ubuntu Jammy (glibc compatible)
FROM eclipse-temurin:21-jre-jammy

# 2. Establish isolated working directory
WORKDIR /app

# 3. Create an unprivileged system user for container security
RUN addgroup --system appgroup && adduser --system appuser --ingroup appgroup

# 4. Copy pre-compiled JAR with correct ownership
COPY --chown=appuser:appgroup target/payment-service-1.0.jar app.jar

# 5. Switch to unprivileged user
USER appuser

# 6. Document network port
EXPOSE 8080

# 7. Execution command using Exec Form (Array syntax)
ENTRYPOINT ["java", "-jar", "app.jar"]

Build and Run Commands

# Build the image (-t assigns a repository tag, dot '.' specifies the build context):
docker build -t payment-service:1.0 .

# Run the container in detached mode (-d) with port forwarding (-p):
docker run -d -p 8080:8080 --name payment-app payment-service:1.0

🟡 Middle Level

Layer Mechanics and Cache Invalidation Rules

A Docker image is assembled as a stack of immutable, read-only filesystem layers managed by Overlay2:

  • Only instructions that modify the filesystem create a new physical layer: RUN, COPY, and ADD.
  • Metadata instructions (WORKDIR, ENV, EXPOSE, USER, ENTRYPOINT, CMD) modify the image configuration manifest without creating filesystem diff layers.
+-------------------------------------------------------------------+
|                  Docker Layer Caching & Invalidation              |
|                                                                   |
|   Step 1: FROM eclipse-temurin:21-jre-jammy  [CACHED]             |
|   Step 2: WORKDIR /app                       [CACHED]             |
|   Step 3: COPY pom.xml .                     [CACHED - Hash match]|
|   Step 4: RUN ./mvnw dependency:go-offline   [CACHED - Same cmd]  |
|   Step 5: COPY src ./src                     [CACHE MISS - Code!] |
|   Step 6: RUN ./mvnw package                 [FORCED REBUILD]     |
|   Step 7: ENTRYPOINT ["java", "-jar", ...]   [FORCED REBUILD]     |
|                                                                   |
|   Rule: Any cache miss invalidates ALL subsequent layers!         |
+-------------------------------------------------------------------+

How Docker Evaluates Cache Hits:

  1. For RUN commands: Docker compares the raw string text of the command with the metadata of previously built layers. If the command string matches and parent layers were cached, Docker reuses the cached layer.
  2. For COPY and ADD commands: Docker computes a cryptographic checksum (SHA-256) of every file being copied. If any file has a modified hash, a Cache Miss occurs.
  3. Cascading Cache Invalidation: The moment a step triggers a cache miss, all subsequent instructions are executed from scratch, completely bypassing the cache.

Layer Ordering Optimization: Dependencies vs Source Code

Because source code changes with every commit while third-party dependencies change infrequently, you must structure instructions from least frequently changed to most frequently changed:

# ❌ ANTI-PATTERN: Re-downloads 300 MB of Maven dependencies on every 1-line code edit!
COPY . .
RUN mvn clean package

# ✅ PRODUCTION PATTERN: Independent dependency caching layer
WORKDIR /build

# 1. Copy ONLY project descriptors (infrequently modified)
COPY pom.xml mvnw ./
COPY .mvn .mvn

# 2. Pre-fetch dependencies into local m2 cache (fully cached step)
RUN ./mvnw dependency:go-offline -B

# 3. Copy application source code (modified frequently)
COPY src ./src

# 4. Compile application using cached dependencies (fast rebuild in ~2-4s)
RUN ./mvnw package -DskipTests -B

The Build Context & .dockerignore

When you execute docker build .:

  • The Docker CLI client archives the entire current working directory into a tar stream (the Build Context) and uploads it to the Docker daemon / BuildKit socket.
  • If unneeded directories (.git, target/, .idea, large test data dumps) are present, transfer times can take minutes, wasting gigabytes of network and disk I/O.
  • Always include a .dockerignore file in the root of your repository:
.git
.gitignore
.idea
.vscode
target/
build/
*.log
*.tmp
Dockerfile
docker-compose*.yml

🔴 Senior Level

Spring Boot 3 Layered JAR Extraction (Production Gold Standard)

In standard Spring Boot uber-jars, external dependencies (BOOT-INF/lib) account for over 95% of the total file size but rarely change. Application code (BOOT-INF/classes) is only a few hundred kilobytes but changes continuously.

Using Spring Boot’s built-in Layered Tools, we can break the fat-jar into distinct layers during the Docker build:

# ==========================================
# STAGE 1: Extract JAR Layers
# ==========================================
FROM eclipse-temurin:21-jre-jammy AS extractor
WORKDIR /extracted
COPY target/application.jar app.jar

# Spring Boot 3 built-in layer extraction tool
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted/

# ==========================================
# STAGE 2: Minimal Production Runtime
# ==========================================
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app

# Enforce least-privilege non-root execution
RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 --ingroup appgroup appuser
USER appuser:appgroup

# Copy layers from most stable to most volatile:
COPY --from=extractor /extracted/dependencies/ ./
COPY --from=extractor /extracted/spring-boot-loader/ ./
COPY --from=extractor /extracted/snapshot-dependencies/ ./
COPY --from=extractor /extracted/application/ ./

EXPOSE 8080

# Production container JVM ergonomics: Use JarLauncher instead of raw java -jar
ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \
    "-XX:InitialRAMPercentage=75.0", \
    "-XX:+ExitOnOutOfMemoryError", \
    "org.springframework.boot.loader.launch.JarLauncher"]

Result: Pushing updates to your container registry takes less than 2 seconds, because Docker reuses the remote layers for dependencies and only uploads the tiny application layer!


Secure Builds with Docker BuildKit: Secret & Cache Mounts

In enterprise CI/CD environments, applications need access to private artifact repositories (Nexus, Artifactory) or private GitHub repositories. Passing credentials via ARG or ENV is a severe security vulnerability because values persist in docker history and layer blobs.

Modern Docker BuildKit solves this with ephemeral memory mounts:

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app

# 1. Mount host Maven cache ~/.m2 to prevent re-downloading across builds
# 2. Mount private settings.xml directly into RAM via secret mount
RUN --mount=type=cache,target=/root/.m2 \
    --mount=type=secret,id=maven_settings,target=/root/.m2/settings.xml \
    mvn clean package -DskipTests
# Execute build providing secret without baking it into the image:
docker build --secret id=maven_settings,src=~/.m2/settings.xml -t app:1.0 .

The secret exists exclusively in a temporary RAM filesystem (tmpfs) during that single RUN execution, leaving zero traces in the generated image layers or history.


4 Tricky Questions with In-Depth Answers

1. Why is passing passwords or tokens via ARG or ENV in a Dockerfile a critical security vulnerability, and how should secrets be handled?

Answer:

  • Any build variable passed via ARG is stored permanently in the image JSON configuration manifest and layer history. Anyone who pulls the image can run docker history --no-trunc <image> or inspect the image config to retrieve passwords in cleartext.
  • ENV is even more dangerous: it embeds the secret directly into the container’s runtime environment variables, making it visible to any process inside the container and accessible via docker inspect.
  • Solution: Use Docker BuildKit Secret Mounts (RUN --mount=type=secret,id=my_secret ...). BuildKit securely injects the secret as an in-memory tmpfs file under /run/secrets/ for the duration of the build step. It is never committed to intermediate layer diffs, metadata, or the final image.

2. Why does deleting a large file with RUN rm /tmp/big_file.iso in a subsequent instruction NOT decrease the final image size in Overlay2?

Answer: Docker image layers are strictly immutable (Append-Only):

  • Instruction 1 (RUN wget /tmp/big_file.iso) creates Layer 1 containing the 1 GB file on disk.
  • Instruction 2 (RUN rm /tmp/big_file.iso) creates Layer 2. In Overlay2, deleting a file from a lower layer creates a special 0-byte character device entry called a Whiteout File (.wh.big_file.iso).
  • When the container runs, Overlay2 hides the file from the merged view. However, both Layer 1 (1 GB) and Layer 2 (whiteout entry) are bundled into the image and downloaded when pulling.
  • Solution: Execute download, extraction, and cleanup in a single chained RUN instruction:
    RUN wget http://.../file.tar.gz && tar -xzf file.tar.gz && rm file.tar.gz
    

    Or use a Multi-Stage Build where temporary build files reside only in an unexported builder stage.

3. What are the production risks of using Alpine Linux (eclipse-temurin:21-alpine) for high-throughput Java applications?

Answer: Alpine Linux uses musl libc instead of GNU glibc:

  1. JNI / C++ Native Transport Incompatibility: High-performance libraries that rely on native code—such as Netty Epoll transport (netty-transport-native-epoll), RocksDB, Snappy/Zstandard compression for Apache Kafka, and profilers like Async-Profiler—are compiled against glibc. On Alpine, they either fail to load (UnsatisfiedLinkError) or fall back to slow standard Java NIO transports.
  2. Memory Allocator Fragmentation: The default memory allocator in musl is optimized for tiny static binary size, not high-concurrency throughput. Under heavy multi-threaded allocation loads, it suffers from thread lock contention and memory fragmentation compared to ptmalloc (glibc) or jemalloc.
    • Production Recommendation: For enterprise Java services, use Debian-Slim (eclipse-temurin:21-jre-jammy) or Google Distroless Java to ensure 100% glibc compatibility.

4. What is the subtle difference in behavior between COPY src/ /dest/ and COPY src /dest/?

Answer: In Dockerfile syntax, trailing slashes and directory copying have counter-intuitive semantics:

  • When the source is a directory, Docker always copies the contents of the directory, not the directory itself!
  • COPY src /dest/ copies all files inside src directly into /dest/.
  • If you want the destination to contain a directory named src, you must explicitly specify it in the destination path: COPY src /dest/src/.
  • Furthermore, COPY preserves the source files’ host permissions but sets ownership to root:root by default. If the container runs as a non-root user, the application will encounter AccessDeniedException unless the --chown flag is supplied: COPY --chown=appuser:appgroup target/*.jar app.jar.

🎯 Interview Cheat Sheet

30-Second Elevator Pitch

A Dockerfile is a declarative recipe for building immutable, layered container images. Filesystem changes (RUN, COPY, ADD) produce discrete Overlay2 layers. Because cache invalidation cascades downward, static dependencies (like Maven offline dependencies) should always be declared before dynamic application code. In modern enterprise Java, senior engineers use Spring Boot 3 Layered JAR extraction (JarLauncher), Docker BuildKit cache and secret mounts, and Distroless or Debian-Slim base images to avoid musl libc JNI bottlenecks.

Base Image Comparison Matrix

| Base Image | Typical Size | C Standard Library | Production Advantages | Production Disadvantages | |—|—|—|—|—| | temurin:21-jre-jammy | $\approx 250$ MB | glibc | 100% compatibility with Netty, Kafka, Async-Profiler | Larger initial image footprint | | temurin:21-alpine | $\approx 100$ MB | musl | Minimal disk size, fast cold pulls | JNI crashes, memory allocator lock contention | | distroless/java21 | $\approx 150$ MB | glibc | Zero shells, package managers, minimal CVE attack surface | Cannot debug via docker exec -it |

Red Flags (DO NOT Say)

  • ❌ “I just add RUN rm -rf /tmp/large-file later in the Dockerfile to make the image smaller.” (Fails to understand OverlayFS immutability and whiteout files).
  • ❌ “Passing passwords via ARG is safe because args are not environment variables.” (Args are saved in plaintext inside docker history and image metadata).
  • ❌ “Always use :latest for base images so you stay up-to-date.” (Breaks build reproducibility and auditability; always pin specific tags or SHA256 digests).
  • ❌ “Alpine is always the best choice for Java because it’s the smallest.” (Ignores musl performance overhead and native JNI failures in Netty/Kafka).