Arhitectura datelor și a sistemelor, de la zero Lecția 54 / 80

Containere: Docker pentru job-uri de date

Pattern-uri de Dockerfile, multi-stage builds, imaginea de bază potrivită, registry-uri de imagini. Fundamentele de containere pe care fiecare data engineer ar trebui să le știe.

Un job de date în 2026 aproape sigur rulează într-un container. Executor-ul Spark pe Kubernetes, task-ul Airflow care face ridicatul greu, materializarea unui asset Dagster, rularea de dbt dintr-un pipeline CI: toate sunt containere, pornite din imagini, programate de ceva. Chiar și platformele gestionate (Databricks, Snowpark de la Snowflake, AWS Glue) sunt containere dedesubt; doar nu vezi cusăturile.

Lecția asta e fundamentele de containere pe care fiecare data engineer ar trebui să le știe. Modelul mental, pattern-urile de Dockerfile care contează, lucrurile care merg prost pe drumul către o imagine mică și reproductibilă, registry-urile unde locuiesc imaginile și use case-urile specifice datelor care leagă totul.

Containere și ce nu sunt

Un container nu e o mașină virtuală. Distincția contează pentru că modelează atât costurile, cât și modurile de eșec.

O mașină virtuală are propriul kernel, propriul proces de boot, propria alocare de memorie, adesea gigabyte de disk. Hypervisor-ul pretinde că VM-ul e un calculator real. Pornirea unui VM ia minute; rularea a zece VM-uri pe o mașină costă de zece ori overhead-ul de kernel.

Un container împarte kernel-ul host-ului. E un proces (sau un grup mic de procese) care rulează în namespace-uri izolate, cu un filesystem care e al lui și o limită CPU/memorie impusă de cgroups. Pornirea unui container ia milisecunde. Rularea a cincizeci de containere pe o mașină e normală. Izolarea e mai slabă decât un VM (un exploit de kernel scapă din orice container; rămâne în VM), dar pentru use case-ul „vreau să rulez codul meu într-un mediu reproductibil”, containerele sunt primitiva potrivită.

Cele două concepte cheie de ținut separate sunt imaginea și containerul. Imaginea e artefactul imutabil: un filesystem stratificat cu un fișier de metadata care descrie cum se pornește un proces în interiorul lui. Containerul e o instanță care rulează a unei imagini, cu propriul layer scriptibil deasupra. Construiești imagini. Rulezi containere. Un container nou din aceeași imagine pornește în același state curat.

Docker e implementarea originală a acestor idei, dominantă din 2013. Open Container Initiative a standardizat formatele astfel încât imaginile construite cu Docker să ruleze oriunde se vorbește OCI: containerd, podman, CRI-O, fiecare serviciu de containere al fiecărui cloud. Când data engineers spun „imagine Docker” de obicei vor să spună „imagine OCI”; formatele sunt aceleași.

Dockerfile-ul

Un Dockerfile e rețeta pentru construirea unei imagini. Fiecare instrucțiune creează un layer în filesystem-ul imaginii. Layer-ele sunt cache-uite, deci dacă schimbi o instrucțiune, doar layer-ele de la acel punct în jos sunt reconstruite.

Un Dockerfile naiv pentru un job Python de date:

FROM python:3.13
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "job.py"]

Asta funcționează și e greșit în trei feluri diferite. Imaginea de bază e mai mare decât trebuie să fie. Ordonarea layer-elor înseamnă că o modificare de cod invalidează layer-ul de instalare a dependențelor. Nu există pinning de versiuni. O să le reparăm pe toate.

Best practices care își câștigă pâinea

Folosește imagini de bază specifice și minime. python:3.13-slim în loc de python:latest. Variantele slim renunță la unelte de build și la majoritatea pachetelor de sistem, păstrând doar ce e necesar pentru a rula un interpreter Python. Tag-ul latest e o țintă mobilă; pin-uiește o versiune specifică ca un rebuild peste luni să producă același artefact. Pentru imagini și mai mici există variante distroless sau Alpine, cu compromisul că unele wheel-uri Python nu au binare prebuilt pentru ele și pici înapoi pe build din sursă.

Costul alegerii unei imagini de bază mai mici se cumulează. O imagine de 50 MB se trage mai repede, se scanează pentru vulnerabilități mai repede, are o suprafață de atac mai mică și costă mai puțin în storage de registry. Pentru un job care rulează pe o sută de executor-i Spark, cei 50 MB sunt înmulțiți cu o sută de fiecare dată când cluster-ul pornește.

Ordonează layer-ele după frecvența modificărilor. Lucrurile care se schimbă rar (pachete OS, dependențe de sistem, interpreter Python) merg aproape de începutul Dockerfile-ului. Lucrurile care se schimbă des (codul tău) merg aproape de final. Cache-ul de build Docker invalidează un layer și tot ce e dedesubt când input-urile lui se schimbă. Dacă copiezi codul înainte de a instala dependențele, fiecare modificare de cod invalidează instalarea dependențelor, iar build-ul tău ia zece minute când ar putea lua zece secunde.

Ordonarea convențională pentru Python:

FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "job.py"]

Copierea requirements.txt e propriul ei pas; pip install e propriul lui pas. Copia de cod vine după. Editarea unui fișier Python reconstruiește doar ultimele două layer-e, în milisecunde.

Pin-uiește versiunile dependențelor. requirements.txt cu hash-uri, sau un lockfile uv, sau poetry.lock de la Poetry, sau pip-tools. Indiferent de unealtă, lockfile-ul e contractul: exact acest arbore de dependențe, de fiecare dată. Un requests ne-pin-uit în requirements-urile tale devine o versiune diferită luna viitoare, iar imaginea ta devine brusc diferită în feluri pe care nimeni nu le-a cerut.

Nu rula ca root. Default-ul în majoritatea imaginilor de bază e root, și e un footgun. Un proces compromis care rulează ca root într-un container are o șansă mult mai bună să scape sau să se miște lateral decât același proces care rulează ca utilizator neprivilegiat. Cele două linii în plus:

RUN useradd --create-home --shell /bin/bash appuser
USER appuser

aproape de finalul Dockerfile-ului mută procesul care rulează la un utilizator non-root. Pentru majoritatea job-urilor de date asta nu costă nimic și elimină o categorie de risc.

Folosește .dockerignore. Contextul de build e tot ce se află în directorul de unde ai rulat docker build; fără un .dockerignore, un director rătăcit node_modules sau .venv sau data/ poate balona contextul de build la gigabytes. Fișierul funcționează ca .gitignore. Adaugă intrările evidente: .git, __pycache__, *.pyc, .venv, node_modules, orice directoare locale de date.

Multi-stage builds

Cea mai mare îmbunătățire singulară pentru dimensiunea imaginii e multi-stage build-ul. Ideea: un singur Dockerfile definește două (sau mai multe) imagini, unde stage-urile ulterioare copiază artefacte de la cele anterioare, iar doar stage-ul final devine imaginea publicată.

Un pattern tipic pentru un job Python care are un pas de build (compilare extensii C, generare fișiere):

FROM python:3.13 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

FROM python:3.13-slim AS runtime
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/*.whl
COPY . .
RUN useradd --create-home appuser
USER appuser
CMD ["python", "job.py"]

Stage-ul builder are imaginea Python completă cu compilatoare. Construiește wheel-urile. Stage-ul runtime pornește proaspăt din python:3.13-slim și doar copiază wheel-urile. Imaginea finală nu are compilatoare, nu are unelte de build, niciun cache. Mai mică, mai rapidă de tras, mai puțin de scanat.

Multi-stage strălucește pentru limbajele cu pași de build expliciți: Go, Java, Rust, TypeScript care compilează în JavaScript. Pentru muncă pură de Python economiile sunt mai mici, dar reale, pentru că pip install din wheel-uri prebuilt în stage-ul de runtime e mai rapid și mai ușor decât instalarea completă.

Registry-uri de imagini

O imagine trebuie să trăiască undeva pentru ca cluster-ul să o tragă. Un registry e stratul de stocare și distribuție: Docker Hub, GitHub Container Registry, GitLab Registry, AWS ECR, Google Artifact Registry, Azure ACR, JFrog, Harbor.

Alegerea depinde mai ales de unde locuiește restul infrastructurii. Workload-urile AWS trag din ECR. GCP din Artifact Registry. Echipele centrate pe GitHub folosesc GitHub Container Registry, care se autentifică cu aceleași token-uri ca restul GitHub. Docker Hub e ok pentru imagini open-source, dar are limite de rate care lovesc rapid cluster-ele de producție; pin-uiește un registry cloud specific pentru producție.

Strategia de tag-uri contează mai mult decât realizează oamenii. Tag-ul latest e mutabil; nu-l folosi în producție. Folosește tag-uri imutabile: un git commit SHA, un semver, un build number. Cluster-ul trage un tag specific, cluster-ul rulează o imagine specifică, deploy-ul e reproductibil. Dacă vreodată ai nevoie de rollback, redeploy-uiești tag-ul anterior și știi exact ce rulează.

Semnează-ți imaginile și scanează-le. Cosign pentru semnare, Trivy sau Grype pentru scanare. Semnătura îi dă cluster-ului un fel de a refuza imagini care n-au venit din CI-ul tău; scanner-ul îți dă o listă de vulnerabilități cunoscute în dependențele tale. Ambele ar trebui să ruleze în CI înainte ca imaginea să fie împinsă.

Ce înseamnă asta pentru job-urile de date

Docker apare în data engineering în trei locuri principale.

Spark pe Kubernetes e exemplul cel mai curat. Driver-ul și executor-ii Spark rulează ca pod-uri, pornite dintr-o imagine custom care conține codul tău PySpark, dependențele tale și distribuția Spark. Imaginea e construită în CI, împinsă într-un registry, referențiată în CR-ul SparkApplication sau în comanda spark-submit. Aceeași imagine rulează în dev, în CI și în prod, cu configurări diferite. Imaginea e unitatea de deploy. Lecția 55 acoperă partea de Kubernetes în detaliu.

Operatorii Airflow care rulează cod de utilizator în containere sunt al doilea loc. KubernetesPodOperator e exemplul canonic: scheduler-ul Airflow îi cere lui Kubernetes să ruleze un pod dintr-o imagine specificată, cu argumente specificate, și așteaptă să se termine. DockerOperator face același lucru pentru Docker simplu. Beneficiul e izolarea completă: dependențele unui task nu interferează cu dependențele altuia. Costul e overhead-ul mic per task de pornire a unui container.

Dezvoltarea locală reproductibilă e al treilea loc, și e cel mai subutilizat. Aceeași imagine pe care o rulezi în producție poate rula pe laptopul unui developer, cu aceeași versiune de Python, aceleași dependențe, același cod. Bug-urile care apar doar în producție nu se mai întâmplă, pentru că mediul de dev e mediul de producție. O comandă docker run care montează directorul de cod și pornește job-ul local e genul de chestie mică ce schimbă felul în care lucrează o echipă.

Ciclul build-and-deploy leagă totul:

flowchart TB
    CODE[Dockerfile plus code] --> BUILD[docker build]
    BUILD --> SCAN[Scan and sign]
    SCAN --> PUSH[Push to registry]
    PUSH --> PULL[Cluster pulls image]
    PULL --> RUN[Job runs]
    RUN --> METRICS[Metrics and logs]

O mică checklist de Dockerfile

Înainte de a face merge unui Dockerfile, lucrurile la care să te uiți:

  • Imagine de bază specifică, slim, cu o versiune pin-uită.
  • Multi-stage build dacă există pași de compilare.
  • Layer-e ordonate de la modificări rare la modificări frecvente.
  • Lockfile de dependențe copiat înainte de cod, instalat înainte ca codul să fie copiat.
  • Utilizator non-root pentru procesul care rulează.
  • .dockerignore care exclude gunoiul evident.
  • Tag imutabil în numele publicat.
  • Health check sau entrypoint cunoscut bun.
  • Niciun secret băgat în imagine (variabile de mediu la runtime, nu build args).

Fiecare item e mic. Împreună sunt diferența dintre o imagine de patruzeci de megabyte care se trage într-o secundă și o imagine de două gigabyte care ia un minut, înmulțită cu oricâte copii rulează cluster-ul tău.

Ce înseamnă asta în practică

Containerele sunt unitatea de deploy. Dockerfile-ul e contractul dintre codul pe care-l scrii și runtime-ul care-l rulează. Un Dockerfile mic și bine format e una dintre cele mai pârghioase piese de cod din repo-ul unei echipe de date: fiecare job moștenește proprietățile lui.

Vestea bună e că pattern-urile sunt stabile. Un Dockerfile scris azi pentru un job Python va continua să funcționeze peste cinci ani; cloud-ul de dedesubt se va schimba, orchestratorul se va schimba, registry-urile se vor schimba, dar Dockerfile-ul și imaginea OCI rămân aceleași. Dintre toate straturile din stack-ul modern de date, ăsta e unul dintre cele mai plictisitoare, în sensul cel mai bun.

Lecția 55 construiește pe asta cu Kubernetes, runtime-ul pe care majoritatea echipelor își rulează acum containerele. Lecția 56 închide modulul cu povestea de deployment de la Stripe, care leagă CI, CD, IaC și containerele într-o singură imagine operațională.

Citații

  • Documentația Docker, „Best practices for writing Dockerfiles” (https://docs.docker.com/build/building/best-practices/, consultat 2026-05-01).
  • Specificațiile Open Container Initiative (https://opencontainers.org/, consultat 2026-05-01).
  • „Multi-stage builds” în documentația Docker (https://docs.docker.com/build/building/multi-stage/, consultat 2026-05-01).
  • Documentația Spark on Kubernetes (https://spark.apache.org/docs/latest/running-on-kubernetes.html, consultat 2026-05-01).
  • Proiectul Cosign (https://github.com/sigstore/cosign, consultat 2026-05-01).
Caută