docker for data engineers is the containerisation surface every DE platform team eventually owns — the dbt image that runs a nightly job in the CI runner and again on the Kubernetes worker, the Airflow image with the extra provider packages the team needs but the official image doesn't ship, the Spark driver image whose Java version has to exactly match the executor's, the JupyterLab image analysts use for exploration. Every DE eventually writes a Dockerfile; knowing multi-stage builds (builder + runtime split), non-root users, .dockerignore hygiene, layer-cache ordering, HEALTHCHECK for K8s liveness, ENTRYPOINT exec-form for signal handling, the Airflow constraints-file pattern that prevents provider dependency conflicts, and BuildKit for parallel stage builds is what separates a mid-level operator who ships fragile 2 GB images from a senior operator who ships lean 200 MB images with clean CI cycles.
The tour walks the five pillars — (1) production Dockerfile for dbt with multi-stage builder + non-root user + profiles at runtime, (2) custom Airflow images extending the official image with pinned providers via the constraints URL matching your Airflow version, (3) Dockerfiles for Spark driver + JupyterLab notebook environments including the OpenJDK layer and mamba for reproducible Python envs, (4) the multi-stage build pattern that separates fat builder from lean runtime + the .dockerignore / layer-cache-order rules that speed rebuilds 10×, and (5) production best practices — HEALTHCHECK, ENTRYPOINT ["exec"] for SIGTERM handling, EXPOSE documentation, ARG vs ENV, secrets via env-only, trivy scanning, cosign signing. Every section ships a Solution-Tail interview answer — code, trace, output, why-this-works with __concept__ underlines.
Practice on SQL library →, SQL optimization drills →, and SQL indexing drills →.
On this page
- Why Docker matters for DE in 2026
- Dockerfile for dbt
- Dockerfile for Airflow
- Dockerfile for Spark / notebook
- Multi-stage + best practices
- Cheat sheet — Docker recipe list
- Frequently asked questions
- Practice on PipeCode
1. Why Docker matters for DE in 2026
The docker for data engineers mental model — reproducible environments, small images, layer caching, non-root, signals
The one-sentence invariant: a production Docker image is small (< 300 MB for a Python DE image, < 1 GB for Spark), reproducible (pinned versions everywhere, no latest tags), safe (non-root user, no baked secrets), fast to rebuild (dependencies before source code so cache invalidation is minimal), and correct under Kubernetes (HEALTHCHECK, exec-form ENTRYPOINT for SIGTERM handling); every DE image you ship should hit those five properties or the on-call engineer will pay the price at 3 a.m.
Where Docker actually shows up in DE.
- Airflow / Prefect operators. Each task is a container; the image ships the DAG's dependencies.
- dbt Cloud alternatives. Self-hosted dbt in container; scheduled via Airflow or Argo.
- Spark on K8s. SparkApplication CRD launches driver + executor containers.
- CI test isolation. Reproducible env per test run; no "works on my machine".
- Notebook environments. JupyterLab for analysts; kernel = data-engineer-blessed image.
-
Local dev.
docker-compose upspins Postgres + Airflow + dbt + Metabase for feature development.
The five properties of a good DE image.
-
Small.
python:3.11-slim(150 MB) notpython:3.11(900 MB) notpython:3.11-alpine(musl compat issues on numpy/pandas). - Reproducible. Pin versions of everything (dbt-core==1.9.0, snowflake-connector-python==3.13.0); use constraints files where relevant (Airflow).
-
Safe.
useradd -u 1000 appuser+USER appuser; never run as root; never bake secrets into layers. -
Fast to rebuild. COPY requirements before source; multi-stage builder + runtime;
.dockerignoreexcludes bulky files. -
K8s-friendly.
HEALTHCHECK,ENTRYPOINT ["exec"](for SIGTERM propagation),EXPOSE(docs), resource-aware.
The anti-patterns.
-
FROM ubuntu:latest. Latest tag = non-reproducible; ubuntu-full is bloated. - Running as root. Security violation; K8s PSPs reject.
-
No
.dockerignore. Copies.git,.venv,__pycache__,node_modules— inflates image + slow build. -
Single monolithic layer.
COPY . .beforepip install— every source change invalidates deps cache. -
ENTRYPOINT "cmd". Shell form; doesn't propagate SIGTERM; K8s can't gracefully stop. -
Embedded secrets.
ENV AWS_ACCESS_KEY=...— visible in layer history forever. -
pip installwithout version pins. Every rebuild = different versions = broken pipelines.
What senior interviewers actually probe on Docker for DE.
- Multi-stage builds. Builder + runtime; why final image is small.
-
.dockerignorecontents..git,.venv,__pycache__,*.pyc,.pytest_cache,node_modules. - Layer cache order. COPY requirements first, install deps, then COPY source.
-
Non-root user.
useradd -m -u 1000 appuser+USER appuser. - HEALTHCHECK. Simple command verifying process is alive.
-
ENTRYPOINT exec form.
["python", "main.py"]not"python main.py". - Airflow constraints file. The URL matching your Airflow + Python version.
- Base image choice. slim vs alpine vs distroless.
- Image size. Aim < 300 MB Python; < 1 GB Spark.
- Signal handling. SIGTERM to child; tini as init for zombies.
-
BuildKit.
DOCKER_BUILDKIT=1for parallel stages. - trivy scan. Vulnerability scanning in CI.
Worked example — the 2 GB image that shrunk to 250 MB
Detailed explanation. A dbt image at v1 was 2.1 GB — built from python:3.11 (900 MB) + all dev tools + tests + docs. After applying five best practices, dropped to 250 MB.
Question. Show before + after.
Code — Before (2.1 GB).
FROM python:3.11
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
RUN pip install -r requirements-dev.txt
CMD python main.py
Code — After (250 MB).
# ---- Builder stage ----
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# ---- Runtime stage ----
FROM python:3.11-slim
RUN useradd -m -u 1000 dbtuser
COPY --from=builder --chown=dbtuser:dbtuser /root/.local /home/dbtuser/.local
ENV PATH=/home/dbtuser/.local/bin:$PATH
USER dbtuser
WORKDIR /app
COPY --chown=dbtuser:dbtuser dbt_project /app
HEALTHCHECK --interval=30s --timeout=5s CMD dbt --version || exit 1
ENTRYPOINT ["dbt"]
CMD ["run"]
Step-by-step explanation.
-
python:3.11→python:3.11-slim— saves ~700 MB (Debian slim vs full). - Multi-stage — builder installs deps; runtime only copies wheels; dev deps never in runtime.
-
--no-cache-dir— pip skips ~200 MB of package caches. -
.dockerignore(implied) — excludes.git, tests, docs. - Non-root user — sec best practice; K8s PSP-compatible.
-
HEALTHCHECK— K8s liveness probe honours. -
ENTRYPOINT ["dbt"]exec form — SIGTERM propagates cleanly.
Output.
| Attribute | Before | After |
|---|---|---|
| Size | 2.1 GB | 250 MB |
| Base image | python:3.11 | python:3.11-slim |
| Non-root | no | yes |
| Multi-stage | no | yes |
| Rebuild speed (source-only change) | 3 min | 15 sec |
Rule of thumb. Every DE image should be < 300 MB (Python) or < 1 GB (Spark). Multi-stage + slim base is the biggest win.
Worked example — layer cache ordering
Detailed explanation. Wrong order: COPY . . RUN pip install — every source change invalidates the pip layer. Right order: COPY requirements.txt . RUN pip install . COPY . . — pip layer stays cached until requirements change.
Code.
# BAD — pip re-runs on every code change
FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
# GOOD — pip cached until requirements.txt changes
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
Rule of thumb. Slowest-changing (dependencies) copied first; fastest-changing (source) last.
Worked example — signal handling
Detailed explanation. Kubernetes sends SIGTERM to the container; shell-form ENTRYPOINT doesn't propagate to the child process; process ignores SIGTERM; K8s waits 30 sec then SIGKILL. Data loss possible.
Code.
# BAD — shell form; SIGTERM not propagated
ENTRYPOINT python main.py
# GOOD — exec form; SIGTERM goes to python directly
ENTRYPOINT ["python", "main.py"]
# BEST — tini as init to reap zombies
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["tini", "--", "python", "main.py"]
Rule of thumb. Always exec-form ENTRYPOINT. tini for long-running processes to reap zombies.
Common beginner mistakes
-
FROM latest— no version reproducibility. - Root user — security violation.
- No
.dockerignore— image bloat + slow builds. - COPY source before deps — cache thrash.
- Shell form ENTRYPOINT — signal-handling broken.
- Baking secrets — visible forever in layer history.
- No HEALTHCHECK — K8s can't tell liveness.
- Alpine base for numpy/pandas — musl compat issues.
docker for data engineers interview question on image size + build speed
A senior interviewer opens with: "Your dbt image is 2 GB and takes 5 minutes to rebuild on every source change. Optimise both."
Solution Using multi-stage + slim base + .dockerignore + BuildKit + layer ordering
# syntax=docker/dockerfile:1.6
# ---- Builder stage ----
FROM python:3.11-slim AS builder
WORKDIR /app
# Deps first (cached until requirements change)
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --user --no-cache-dir -r requirements.txt
# ---- Runtime stage ----
FROM python:3.11-slim
RUN useradd -m -u 1000 dbtuser
COPY --from=builder --chown=dbtuser:dbtuser /root/.local /home/dbtuser/.local
ENV PATH=/home/dbtuser/.local/bin:$PATH
USER dbtuser
WORKDIR /app
# Source last (invalidates less often)
COPY --chown=dbtuser:dbtuser dbt_project /app
HEALTHCHECK --interval=30s --timeout=5s CMD dbt --version || exit 1
ENTRYPOINT ["dbt"]
CMD ["run"]
Companion .dockerignore.
.git/
.venv/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
node_modules/
docs/
tests/
*.log
Build with BuildKit.
DOCKER_BUILDKIT=1 docker build -t myco/dbt:1.9.0 .
Step-by-step trace.
| Step | Action | Layer change |
|---|---|---|
| 1 | slim base | 150 MB |
| 2 | pip install requirements | 100 MB (cached) |
| 3 | Copy source | Small (invalidates only on source change) |
| 4 | Multi-stage discards builder | Final image ~250 MB |
| 5 | BuildKit parallel + cache mount | 15-30 sec rebuild |
Output:
| Metric | Before | After |
|---|---|---|
| Image size | 2 GB | 250 MB |
| First build | 5 min | 3 min |
| Source-only rebuild | 5 min | 15 sec |
| Deps-changed rebuild | 5 min | 45 sec |
Why this works — concept by concept:
- Multi-stage — builder + runtime; dev tools never in runtime.
- Slim base — 150 MB Debian slim, compatible with pandas/numpy.
- Layer order — deps before source; cache stays valid on source-only changes.
- .dockerignore — excludes .git, .venv, tests; smaller build context; faster upload.
- BuildKit cache mount — pip cache preserved across builds.
- Non-root — K8s PSP-compatible.
- HEALTHCHECK — K8s liveness.
- ENTRYPOINT exec form — clean SIGTERM handling.
SQL
Topic — SQL
SQL practice library
2. Dockerfile for dbt
dockerfile dbt — multi-stage build with pinned dbt-core + adapters + profiles.yml at runtime
The mental model in one line: a production dbt Docker image installs dbt-core plus the target-specific adapter(s) (dbt-postgres, dbt-snowflake, dbt-bigquery) with pinned versions in a builder stage, copies the wheels into a slim runtime stage as a non-root user, mounts profiles.yml at runtime (never bake credentials), sets DBT_PROFILES_DIR env, and exposes a HEALTHCHECK that verifies dbt is invokable — the image is ~250 MB and rebuilds in 15 seconds on source-only changes.
Slot 1 — the canonical dbt Dockerfile.
# syntax=docker/dockerfile:1.6
# ---- Builder ----
FROM python:3.11-slim AS builder
# Install build deps if needed for compilation
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Pin exact versions
RUN pip install --user --no-cache-dir \
dbt-core==1.9.0 \
dbt-postgres==1.9.0 \
dbt-snowflake==1.9.0 \
dbt-bigquery==1.9.0
# ---- Runtime ----
FROM python:3.11-slim
# Non-root user
RUN useradd -m -u 1000 dbtuser
COPY --from=builder --chown=dbtuser:dbtuser /root/.local /home/dbtuser/.local
ENV PATH=/home/dbtuser/.local/bin:$PATH
USER dbtuser
WORKDIR /app
# Copy dbt project (models, macros, dbt_project.yml)
COPY --chown=dbtuser:dbtuser dbt_project /app
# profiles.yml mounted at runtime; never baked in
ENV DBT_PROFILES_DIR=/app/.dbt
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s CMD dbt --version || exit 1
ENTRYPOINT ["dbt"]
CMD ["run"]
Slot 2 — pinning versions.
-
dbt-core==1.9.0— exact version. - Adapter version must match core (
dbt-postgres==1.9.0). - Don't use
>=1.9.0— future patch might break. - Test versions in staging before pinning.
Slot 3 — profiles.yml at runtime.
# Mount profiles as volume
docker run -v ./.dbt:/app/.dbt myco/dbt:1.9.0 run
# Or via K8s
volumeMounts:
- name: profiles
mountPath: /app/.dbt
Credentials come from env vars referenced in profiles.yml:
# profiles.yml
myproject:
target: prod
outputs:
prod:
type: snowflake
account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
user: "{{ env_var('SNOWFLAKE_USER') }}"
password: "{{ env_var('SNOWFLAKE_PASSWORD') }}"
Slot 4 — dbt commands.
# Full build
docker run myco/dbt:1.9.0 build
# Specific model
docker run myco/dbt:1.9.0 run --select fact_orders+
# Deps + build
docker run myco/dbt:1.9.0 deps
docker run myco/dbt:1.9.0 build
# Test only
docker run myco/dbt:1.9.0 test
Slot 5 — running in K8s (KubernetesPodOperator).
from airflow.providers.cncf.kubernetes.operators.pod import KubernetesPodOperator
dbt_build = KubernetesPodOperator(
task_id="dbt_build",
image="myco/dbt:1.9.0",
cmds=["dbt"],
arguments=["build", "--select", "fact_orders+"],
env_vars={"SNOWFLAKE_ACCOUNT": "...", "SNOWFLAKE_USER": "..."},
volumes=[{...profiles...}],
volume_mounts=[{...mount to /app/.dbt...}],
)
Slot 6 — dbt deps caching.
# Cache dbt deps if you use packages
COPY packages.yml /app/
RUN dbt deps
Slot 7 — target-schema pattern.
- Bake environment-neutral image.
- Environment-specific config via env vars.
- Same image runs in dev/staging/prod.
Slot 8 — logging.
- dbt writes logs to
logs/dbt.logby default. - Configure to stdout for K8s pod logs.
-
dbt --log-format jsonfor structured logging.
Slot 9 — CI usage.
# GitHub Actions
- name: Build dbt image
run: docker build -t myco/dbt:${{ github.sha }} .
- name: Run dbt tests
run: docker run --env SNOWFLAKE_PASSWORD myco/dbt:${{ github.sha }} test
Slot 10 — dbt state comparison (slim CI).
# Run only changed models
docker run \
-v ./state:/app/state \
myco/dbt:1.9.0 build --select state:modified+ --state /app/state
Common beginner mistakes
- Baking
profiles.ymlwith credentials — visible in layers. - Using
dbt-coreunpinned — surprise upgrades. - Running as root — security violation.
- Not COPYing
dbt_project.yml— image can't identify project. - Missing
HEALTHCHECK— K8s can't verify. - Alpine base with numpy — musl compat issues.
Worked example — build + test in CI
Code.
# .github/workflows/dbt.yml
name: dbt CI
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: |
DOCKER_BUILDKIT=1 docker build \
-t myco/dbt:${{ github.sha }} \
.
- name: Run dbt tests
env:
SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_USER }}
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }}
run: |
docker run --rm \
-e SNOWFLAKE_ACCOUNT \
-e SNOWFLAKE_USER \
-e SNOWFLAKE_PASSWORD \
-v ${{ github.workspace }}/profiles:/app/.dbt \
myco/dbt:${{ github.sha }} \
test
Rule of thumb. CI builds fresh image; tests against ephemeral schema; credentials via secrets.
Worked example — running as sidecar in Airflow
Code.
KubernetesPodOperator(
task_id="dbt_daily",
image="myco/dbt:1.9.0",
cmds=["dbt"],
arguments=["build"],
env_vars={
"SNOWFLAKE_ACCOUNT": Variable.get("sf_account"),
"SNOWFLAKE_USER": Variable.get("sf_user"),
"SNOWFLAKE_PASSWORD": Variable.get("sf_pw"),
},
volumes=[
k8s.V1Volume(
name="profiles",
config_map=k8s.V1ConfigMapVolumeSource(name="dbt-profiles"),
)
],
volume_mounts=[
k8s.V1VolumeMount(name="profiles", mount_path="/app/.dbt")
],
is_delete_operator_pod=True, # cleanup on success/failure
get_logs=True,
)
Rule of thumb. Profiles.yml in ConfigMap; secrets in Kubernetes Secrets referenced via env_vars.
Worked example — slim CI with state:modified+
Code.
- name: Run only changed models
run: |
docker run --rm \
-v ./state:/app/state \
myco/dbt:latest \
build --select state:modified+ --defer --state /app/state
Rule of thumb. dbt slim CI runs only changed models + downstream; huge speedup on large repos.
dockerfile dbt interview question on state management
A senior interviewer asks: "Your dbt repo has 500 models. CI runs the full build on every PR — takes 30 minutes. How do you slim?"
Solution Using dbt slim CI with state comparison + image caching
- name: Restore prod state
uses: actions/download-artifact@v3
with:
name: prod-dbt-manifest
path: ./state
- name: Build image with cache
run: |
DOCKER_BUILDKIT=1 docker build \
--cache-from myco/dbt:cache \
--cache-to myco/dbt:cache \
-t myco/dbt:${{ github.sha }} \
.
- name: Run only changed
run: |
docker run --rm \
-v ./state:/app/state \
myco/dbt:${{ github.sha }} \
build --select state:modified+ --defer --state /app/state
Why this works — concept by concept:
- Prod manifest artifact — the state file from prod's last successful run.
-
state:modified+selector — only models changed or downstream of changed. -
--defer— unchanged upstream models come from prod state, not rebuilt. - Image cache — BuildKit cache-from/to speeds image rebuilds.
- Cost — 30 min → 3 min for typical PRs.
SQL
Topic — SQL
SQL practice library
3. Dockerfile for Airflow
dockerfile airflow — extend the official image with providers via constraints.txt
The mental model in one line: never build Airflow from scratch — always extend the official apache/airflow:VERSION-pythonVERSION image, install additional Python providers via pip install --constraint <constraints-url> using the URL matching your Airflow version + Python minor version (https://raw.githubusercontent.com/apache/airflow/constraints-2.10.0/constraints-3.11.txt) to prevent dependency conflicts, bake DAGs into the image OR mount from git-sync sidecar, use non-root airflow user (already in official image), and add HEALTHCHECK for K8s liveness.
Slot 1 — the canonical Airflow Dockerfile.
FROM apache/airflow:2.10.0-python3.11
# Switch to root for system deps
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
&& rm -rf /var/lib/apt/lists/*
# Switch back to airflow user for pip install
USER airflow
# Install providers with constraints to avoid conflicts
ARG AIRFLOW_VERSION=2.10.0
ARG PYTHON_VERSION=3.11
RUN pip install --no-cache-dir \
"apache-airflow-providers-snowflake" \
"apache-airflow-providers-postgres" \
"apache-airflow-providers-amazon" \
"apache-airflow-providers-cncf-kubernetes" \
"dbt-core==1.9.0" \
"dbt-snowflake==1.9.0" \
--constraint "https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-${PYTHON_VERSION}.txt"
# Copy DAGs (or use git-sync at runtime)
COPY --chown=airflow:root dags /opt/airflow/dags
COPY --chown=airflow:root plugins /opt/airflow/plugins
Slot 2 — the constraints file explained.
- Airflow ships a
constraints-VERSION/constraints-PYVER.txtper release. - Pins every dep to a compatible version tested with Airflow release.
- Without it, pip resolves to latest and conflicts silently.
- URL format:
https://raw.githubusercontent.com/apache/airflow/constraints-{AIRFLOW}/constraints-{PY}.txt.
Slot 3 — DAG deployment options.
- Bake in image. Copy DAGs into image at build. Simple; requires rebuild per DAG change.
- git-sync sidecar. Sidecar container clones repo into shared volume; DAGs sync every 60s. Best for GitOps.
- S3 sync. Similar to git-sync but from S3; less common.
Slot 4 — configuration via env vars.
ENV AIRFLOW__CORE__EXECUTOR=KubernetesExecutor
ENV AIRFLOW__CORE__LOAD_EXAMPLES=False
ENV AIRFLOW__WEBSERVER__RBAC=True
Airflow config values via AIRFLOW__<section>__<key> env pattern.
Slot 5 — custom plugins.
COPY --chown=airflow:root plugins /opt/airflow/plugins
Custom operators, hooks, sensors.
Slot 6 — providers matrix.
-
apache-airflow-providers-snowflake— Snowflake connections + operators. -
apache-airflow-providers-postgres— Postgres. -
apache-airflow-providers-amazon— AWS (S3, Redshift, Athena, Glue). -
apache-airflow-providers-google— GCP (BigQuery, GCS). -
apache-airflow-providers-microsoft-azure— Azure. -
apache-airflow-providers-dbt-cloud— dbt Cloud. -
apache-airflow-providers-cncf-kubernetes— Kubernetes.
Slot 7 — DAG image vs image-per-task.
- Single image with all DAGs — simpler; larger image.
- Image per task — KubernetesPodOperator + task-specific image (dbt, spark, etc.); best isolation.
Slot 8 — building with local test.
DOCKER_BUILDKIT=1 docker build -t myco/airflow:2.10.0 .
# Verify
docker run --rm myco/airflow:2.10.0 airflow version
Slot 9 — providers-only vs full stack.
Alternative: use apache/airflow and mount providers via values.yaml on Helm chart. Less flexibility; simpler.
Slot 10 — DAG parsing at startup.
- Airflow parses DAGs on scheduler start.
- Bad DAG = scheduler crash.
- CI check:
python -c "import dag_module"on every DAG. -
airflow dags listin build validates parsing.
Common beginner mistakes
- Not using constraints URL — provider conflicts.
- Root user for DAGs — permission issues.
- Baking secrets — visible in layer history.
- Wrong Python version in constraints URL.
-
apache-airflowin pip (main package) — should extend official image. - Forgetting
--constraint— pip resolves latest.
Worked example — extending Airflow with custom providers
Code.
FROM apache/airflow:2.10.0-python3.11
USER airflow
ARG AIRFLOW_VERSION=2.10.0
ARG PYTHON_VERSION=3.11
ARG CONSTRAINT_URL=https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-${PYTHON_VERSION}.txt
RUN pip install --no-cache-dir \
"apache-airflow-providers-snowflake" \
"apache-airflow-providers-postgres" \
"apache-airflow-providers-amazon" \
"dbt-core==1.9.0" \
"dbt-snowflake==1.9.0" \
"pandas==2.1.4" \
--constraint "${CONSTRAINT_URL}"
COPY --chown=airflow:root dags /opt/airflow/dags
COPY --chown=airflow:root plugins /opt/airflow/plugins
ENV AIRFLOW__CORE__LOAD_EXAMPLES=False
Rule of thumb. Constraints URL is mandatory for stability.
Worked example — validating DAGs at build
Code.
# At end of Dockerfile
RUN airflow db init && \
airflow dags list-import-errors && \
if [ "$?" != "0" ]; then exit 1; fi
Rule of thumb. Fail CI on DAG parse errors.
Worked example — GitOps with git-sync sidecar
Helm values.yaml.
dags:
gitSync:
enabled: true
repo: git@github.com:myco/airflow-dags.git
branch: main
subPath: dags
interval: 60
knownHosts: |
github.com ssh-rsa AAAAB...
Rule of thumb. git-sync for GitOps; image build for immutable deploys.
dockerfile airflow interview question on constraints
A senior interviewer asks: "What's the constraints-file pattern for Airflow, and what breaks without it?"
Solution Using constraints URL matching Airflow + Python version
ARG AIRFLOW_VERSION=2.10.0
ARG PYTHON_VERSION=3.11
RUN pip install --no-cache-dir \
"apache-airflow-providers-snowflake" \
"apache-airflow-providers-postgres" \
--constraint "https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-${PYTHON_VERSION}.txt"
Why this works — concept by concept:
- Constraints file — Airflow's tested-compatible versions of every dep.
- Without it — pip resolves latest; provider vX.Y incompatible with core vA.B.
- Symptom — silent version conflicts; scheduler crashes; ImportError at runtime.
-
Fix — always pass
--constraint URLwhen installing providers.
SQL
Topic — SQL
SQL practice library
4. Dockerfile for Spark / notebook
dockerfile spark — driver image matching cluster Java version; JupyterLab for analysts
The mental model in one line: Spark Docker images extend apache/spark:VERSION-pythonN for the driver, install pyspark + Delta / Iceberg libraries pinned to the Spark version, run as non-root spark user, and match the Java version to the K8s / EMR / Databricks cluster exactly — mismatched Java = runtime NoClassDefFoundError; JupyterLab images extend jupyter/pyspark-notebook for analysts + install Polars, DuckDB, Pydantic via mamba for reproducible env.
Slot 1 — Spark driver image.
FROM apache/spark:3.5.1-python3
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
USER spark
RUN pip install --no-cache-dir \
pyspark==3.5.1 \
delta-spark==3.2.0 \
pandas==2.1.4 \
polars==0.20.0 \
boto3
WORKDIR /opt/spark/work-dir
# Copy application
COPY --chown=spark:spark app /opt/spark/work-dir
ENTRYPOINT ["/opt/entrypoint.sh"]
Slot 2 — Java version match.
- Spark 3.5 → Java 17.
- Spark 3.4 → Java 11 or 17.
- Cluster Java must match driver Java for serialised UDFs.
-
apache/spark:3.5.1-python3ships Java 17.
Slot 3 — SparkApplication CRD (K8s).
apiVersion: sparkoperator.k8s.io/v1beta2
kind: SparkApplication
spec:
image: myco/spark:3.5.1
mainApplicationFile: local:///opt/spark/work-dir/main.py
sparkVersion: 3.5.1
driver:
cores: 1
memory: 2g
serviceAccount: spark
executor:
instances: 5
cores: 2
memory: 4g
Slot 4 — Delta Lake + Iceberg.
RUN pip install --no-cache-dir \
delta-spark==3.2.0 \
pyiceberg==0.6.0
Slot 5 — JupyterLab image for analysts.
FROM jupyter/pyspark-notebook:python-3.11
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
USER $NB_UID
RUN mamba install -c conda-forge -y \
duckdb=0.10.0 \
polars=0.20.0 \
pydantic=2.5.0 \
pyarrow=15.0.0 \
&& mamba clean -a -y
# Custom kernels
COPY --chown=$NB_UID:$NB_GID custom-kernels /opt/kernels/
Slot 6 — using PySpark from Airflow.
from airflow.providers.apache.spark.operators.spark_submit import SparkSubmitOperator
etl = SparkSubmitOperator(
task_id="etl",
application="s3://bucket/etl.py",
conn_id="spark_default",
executor_memory="4g",
driver_memory="2g",
num_executors=5,
application_args=["--date", "2026-07-12"],
)
Slot 7 — mamba vs pip for notebooks.
- mamba — faster than conda; solves deps in seconds.
- conda — original; slower on complex envs.
- pip — fastest for pure-Python; use in slim Docker images.
- JupyterLab image — mamba preferred for R + Python + native libs.
Slot 8 — running notebook as service.
docker run -p 8888:8888 \
-v $PWD/notebooks:/home/jovyan/work \
myco/jupyter:latest \
jupyter lab --ip=0.0.0.0 --no-browser
Slot 9 — GPU support for notebooks.
FROM jupyter/pytorch-notebook:cuda-python-3.11
RUN mamba install -c conda-forge cudatoolkit=12.1
Requires NVIDIA container runtime on host.
Slot 10 — image size for Spark.
- Full Spark image: ~2 GB (Java + Python + Spark libs).
- Slim custom: ~1.2 GB via multi-stage.
- Can't get much smaller — Spark is inherently large.
Common beginner mistakes
- Java version mismatch — driver 17 vs cluster 11 → runtime errors.
- Not matching Delta version to Spark version.
- Running as root — K8s PSP rejects.
- Missing Hadoop / cloud storage libs.
- Notebook image without pinned kernels.
Worked example — Delta Lake ETL image
Code.
FROM apache/spark:3.5.1-python3
USER spark
RUN pip install --no-cache-dir \
delta-spark==3.2.0 \
pyspark==3.5.1 \
boto3
WORKDIR /opt/spark/work-dir
COPY --chown=spark:spark etl.py /opt/spark/work-dir/
ENTRYPOINT ["spark-submit", \
"--packages", "io.delta:delta-spark_2.12:3.2.0", \
"--conf", "spark.sql.extensions=io.delta.sql.DeltaSparkSessionExtension", \
"/opt/spark/work-dir/etl.py"]
Rule of thumb. Delta version + Spark version + Scala version must all match.
Worked example — analyst notebook stack
Code.
FROM jupyter/pyspark-notebook:python-3.11
USER $NB_UID
RUN mamba install -c conda-forge -y \
duckdb polars pydantic sqlalchemy \
matplotlib seaborn plotly \
scikit-learn statsmodels \
&& mamba clean -a -y
# JupyterLab extensions
RUN pip install jupyterlab-lsp python-lsp-server
# Copy default notebooks
COPY --chown=$NB_UID:$NB_GID starter-notebooks /home/jovyan/starter-notebooks/
Rule of thumb. Standard analyst image contains DE + BI + ML libraries.
Worked example — Spark on K8s
Kubernetes.
apiVersion: sparkoperator.k8s.io/v1beta2
kind: SparkApplication
metadata:
name: daily-etl
spec:
type: Python
mode: cluster
image: myco/spark:3.5.1
imagePullPolicy: Always
mainApplicationFile: local:///opt/spark/work-dir/etl.py
sparkVersion: 3.5.1
restartPolicy:
type: OnFailure
onFailureRetries: 3
driver:
cores: 1
memory: 2g
serviceAccount: spark
executor:
instances: 5
cores: 2
memory: 4g
Rule of thumb. SparkApplication CRD + SparkOperator = declarative Spark jobs.
dockerfile spark interview question on Java + Python + Spark version matching
A senior interviewer asks: "You have Spark 3.5.1 driver, Delta 3.2.0, and Java 17. What's the compatibility matrix?"
Solution Using explicit version pinning across all layers
FROM apache/spark:3.5.1-python3 # Java 17 baked in
USER spark
RUN pip install --no-cache-dir \
pyspark==3.5.1 \
delta-spark==3.2.0 \
pandas==2.1.4
ENTRYPOINT ["spark-submit", \
"--packages", "io.delta:delta-spark_2.12:3.2.0", \
"--conf", "spark.sql.extensions=io.delta.sql.DeltaSparkSessionExtension"]
Why this works — concept by concept:
-
Spark 3.5.1 requires Java 17 — base image
apache/spark:3.5.1-python3uses Java 17. - Delta 3.2.0 compatible with Spark 3.5.x — check compat matrix on Delta docs.
-
Scala 2.12 —
_2.12suffix on package coordinate matches Spark. - PyPI pyspark version == cluster Spark version — client-server compat.
- Cost of mismatch — NoClassDefFoundError, ClassNotFoundException at runtime.
SQL
Topic — optimization
SQL optimization drills
5. Multi-stage + best practices
The six best practices every Dockerfile ships with — .dockerignore + multi-stage + non-root + HEALTHCHECK + ENTRYPOINT exec + trivy
The mental model in one line: every production DE Docker image must combine six practices — a .dockerignore that excludes .git/.venv/__pycache__/tests, a multi-stage build with fat builder + lean runtime, a non-root user via useradd -u 1000, a HEALTHCHECK command for K8s liveness, an exec-form ENTRYPOINT ["cmd"] for SIGTERM propagation, and a trivy image scan step in CI to catch CVEs — miss any one and you pay for it in production.
Slot 1 — the canonical .dockerignore.
.git/
.gitignore
.gitattributes
.venv/
venv/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
node_modules/
docs/
tests/
*.log
.env
.env.*
docker-compose*.yml
.dockerignore
Dockerfile*
README.md
Slot 2 — multi-stage builds.
FROM python:3.11-slim AS builder
# Install everything needed to compile
RUN apt-get update && apt-get install -y gcc && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
# Copy only the installed wheels, not the compiler
COPY --from=builder /root/.local /root/.local
Slot 3 — layer cache order.
- Slowest-changing first. Base image, system deps, requirements.
- Fastest-changing last. Source code.
-
Bad:
COPY . . RUN pip install— cache dies on every source change. -
Good:
COPY requirements.txt . RUN pip install . COPY . .— cache lives until requirements change.
Slot 4 — non-root user.
RUN useradd -m -u 1000 appuser
USER appuser
- K8s PodSecurityContext often requires non-root.
- Reduces blast radius of container escape.
- Files copied should be
chownto appuser.
Slot 5 — HEALTHCHECK.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import myapp; print('ok')" || exit 1
- K8s liveness probe honours HEALTHCHECK.
- Container marked unhealthy after 3 consecutive fails.
- Restart triggered.
Slot 6 — ENTRYPOINT + CMD.
ENTRYPOINT ["python", "-m", "myapp"]
CMD ["--help"]
- Exec form (
[]) — PID 1; SIGTERM propagates. - ENTRYPOINT = fixed executable.
- CMD = default args (overridable via
docker run image arg1).
Slot 7 — ARG vs ENV.
ARG BUILD_DATE
ARG VCS_REF
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.revision=$VCS_REF
ENV APP_HOME=/app
ENV LOG_LEVEL=INFO
- ARG — build-time variable; not available at runtime.
- ENV — runtime environment; visible in container.
- Use ARG for build metadata; ENV for runtime config.
Slot 8 — BuildKit for parallel + cache.
DOCKER_BUILDKIT=1 docker build \
--cache-from myco/app:cache \
--cache-to myco/app:cache \
-t myco/app:1.2.3 \
.
- Parallel stage execution.
- Better cache management.
-
--mount=type=cachefor pip / npm caches.
Slot 9 — trivy security scan.
trivy image --severity HIGH,CRITICAL myco/app:1.2.3
- Scans OS packages + language deps for CVEs.
- CI fails if HIGH or CRITICAL found.
- Update base images regularly.
Slot 10 — image signing with cosign.
cosign sign --key cosign.key myco/app:1.2.3
cosign verify --key cosign.pub myco/app:1.2.3
- Supply-chain security.
- Kubernetes admission controller can enforce.
Slot 11 — SBOM (Software Bill of Materials).
docker buildx build --sbom=true -t myco/app:1.2.3 .
- Lists every package in the image.
- Compliance requirement in some industries.
Slot 12 — tini for signal + zombie reaping.
RUN apt-get update && apt-get install -y --no-install-recommends tini && \
rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["tini", "--", "python", "-m", "myapp"]
- tini reaps zombie processes.
- Handles SIGTERM correctly.
- Recommended for long-running processes.
Common beginner mistakes
- No
.dockerignore— image bloat + slow uploads. - Shell-form ENTRYPOINT — SIGTERM broken.
- Root user — security violation.
- Missing HEALTHCHECK — K8s can't check liveness.
- No trivy in CI — CVEs leak to prod.
- No SBOM — compliance gaps.
- ARG for runtime — not available at runtime.
Worked example — the complete production Dockerfile
Code.
# syntax=docker/dockerfile:1.6
ARG PYTHON_VERSION=3.11
# ---- Builder ----
FROM python:${PYTHON_VERSION}-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --user --no-cache-dir -r requirements.txt
# ---- Runtime ----
FROM python:${PYTHON_VERSION}-slim
# Metadata
ARG BUILD_DATE
ARG VCS_REF
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.revision=$VCS_REF
LABEL org.opencontainers.image.source=https://github.com/myco/repo
# Non-root user
RUN useradd -m -u 1000 appuser
COPY --from=builder --chown=appuser:appuser /root/.local /home/appuser/.local
ENV PATH=/home/appuser/.local/bin:$PATH
# tini for signals + zombies
RUN apt-get update && apt-get install -y --no-install-recommends tini && \
rm -rf /var/lib/apt/lists/*
USER appuser
WORKDIR /app
COPY --chown=appuser:appuser src /app
# Environment
ENV LOG_LEVEL=INFO PYTHONUNBUFFERED=1
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import myapp; print('ok')" || exit 1
# Signal-safe entrypoint
ENTRYPOINT ["tini", "--", "python", "-m", "myapp"]
CMD ["--help"]
Rule of thumb. Every DE image starts from this template.
Worked example — CI pipeline with security
GitHub Actions.
name: Docker CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: myco/app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myco/app:${{ github.sha }}
exit-code: 1
severity: HIGH,CRITICAL
Rule of thumb. Every image push scanned; block on HIGH/CRITICAL CVEs.
Worked example — image size analysis
$ docker images myco/app
myco/app 1.2.3 ... 256MB
$ docker history myco/app:1.2.3
IMAGE SIZE CREATED BY
6c8f7b9a1... 150MB /bin/sh -c useradd -m -u 1000 appuser
6c8f7b9a2... 90MB COPY /root/.local /home/appuser/.local
6c8f7b9a3... 5MB COPY src /app
6c8f7b9a4... 11MB apt-get install tini
$ dive myco/app:1.2.3
# Interactive tree explorer for wasted space
Rule of thumb. dive tool inspects layers; catches wasted space.
docker for data engineers interview question on production readiness
A senior interviewer asks: "Review this Dockerfile for prod-readiness."
FROM python:3.11
COPY . .
RUN pip install -r requirements.txt
CMD python main.py
Solution Using the 6-property audit
Issues found:
1. FROM python:3.11 (full, ~900 MB) — should be python:3.11-slim.
2. COPY . . before requirements — cache dies on any source change.
3. Root user — security violation.
4. Shell-form CMD — SIGTERM not propagated.
5. No HEALTHCHECK — K8s can't check liveness.
6. No .dockerignore — image bloat.
7. pip install without --no-cache-dir — extra 100+ MB of caches.
8. No pinned dbt/lib versions — non-reproducible.
9. No multi-stage — dev tools in runtime.
10. No trivy scan in CI.
Fixed version:
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
RUN useradd -m -u 1000 appuser
COPY --from=builder --chown=appuser:appuser /root/.local /home/appuser/.local
ENV PATH=/home/appuser/.local/bin:$PATH
USER appuser
WORKDIR /app
COPY --chown=appuser:appuser . /app
HEALTHCHECK CMD python -c "import myapp" || exit 1
ENTRYPOINT ["python", "-m", "myapp"]
Output:
| Metric | Original | Fixed |
|---|---|---|
| Size | 1.8 GB | 250 MB |
| Non-root | ✗ | ✓ |
| SIGTERM safe | ✗ | ✓ |
| K8s healthcheck | ✗ | ✓ |
| Layer cache | Poor | Good |
SQL
Topic — SQL
SQL practice library
SQL
Topic — optimization
SQL optimization drills
Cheat sheet — Docker recipe list
-
FROM python:3.11-slim— reasonable base for Python DE. -
FROM apache/airflow:2.10.0-python3.11— extend official. -
FROM apache/spark:3.5.1-python3— for Spark. -
FROM jupyter/pyspark-notebook— for analyst notebooks. - Multi-stage builder pattern — fat builder, lean runtime.
-
RUN useradd -m -u 1000 appuser+USER appuser— non-root. -
COPY requirements.txt .before source — layer cache order. -
.dockerignore— exclude.git,.venv,__pycache__. -
HEALTHCHECK CMD ...— K8s liveness. -
ENTRYPOINT ["exec"]— signal-safe. tinifor zombie reap.-
EXPOSE 8080— documentation only. - ARG for build-time; ENV for runtime.
- Pin versions everywhere.
- Constraints file for Airflow.
- Never bake secrets.
docker build --build-arg VERSION=1.2 ..-
trivy image myco/app:1.2.3— CVE scan. -
cosign sign / verify— supply-chain. -
BuildKit —
DOCKER_BUILDKIT=1. -
--mount=type=cache— pip cache mount. -
divetool — layer size explorer.
Frequently asked questions
Why use multi-stage builds?
Multi-stage separates build dependencies (compilers, dev libraries, pip caches) from runtime. The final image is small (100-200 MB vs 800 MB+) — faster pulls, less attack surface, faster startup on K8s cold start, smaller Docker registry storage bills. The pattern: FROM ... AS builder for the fat image that installs everything; FROM ... AS runtime copies only the built artifacts. Every production Dockerfile should use it.
Should I use Alpine base images?
Only for services that don't need compiled Python packages. Alpine uses musl libc; some Python packages (like psycopg2-binary, numpy, pandas) require glibc and fail on Alpine with cryptic errors. Use python:3.11-slim (Debian slim) instead — good balance of size (150 MB) and compatibility. For truly minimal images, look at distroless (gcr.io/distroless/python3) but only after ensuring all deps work.
How do I bake in Airflow providers?
Extend the official apache/airflow:VERSION-pythonVERSION image. Use pip install --constraint URL with the constraints file matching your Airflow version + Python minor version (https://raw.githubusercontent.com/apache/airflow/constraints-2.10.0/constraints-3.11.txt). Constraints prevent provider dependency conflicts. Bake DAGs into image for stable, immutable deploys; mount from git-sync sidecar for GitOps deployments where DAGs update without image rebuild.
How do I handle secrets in Docker?
Never bake into image. Use environment variables at runtime (docker run -e SECRET=...), volume-mount secret files, or use K8s Secrets / AWS Secrets Manager / HashiCorp Vault. For Docker Compose, use .env files that aren't committed. For build-time secrets (private repo access), use BuildKit's --secret flag: --mount=type=secret,id=github_token,dst=/token. Layer history is permanent — anything in a RUN line is visible forever.
What's the difference between COPY and ADD?
Prefer COPY. ADD has extra features (auto-extract tarballs, URL fetching) that make behavior surprising and cache-unfriendly. Use COPY for files; separate RUN wget/curl for downloads. Rule — if you need to add a remote file, use RUN with wget + validation, not ADD.
How do I speed up rebuilds?
Order the Dockerfile so cache-invalidating instructions come late — dependencies before source code. Use .dockerignore to exclude bulky files (.git, .venv, node_modules). Enable BuildKit (DOCKER_BUILDKIT=1) for parallel stages and better caching. Use Docker layer cache mounts (--mount=type=cache,target=/root/.cache/pip) for pip / npm caches across builds. Use build args for common versions so cache stays warm. Typical result: source-only rebuild drops from minutes to seconds.
Practice on PipeCode
- Drill the SQL practice library → — 450+ DE-focused questions covering containerised workloads.
- Sharpen SQL optimization drills → for tuning queries that run inside containers.
- Layer SQL indexing drills → — schema design supports the workloads containers execute.
- Warm up with SQL join drills → — DAG task chains often join data across services.
- For the broader SQL interview surface, take the SQL for Data Engineering course →.
Pipecode.ai is Leetcode for Data Engineering — every `docker for data engineers` pattern above ships with hands-on practice rooms where you write a multi-stage dbt Dockerfile that drops image size from 2 GB to 250 MB, extend Airflow with providers via the constraints URL matching version + Python, ship a Spark driver image whose Java version exactly matches the cluster, run a JupyterLab image for analysts with mamba-installed Polars + DuckDB + Pydantic, and finally close the loop with `.dockerignore` + `HEALTHCHECK` + `ENTRYPOINT ["exec"]` + trivy in CI — the exact container fluency that senior DE platform interviews probe.





Top comments (0)