Szybki start z Dockerem dla programistów: od pierwszego kontenera do compose

0
34
2.7/5 - (4 votes)

Nawigacja:

Po co programiście Docker i kiedy wcale go nie potrzebuje

Realne problemy, które Docker faktycznie rozwiązuje

Docker dla programistów nie jest kolejną modną zabawką, tylko odpowiedzią na kilka bardzo konkretnych problemów. Najczęściej pojawia się wtedy, gdy zespół ma dość sytuacji typu „u mnie działa”, konfliktów bibliotek systemowych i ręcznego odtwarzania środowiska na nowych maszynach.

Najbardziej typowe problemy, które Docker rozwiązuje w sposób przewidywalny:

  • Różne wersje narzędzi i bibliotek – na jednym projekcie Python 3.10 + określona wersja Postgresa, na innym legacy z Pythonem 3.7 i innym systemem operacyjnym. Na gołym systemie takie kombinacje szybko zamieniają się w bałagan. Kontenery izolują środowisko per projekt.
  • Powtarzalne środowisko developerskie – zamiast wiki z instrukcją „zainstaluj to, tamto, ustaw zmienne środowiskowe, potem może zadziała”, masz Dockerfile i docker-compose.yml. Nowy członek zespołu uruchamia kilka komend i dostaje prawie identyczne środowisko jak reszta.
  • Różnice między systemami – deweloperzy na Windows, macOS i Linuxie, a produkcja na Linuxie. Docker na desktopie opakowuje wszystko w spójny runtime linuksowy, więc znika cała masa drobnych różnic.
  • Symulacja środowiska produkcyjnego – backend w Node + Redis + Postgres + Nginx jako reverse proxy? Wszystko można szybko złożyć w docker compose i przetestować lokalnie nawet bez dostępu do zewnętrznej infrastruktury.
  • Łatwiejsza integracja z CI/CD – pipeline w GitLab CI, GitHub Actions czy Jenkinsie może uruchamiać testy w dokładnie tym samym obrazie, z którego korzysta się lokalnie. Mniej niespodzianek typu „na CI nie przechodzą testy, a lokalnie tak”.

W praktyce Docker jest szczególnie mocny tam, gdzie aplikacja składa się z kilku usług lub wymaga specyficznej kombinacji systemu, bibliotek, serwerów i narzędzi. W takich przypadkach czas poświęcony na przygotowanie dobrego obrazu i pliku compose szybko zwraca się w postaci stabilnego, przewidywalnego środowiska pracy.

Scenariusze, gdzie Docker tylko komplikuje życie

Istnieje jednak cała klasa zastosowań, gdzie Docker bardziej przeszkadza niż pomaga. Popularna rada „konteneryzuj wszystko” w tych scenariuszach zwyczajnie nie działa.

  • Małe, jednorazowe skrypty – szybki parser logów w Pythonie, małe narzędzie CLI w Go, które kompilujesz lokalnie raz na jakiś czas. Tu kontener dodaje warstwę złożoności: Dockerfile, build, image tag, potencjalne problemy z wolumenami, a zysku z tego niewiele.
  • Nauka języka od zera – jeśli dopiero zaczynasz z Pythonem, Node czy Go, dokładanie Dockera w pierwszym tygodniu nauki miesza w głowie. Zamiast skupić się na języku, trzeba ogarniać obrazy i kontenery. Dużo lepiej najpierw poczuć narzędzie „gołym” systemem, a dopiero później przenieść je do kontenera.
  • Proste aplikacje desktopowe – Docker świetnie radzi sobie z backendem i usługami serwerowymi, ale dla typowych aplikacji desktopowych (GUI) zwykle jest nadmiarem. Da się, ale wymaga hacków z X11, pulseaudio i innymi dodatkami.
  • Monolityczny projekt z prostym stackiem – jeśli masz jeden backend + baza danych, skonfigurowane dobrze narzędzia typu pyenv/nvm/virtualenv oraz przyzwoitą dokumentację, Docker może nie dawać aż takiej przewagi na starcie. Z czasem i tak go pewnie wprowadzisz, ale nie zawsze w pierwszym dniu.

Jeśli jedynym powodem użycia Dockera jest to, że „tak robią wszyscy” lub „na Kubernetesie tak działa”, opłaca się zatrzymać i policzyć koszt. Dla małych, prostych repozytoriów lepszy bywa klasyczny virtualenv lub nvm plus sensowny Makefile.

Docker jako runtime vs orkiestracja typu Kubernetes

Docker często jest wrzucany do jednego worka z Kubernetesem i „kontenerami w chmurze”, co niepotrzebnie straszy początkujących. W praktyce Docker to przede wszystkim runtime kontenerów i narzędzie developerskie, a Kubernetes to platforma orkiestracji wielu kontenerów na wielu maszynach.

Różnica w poziomie skomplikowania jest ogromna:

  • Docker: jeden host (lub Docker Desktop), komenda docker run, proste sieci, wolumeny, Docker Compose do kilku usług.
  • Kubernetes: klastry, schedulery, load balancery, deploymenty, configmapy, secret-y, ingress-y, autoscaling. Inna liga i inny moment życia projektu.

Dobry scenariusz: używać Dockera lokalnie do tworzenia i testowania obrazów oraz Docker Compose do uruchamiania kilku usług naraz. Dopiero gdy aplikacja rozrośnie się i trafi do środowiska, w którym realnie potrzebny jest Kubernetes, zaczyna mieć sens nauka orkiestracji. Próba nauczenia się jednocześnie podstaw Dockera i Kubernetesa rzadko kończy się dobrze.

Kiedy lepiej zostać przy prostszych narzędziach

Są projekty, którym w zupełności wystarczą klasyczne narzędzia zarządzania środowiskiem. Docker jest narzędziem, a nie religią – są sytuacje, kiedy jego użycie jest po prostu nieoptymalne.

  • virtualenv/pyenv dla Pythona – gdy projekt to pojedynczy backend i jeden rodzaj bazy danych, dobrze skonfigurowany virtualenv plus plik requirements.txt lub poetry.lock zapewniają wystarczającą przewidywalność bez kontenerów.
  • nvm/volta dla Node – kontrola wersji Node + globalnych paczek. Jeśli zespół ma ustalone wersje i sensowną dokumentację, dockerowe obrazy Node mogą być tylko dodatkową warstwą.
  • systemd i klasyczne serwisy – proste, małe usługi na jednym serwerze (np. drobne API w Go) łatwiej czasem uruchomić jako binarkę zarządzaną przez systemd niż budować całą otoczkę dockerową.

Dobrym filtrem jest pytanie: czy Docker rozwiązuje w tym konkretnym projekcie realny ból, którego doświadczam? Jeśli tak – warto wejść. Jeśli nie – nie ma obowiązku pakować wszystkich procesów w kontenery od pierwszego dnia.

Programista przy biurku koduje na dwóch monitorach w nowoczesnym biurze
Źródło: Pexels | Autor: Zayed Hossain

Szybka mapa pojęć: obrazy, kontenery, rejestry, warstwy

Obraz vs kontener – co właściwie się uruchamia

Docker operuje przede wszystkim na dwóch typach bytów: obrazach (images) i kontenerach (containers). To nie są abstrakcyjne pojęcia – bardzo konkretnie da się je powiązać z tym, co dzieje się w systemie.

  • Obraz – coś jak binarka lub snapshot systemu plików + metadane uruchomieniowe (domyślne polecenie, zmienne środowiskowe, porty). Jest niezmienny – raz zbudowany obraz nie zmienia się w czasie. Można go jedynie usunąć lub zastąpić nową wersją o innym tagu.
  • Kontener – działający proces (lub procesy) z przestrzenią nazw odizolowaną od systemu hosta, korzystający z systemu plików pochodzącego z obrazu + ewentualnych wolumenów. Kontener może być zatrzymany, usunięty, ponownie uruchomiony (jeśli nie został skasowany), ale nie zmienia samego obrazu.

Przy poleceniu:

docker run --name mynginx -p 8080:80 nginx:latest

dzieje się kilka rzeczy:

  1. Docker sprawdza, czy na lokalnej maszynie jest obraz nginx:latest. Jeśli nie – pobiera go z rejestru (zwykle Docker Hub).
  2. Tworzy nowy kontener na bazie tego obrazu, nadając mu nazwę mynginx.
  3. Uruchamia proces startowy (zdefiniowany w obrazie) w odizolowanym środowisku.
  4. Mapuje port 80 w kontenerze na port 8080 na hoście.

Obraz to jak „szablon aplikacji”, kontener to „działający egzemplarz”. Tego rozróżnienia trzeba pilnować, szczególnie gdy zaczyna się budować własne obrazy i współdzielić je w zespole.

Warstwy obrazów i cache – dlaczego kolejność instrukcji ma znaczenie

Obrazy Dockera są zbudowane z warstw (layers). Każda instrukcja w Dockerfile (typowo RUN, COPY, ADD) tworzy nową warstwę. Warstwy są niemutowalne i cache’owalne – jeśli ich zawartość się nie zmieniła, Docker nie będzie ich budował ponownie.

To oznacza dwie rzeczy:

  • Kolejność instrukcji w Dockerfile wpływa na szybkość kolejnych buildów.
  • Zmiana pojedynczego pliku skopiowanego w późnym etapie może unieważnić cache tylko dla ostatniej warstwy, zamiast dla całego obrazu.

Przykład (zły pod kątem cache):

FROM node:20

WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]

Każda modyfikacja w kodzie aplikacji unieważnia cache dla kroku COPY . ., a więc także dla RUN npm install. Tymczasem zależności rzadko zmieniają się tak często jak sam kod. Lepszy wariant:

FROM node:20

WORKDIR /app
COPY package*.json ./
RUN npm install

COPY . .
CMD ["npm", "start"]

Tu zmiana w kodzie nie rusza warstwy, w której wykonywane jest npm install. Buildy są zauważalnie szybsze, szczególnie przy większych projektach.

Rejestry obrazów: publiczne i prywatne

Obrazy Dockera trzeba skądś pobrać i gdzieś trzymać. Służą do tego rejestry (registries). Najpopularniejsze:

  • Docker Hub – domyślny, publiczny rejestr. Wiele oficjalnych obrazów (nginx, redis, postgres, node, python).
  • GitHub Container Registry (GHCR) – część GitHub; wygodny, jeśli i tak projekt jest w repozytorium na GitHubie.
  • GitLab Container Registry – wbudowany w GitLaba, naturalny wybór dla CI/CD na tej platformie.
  • Rejestry chmurowe – Amazon ECR, Google Artifact Registry, Azure Container Registry – przydatne przy wdrożeniach w danej chmurze.

Prywatnego rejestru używa się w dwóch sytuacjach:

  • Gdy obraz zawiera kod właścicielski lub wewnętrzne narzędzia, których nie chcesz wypuszczać na publiczny Docker Hub.
  • Gdy potrzebujesz lepszej kontroli nad ciągłością dostępu i politykami (np. retention policy, uprawnienia per projekt, scany bezpieczeństwa).

Sam dockerowy klient przyjmuje adres obrazu zwykle w formie registry/url/nazwa:tag. Jeśli rejestr nie jest podany, użyty zostanie domyślny (Docker Hub).

Tagowanie obrazów i pułapka `latest`

Każdy obraz ma tag, domyślnie latest, jeśli nie zostanie podany. Tag to tylko etykieta – może wskazywać na dowolną wersję obrazu. Brak świadomej polityki tagowania szybko mści się przy wdrożeniach.

Najczęstsze podejścia:

  • Tag latest – wygodny do pracy lokalnej, ale nieprzewidywalny na produkcji. Dziś latest może znaczyć v1.2.3, jutro v1.3.0. Dobre do prototypowania, złe do poważnych wdrożeń.
  • Semver1.2.3, czasem dodatkowo 1.2 i 1. Przejrzyste i kompatybilne z innymi narzędziami.
  • Tagi powiązane z gitemapp:main-<githash>, app:<tag-gita>. Dają precyzyjne odwzorowanie kodu na obraz.

Bezpieczniejszy wzorzec: lokalnie można korzystać z latest lub dev, natomiast w środowiskach testowych i produkcyjnych używać konkretnych tagów (np. semver lub githash). Unika się w ten sposób sytuacji, w której docker pull myapp:latest ściąga inny kod niż dzień wcześniej.

Pierwszy kontakt: instalacja Dockera i podstawowe komendy w praktyce

Docker Desktop vs natywny silnik Dockera na Linuxie

Instalacja Dockera zależy od systemu operacyjnego. Na Linuxie Docker jest „obywatelem pierwszej klasy”: demon dockera działa bezpośrednio w systemie. Na macOS i Windowsie Docker musi korzystać z wirtualizacji, bo sam Docker Engine wymaga kernela linuksowego.

  • Linux – instalujesz pakiety docker-ce / docker.io (w zależności od dystrybucji). Masz pełny kontroler cgroups, namespacy, overlayfs itd. Zwykle najlepsza wydajność I/O i najniższe opóźnienia.
  • macOS i Windows – najczęściej przez Docker Desktop, który dostarcza lekką maszynę wirtualną z Linuxem i w środku Docker Engine. Interfejs CLI (komenda docker) łączy się z tym daemonem w VM.

Instalacja krok po kroku i pierwszy testowy kontener

Niezależnie od systemu, po instalacji trzeba sprawdzić, czy wszystko wstaje poprawnie i czy użytkownik ma uprawnienia do korzystania z Dockera bez sudo (na Linuxie).

Linux – konfiguracja użytkownika i test

Po instalacji pakietów dockera, typowa sekwencja wygląda tak:

sudo groupadd docker   # jeśli jeszcze nie istnieje
sudo usermod -aG docker $USER

Po wylogowaniu i ponownym zalogowaniu (lub restarcie sesji) można spróbować:

docker run --rm hello-world

Jeśli nie ma komunikatu o braku uprawnień i zobaczysz komunikat z kontenera hello-world, podstawowa konfiguracja działa. Popularna rada „zawsze używaj sudo przy dockerze” bywa wygodna na serwerze administracyjnym, ale na maszynie deweloperskiej szybko zamienia się w źródło drobnych frustracji (problemy z uprawnieniami do plików tworzonych przez kontener, dziwne własności katalogów w bind mountach itp.). Grupa docker jest tu bardziej pragmatycznym kompromisem.

macOS / Windows – Docker Desktop i podstawowe ustawienia

Po instalacji Docker Desktop zwykle uruchamia się sam przy starcie systemu. Dwie rzeczy warto zweryfikować od razu:

  • Przydział zasobów – w ustawieniach można ustawić limit CPU, RAM i dysku dla VM z Dockerem. Domyślne wartości są często zbyt wysokie (żrą baterię) albo zbyt niskie (kompilacja większych projektów trwa wieczność). Rozsądny start to 4 CPU i 4–6 GB RAM dla typowej pracy backendowej.
  • Udostępnione katalogi – Docker Desktop na macOS/Windows musi wiedzieć, które katalogi hosta są dostępne z poziomu kontenerów. Jeśli aplikacja leży np. w D:Projects, a nie ustawisz tego w „File sharing”, bind mounty będą sprawiały wrażenie, że „nie działają”.

Testowy kontener:

docker run --rm -it alpine sh

Jeśli zobaczysz prompt / #, jesteś w lekkim kontenerze z Alpine Linux. Warto chwilę się pobawić:

/ # uname -a
/ # ls
/ # ps aux

To szybki sposób, żeby poczuć, że „w środku” naprawdę działa zwykły Linux, a nie jakaś magiczna kapsułka.

Najczęstsze problemy po instalacji i ich krótkie obejścia

Pierwsze podejście do Dockera rzadko obywa się bez potknięć. Zamiast godzin debugowania warto znać kilka typowych pułapek.

  • Błąd „permission denied” przy docker run na Linuxie – najczęściej efekt braku użytkownika w grupie docker albo tego, że nie przeładowano sesji po dodaniu. Sprawdzenie: id powinno wypisać grupę docker.
  • Bardzo wolne I/O na macOS/Windows – intensywny odczyt/zapis w bind mountach na systemie plików hosta potrafi być kilkukrotnie wolniejszy niż wewnątrz VM. Jeśli testy działają koszmarnie wolno, przenieś część pracy do wolumenów Dockera (o tym dalej) lub rozważ Linuxa w VM do ciężkiej pracy.
  • Zajęty port – przy -p 8080:80 możesz zobaczyć błąd o zajętym porcie. Niektóre narzędzia GUI po cichu rezerwują standardowe porty (np. 80, 443). Szybka diagnoza: lsof -i:8080 / netstat zależnie od systemu.

Podstawowe komendy Dockera w codziennej pracy

Choć dokumentacja pokazuje dziesiątki subkomend, w codziennej pracy programisty zwykle powtarza się kilka z nich. Lepsza jest biegłość w tych kilku niż encyklopedyczna znajomość całego CLI.

Praca z obrazami: pull, images, rmi

# pobranie obrazu
docker pull nginx:1.25

# lista lokalnych obrazów
docker images

# usunięcie obrazu (jeśli nie jest używany przez kontenery)
docker rmi nginx:1.25

Popularna rada „zawsze używaj docker rmi do sprzątania” nie sprawdza się przy większej liczbie projektów – zaczynasz usuwać obrazy potrzebne innym repozytoriom i za każdym razem zaciągać je na nowo z sieci. Lepszy nawyk to okresowe (raz na tydzień) sprzątanie z głową, przy użyciu docker image prune lub docker system prune z obejrzeniem podsumowania przed potwierdzeniem.

Praca z kontenerami: run, ps, logs, exec, rm

# uruchomienie jednorazowego kontenera, który usuwa się po wyjściu
docker run --rm -it alpine sh

# lista działających kontenerów
docker ps

# lista wszystkich kontenerów (także zatrzymanych)
docker ps -a

# logi z konkretnego kontenera
docker logs mynginx
docker logs -f mynginx   # tail -f

# wejście do działającego kontenera
docker exec -it mynginx sh

# usunięcie zatrzymanego kontenera
docker rm mynginx

Typowy antywzorzec na starcie: traktowanie kontenera jak maszyny wirtualnej, w którą loguje się i „naprawia” środowisko ręcznie. Działa lokalnie, ale całkowicie rozwala powtarzalność. Jeśli trzeba „dozainstalować pakiet” w środku, to raczej sygnał, że powinien powstać nowy obraz (Dockerfile) z tym pakietem, a nie że trzeba się do kontenera w SSH-ować.

Programista pracujący nad kodem Dockera przy dwóch monitorach w biurze
Źródło: Pexels | Autor: Ofspace LLC, Culture

Od zwykłego polecenia do sensownego docker run

Minimalny przykład: zastąpienie lokalnej instalacji bazy danych

Najprostszy, ale bardzo praktyczny przypadek użycia: baza danych w kontenerze zamiast lokalnej instalacji. Na przykład PostgreSQL:

docker run --name dev-postgres 
  -e POSTGRES_PASSWORD=devpass 
  -p 5432:5432 
  -d postgres:16

Co tu się kryje pod flagami:

  • --name dev-postgres – przyjazna nazwa, zamiast losowego ID; później można używać jej w logs, stop, exec.
  • -e POSTGRES_PASSWORD=devpass – zmienna środowiskowa przekazana do kontenera (tu: hasło użytkownika postgres).
  • -p 5432:5432 – mapowanie portu hosta (lewa strona) na port w kontenerze (prawa strona).
  • -d – uruchomienie w tle (detached).

W efekcie masz działającego Postgresa pod localhost:5432, bez instalacji serwera bazy na hoście. Popularna rada brzmi: „Po prostu użyj docker run postgres i już”. Traci się wtedy kontrolę nad kilkoma istotnymi rzeczami: brakiem trwałości danych, brakiem jasno określonych haseł i wersji obrazu. Sensowny docker run to taki, który jasno opisuje, co chcesz osiągnąć (porty, konfiguracja, wersja).

Flagi docker run, które naprawdę się przydają

Z setek opcji docker run w praktyce powtarza się kilka. Niektóre są nadużywane, inne prawie nieużywane, a szkoda.

  • -p hostPort:containerPort – ekspozycja portu na zewnątrz. Warto świadomie decydować, czy port na hoście ma być taki sam jak w kontenerze (łatwiej pamiętać), czy inny (gdy wiele usług używa tego samego portu w środku).
  • -v /ścieżka/na/hoście:/ścieżka/w/kontenerzebind mount, czyli podpięcie katalogu z hosta. Bardzo wygodne w devie, ale powoli działa na macOS/Windows przy dużej liczbie małych plików.
  • -e NAZWA=wartość lub --env-file – wstrzykiwanie konfiguracji. Lepiej używać plików .env niż wpisywać hasła w historii terminala.
  • --rm – automatyczne usunięcie kontenera po zakończeniu. Dobre do jednorazowych zadań: migracje, skrypty pomocnicze.
  • --name – spójne nazewnictwo ułatwia późniejsze docker exec i docker logs.

Za to dwie opcje, które często pojawiają się w poradnikach, bywają postrachem działów bezpieczeństwa:

  • --privileged – ogromny zestaw uprawnień, zwykle niepotrzebny. Jeśli coś działa tylko z --privileged, to raczej problem z obrazem lub projektem, nie z Dockerem.
  • -v /:/host (montowanie całego systemu plików hosta) – wygodne do debugowania, ale bardzo łatwo wtedy jednym poleceniem usunąć/zepsuć pliki hosta z poziomu kontenera.

Parametryzacja komend: aliasy i małe skrypty

Ręczne wpisywanie długiego docker run za każdym razem szybko męczy. Najprostsze podejścia do uproszczenia:

  • Alias powłoki – np. w ~/.bashrc lub ~/.zshrc:
    alias pgdev='docker run --name dev-postgres 
      -e POSTGRES_PASSWORD=devpass 
      -p 5432:5432 
      -d postgres:16'
  • Skrypt ./dev.sh w repozytorium – bardziej przenośne niż aliasy, bo działają tak samo na każdej maszynie programisty. Mogą zawierać kilka wariantów: start/stop/logi.

Popularna rada, by „od razu wrzucić wszystko do docker-compose”, miewa sens przy większej liczbie usług. Dla pojedynczej bazy alias czy skrypt będą prostsze w utrzymaniu, a dopiero gdy rośnie liczba komponentów, warto wejść w zdefiniowany plik compose.

Pierwszy Dockerfile: pakowanie prostej aplikacji krok po kroku

Prosty backend w Node / Pythonie jako przykład

Zamiast wymyślonych przykładów, weźmy coś, co pojawia się na co dzień: prosty serwis HTTP.

Załóżmy repozytorium Node:

.
├── package.json
├── package-lock.json
└── src
    └── index.js

Podstawowy Dockerfile może wyglądać tak:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3000

CMD ["node", "src/index.js"]

Krok po kroku:

  1. FROM node:20-alpine – baza z Node w wersji 20 na lekkim Alpine. Daje sensowny kompromis między wielkością a kompatybilnością.
  2. WORKDIR /app – ustawienie katalogu roboczego. Wszystkie kolejne instrukcje bazują na tym katalogu.
  3. COPY package*.json ./ – kopiowanie plików z zależnościami.
  4. RUN npm ci --only=production – instalacja zależności. npm ci jest deterministyczne (bazuje na lockfile).
  5. COPY . . – reszta kodu.
  6. EXPOSE 3000 – informacja, jaki port nasłuchuje aplikacja (dla ludzi i narzędzi orkiestracyjnych; samo w sobie nie publikuje portu).
  7. CMD ["node", "src/index.js"] – domyślne polecenie startowe.

Build i uruchomienie:

docker build -t myapp:dev .
docker run --rm -p 3000:3000 myapp:dev

Teraz aplikacja jest dostępna pod http://localhost:3000. Jeśli zmienisz kod, wystarczy przebudować obraz i ponownie uruchomić kontener.

Typowe różnice między trybem dev a produkcyjnym

Popularna rada brzmi: „używaj tego samego obrazu w dev i na produkcji”. To działa, o ile umiesz pogodzić sprzeczne potrzeby: w devie chcesz szybkich iteracji i narzędzi typu debugger, a na produkcji małego, szczelnego obrazu.

Przykładowe rozróżnienie:

  • Dev – obraz z narzędziami debugowania, hot-reloadem, często z bind mountem kodu z hosta. Kontener jest bardziej „elastyczny”, ale mniej reprezentatywny wydajnościowo.
  • Prod – zbudowany kod (np. dist/) w lekkim obrazie typu node:20-alpine lub nawet distroless. Bez dev-dependencies, bez hot-reloadu, z jasno ustawionymi zmiennymi środowiskowymi i parametrami zasobów.

Rozsądny kompromis: jeden Dockerfile z build argami lub targetami stage’y (multi-stage build), które w zależności od celu budują inny profil obrazu.

Prosty Dockerfile dla Pythona

Dla aplikacji w Pythonie struktura może wyglądać podobnie:

Obraz Pythona krok po kroku

Załóżmy prosty serwis HTTP w Pythonie (np. Flask / FastAPI):

.
├── app
│   ├── __init__.py
│   └── main.py
├── requirements.txt
└── pyproject.toml   # opcjonalnie

Najprostszy, ale już w miarę sensowny Dockerfile może wyglądać tak:

FROM python:3.12-slim

# nie buforuj outputu (ważne przy logach w kontenerach)
ENV PYTHONUNBUFFERED=1 
    PYTHONDONTWRITEBYTECODE=1

WORKDIR /app

# systemowe zależności (opcjonalnie, tu jako przykład)
RUN apt-get update && apt-get install -y --no-install-recommends 
      build-essential 
  && rm -rf /var/lib/apt/lists/*

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

COPY . .

EXPOSE 8000

CMD ["python", "-m", "app.main"]

Kilka detali, które często są pomijane:

  • PYTHONUNBUFFERED=1 – logi od razu lecą na stdout/stderr, bez czekania na bufor. Bez tego w logach z Dockera pojawiają się dziwne „grupy” wpisów.
  • PYTHONDONTWRITEBYTECODE=1 – brak .pyc w kontenerze zmniejsza „śmieci”, a różnica wydajności jest marginalna dla typowych mikroserwisów.
  • --no-cache-dir w pip install – mniej danych w obrazie, mniejszy transfer przez sieć.

Budowanie i uruchomienie:

docker build -t mypyapp:dev .
docker run --rm -p 8000:8000 mypyapp:dev

Popularna rada, by „zawsze startować aplikację w Pythonie przez Gunicorna/Uvicorna”, działa na produkcji. W devie proste python -m app.main ma tę zaletę, że łatwiej debugować błędy bez dodatkowej warstwy.

Multi-stage build: jeden Dockerfile, różne warianty

Kuszące jest trzymanie dwóch plików: Dockerfile.dev i Dockerfile.prod. Szybko jednak rozjeżdżają się one funkcjonalnie. Multi-stage rozwiązuje ten problem w jednym pliku.

# stage build
FROM node:20-alpine AS build

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build       # np. do katalogu dist/

# stage runtime
FROM node:20-alpine AS runtime

WORKDIR /app
ENV NODE_ENV=production

# tylko artefakty z builda
COPY --from=build /app/dist ./dist
COPY package*.json ./
RUN npm ci --only=production

EXPOSE 3000
CMD ["node", "dist/index.js"]

Tu widać kilka rzeczy naraz:

  • kompilacja/budowanie dzieje się w osobnym etapie, który nie ląduje w finalnym obrazie,
  • finalny obraz ma tylko skompilowany kod + produkcyjne zależności,
  • oba etapy żyją w jednym pliku, więc trudniej o „rozjechanie” się konfiguracji.

Do tego można dołożyć wariant dev, który bazuje na etapie build:

# dev image
FROM build AS dev

ENV NODE_ENV=development
CMD ["npm", "run", "dev"]

Budowanie konkretnych stage’y:

# obraz do devu (ze stage'a dev)
docker build --target dev -t myapp:dev .

# obraz prod
docker build --target runtime -t myapp:prod .

Rada „zawsze używaj multi-stage” ma sens przy projektach z kompilacją (TypeScript, Go, Java). Przy małym narzędziu CLI w Pythonie czy Node bez bundlera, osobny stage potrafi być tylko niepotrzebnym hałasem.

Programista pracujący nad kodem Dockera przy dwóch monitorach
Źródło: Pexels | Autor: Zayed Hossain

Dobre praktyki w Dockerfile i typowe antywzorce

Minimalny, ale nie za wszelką cenę mały

Od kilku lat w modzie jest gonienie za minimalnym rozmiarem obrazu. Czasem to realna korzyść (krótszy deploy, mniej transferu w CI), ale bywa też pułapką.

Przykład ekstremum:

FROM scratch
COPY my-static-binary /app
ENTRYPOINT ["/app"]

Dla prostego binarnego serwisu w Go – świetnie. Dla Pythona czy Node z natywnymi zależnościami – walka o każdy megabajt zwykle kończy się stratą czasu na debugowanie brakujących bibliotek systemowych.

  • Jeśli obraz ma iść na edge/IoT, gdzie liczy się każdy megabajt – warto się spiąć.
  • Jeśli to wewnętrzny serwis w firmie z normalną siecią – sensowniej postawić na przejrzystość Dockerfile i przewidywalne zachowanie.

Rozsądny punkt startu:

  • python:3.12-slim zamiast python:3.12,
  • node:20-alpine zamiast node:20,
  • obrazy dystrybucji typu debian:bookworm-slim zamiast pełnego debian.

Cache warstw: kolejność instrukcji ma znaczenie

Docker layer cache to jeden z głównych powodów, dla których praca z kontenerami jest wygodna. Antywzorzec: COPY . . na początku pliku, zaraz po FROM. Wtedy każda zmiana w jednym pliku kasuje cache wszystkich kolejnych warstw.

Lepsza kolejność przy typowej aplikacji:

  1. instrukcje rzadko zmieniane (systemowe pakiety, narzędzia),
  2. pliki z zależnościami (package*.json, requirements.txt),
  3. instalacja zależności,
  4. dopiero na końcu COPY . . z resztą kodu.
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

# dopiero teraz kod
COPY . .

CMD ["npm", "start"]

Przy takim układzie zmiana pojedynczego pliku w src/ nie powoduje ponownej instalacji wszystkich modułów NPM, bo warstwa z npm ci pozostaje w cache.

RUN i sprzątanie po sobie

Częsty błąd: wiele instrukcji RUN, z których każda instaluje pakiet, czyści cache, poprawia konfigurację. Każda taka instrukcja to nowa warstwa, więc „śmieci” systemowe zostają w historii obrazu, nawet jeśli w kolejnym RUN zostaną usunięte.

Typowy antywzorzec:

RUN apt-get update
RUN apt-get install -y build-essential
RUN rm -rf /var/lib/apt/lists/*

Lepsze podejście – łączenie w logiczne kroki:

RUN apt-get update && apt-get install -y --no-install-recommends 
      build-essential 
  && rm -rf /var/lib/apt/lists/*

Tu jest jeden legitny wyjątek: oddzielne RUN dla ciężkich kroków, gdzie liczy się osobny cache (np. npm ci vs. npm run build). Łączenie „na siłę” wszystkiego w jedną instrukcję nie zawsze skraca build; czasem po prostu utrudnia czytanie i diagnozowanie błędów.

Użytkownik nie-root w kontenerze

Wiele oficjalnych obrazów domyślnie działa jako root. Lokalnie „działa”, więc często nikt tego nie rusza. Na serwerach bywa to jednak problemem bezpieczeństwa, szczególnie przy „ucieczce” z kontenera.

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

# utworzenie użytkownika bez uprawnień roota
RUN addgroup -S nodegroup && adduser -S nodeuser -G nodegroup

USER nodeuser

EXPOSE 3000
CMD ["node", "src/index.js"]

Dwie rzeczy do dopilnowania:

  • katalog roboczy (np. /app) musi być czytelny/zapisywalny dla nowego użytkownika,
  • przy bind mountach (np. z hosta) trzeba pilnować uprawnień i UID/GID – w przeciwnym razie w logach pojawi się festiwal błędów typu „Permission denied”.

Rada „zawsze używaj nierootowego użytkownika” potrafi boleć w devie przy montowaniu kodu z hosta, szczególnie na macOS/Windows. Część zespołów robi kompromis: USER root w obrazie dev, a nierootowy użytkownik w obrazie prod.

Hardcodowane sekrety w obrazie

Umieszczanie haseł/API keyów w Dockerfile czy wbudowywanie ich na stałe w obraz to prosta droga do incydentów. A mimo to wciąż widać coś takiego:

ENV DB_PASSWORD=supersekret

Lepszy model:

  • wartości domyślne tylko dla zmiennych nienależących do sekretnych (np. ENV APP_ENV=production),
  • sekrety wstrzykiwane przez -e, --env-file lub mechanizmy orkiestratora (Kubernetes Secrets, Docker Swarm secrets),
  • osobny plik .env ignorowany przez .gitignore.

Przy okazji: robienie obrazu, w którym .env jest kopiowane do środka, zwykle kończy się wysłaniem sekretów do rejestru (Docker Hub, ECR, GCR). Jeśli plik .env musi być obecny w kontenerze, lepiej montować go w runtime przez -v niż kopiować w Dockerfile.

„Debugowanie” obrazu za pomocą ręcznych poprawek

Często spotykany workflow: ktoś buduje obraz, odpala kontener, wchodzi do środka przez docker exec, doinstalowuje paczki, zmienia konfigurację, a potem próbuje „zapisać” taki stan jako nowy obraz. Teoretycznie się da (docker commit), praktycznie – kompletnie niepowtarzalny bałagan.

Bardziej przewidywalna metoda:

  1. odpal kontener z --entrypoint sh i sprawdź ręcznie, co jest potrzebne,
  2. spisz zmiany jako kolejne instrukcje RUN w Dockerfile,
  3. przebuduj obraz i usuń stary kontener.
# tymczasowe środowisko do diagnozy
docker run -it --rm --entrypoint sh myapp:dev

# po ustaleniu, czego brakuje, dopisujesz do Dockerfile:
# RUN apk add --no-cache curl

docker commit zostaw na eksperymenty w środowisku, którego nikomu nie pokazujesz – nie jako element oficjalnego procesu.

Praca z plikami i danymi: wolumeny, bind mounts i migracje

Ephemeralność kontenera kontra trwałość danych

Kontener jest z definicji jednorazowy – można go wyłączyć i wyrzucić bez sentymentu. To wygodne, dopóki nie dotyczy danych. Pliki wewnątrz systemu plików kontenera znikają przy jego usunięciu. Stąd trzy typowe strategie:

  • bind mount – katalog hosta podpięty do kontenera,
  • wolumen nazwany Dockera,
  • zdalne systemy plików (NFS, EFS, CSI w Kubernetes) – osobny temat.

Popularna rada „do devu używaj bind mountów, do produkcji wolumenów” ma spory sens, ale tylko jeśli wiemy, co które rozwiązanie robi w praktyce.

Bind mount: katalog z hosta w kontenerze

Bind mount jest najbardziej intuicyjny: wskazuje się konkretną ścieżkę na hoście, która ma być widoczna w kontenerze.

# Node z kodem z hosta
docker run --rm -it 
  -v "$PWD":/app 
  -w /app 
  -p 3000:3000 
  node:20-alpine 
  sh -c "npm install && npm run dev"

Zalety w devie są oczywiste:

  • edytujesz pliki w swoim IDE, kontener widzi je od razu,
  • hot-reload działa normalnie, tak jak przy lokalnej instalacji.

Minusy zaczynają się przy większych projektach i macOS/Windows:

  • duże projekty z tysiącami małych plików działają dużo wolniej niż na natywnym FS,
  • różnice w uprawnieniach / właścicielach plików utrudniają działanie narzędzi.

Na Linuksie bind mounty są zwykle bezproblemowe. Na laptopach z macOS, przy stacku typu Node/React, często kończy się to dziwnymi lagami przy npm install czy webpack-dev-server. W takich sytuacjach paradoksalnie lepiej zrezygnować z mounta dla całego projektu i używać klasycznego builda obrazu, a tylko konfiguracje trzymać na zewnątrz.

Wolumeny Dockera: katalog zarządzany przez silnik

Wolumen to katalog, którym zarządza sam Docker. Nie musisz wskazywać ścieżki na hoście, wystarczy nazwa.

# tworzenie wolumenu
docker volume create pgdata

# użycie w Postgresie
docker run --name dev-postgres 
  -e POSTGRES_PASSWORD=devpass 
  -p 5432:5432 
  -v pgdata:/var/lib/postgresql/data 
  -d postgres:16

Co to zmienia w porównaniu z bind mountem?

  • Docker sam wybiera lokalizację danych (np. /var/lib/docker/volumes/...),
  • łatwo przenieść wolumen (backup/restore) niezależnie od struktury katalogów projektu,
  • nie mieszasz danych aplikacji z plikami konfiguracyjnymi / kodem na hoście.
  • Najczęściej zadawane pytania (FAQ)

    Czy jako programista naprawdę muszę uczyć się Dockera?

    Nie, Docker nie jest obowiązkowy w każdej pracy programisty. Jest kluczowy tam, gdzie projekty mają złożone środowiska (kilka usług, specyficzne wersje baz danych, osobne stacki dla różnych projektów) i zespół ma dość sytuacji typu „u mnie działa”. W takich warunkach Docker szybko zwraca się w postaci powtarzalnego środowiska i mniejszej liczby dziwnych błędów.

    Jeśli jednak tworzysz małe skrypty, proste API w jednym języku albo dopiero uczysz się programowania, Docker łatwo staje się zbędnym obciążeniem. Wtedy rozsądniej zaczynać od prostszych narzędzi (virtualenv, nvm, systemd), a do Dockera wejść dopiero, gdy pojawia się realny ból związany ze środowiskiem.

    Kiedy Docker ma sens w projekcie developerskim, a kiedy tylko przeszkadza?

    Docker ma sens, gdy:

  • musisz utrzymywać kilka projektów z różnymi wersjami języka i bibliotek,
  • zespół pracuje na różnych systemach (Windows, macOS, Linux), a produkcja stoi na Linuksie,
  • lokalnie chcesz odtworzyć mini-„produkcję” (np. backend + Postgres + Redis + Nginx),
  • integrujesz projekt z CI/CD i chcesz, by testy leciały w tym samym obrazie co lokalnie.

Przeszkadza, gdy projekt jest mały i jednowątkowy: jednorazowe skrypty, nauka języka od zera, proste monolity z jednym backendem i jedną bazą. Wtedy szybciej i taniej ogarniesz środowisko klasycznym virtualenv/pyenv lub nvm plus dobra instrukcja instalacji.

Czy do nauki Pythona / Node / Go powinienem od razu używać Dockera?

Na początek – raczej nie. Łączenie nauki języka z nauką Dockera zwykle kończy się tym, że mieszają się warstwy problemu: nie wiesz, czy błąd wynika z kodu, konfiguracji środowiska czy obrazu. Prościej najpierw zrozumieć sam język i jego standardowe narzędzia (virtualenv, poetry, npm, go mod), a dopiero później zwinąć to w kontener.

Docker zaczyna pomagać, gdy:

  • masz już ogarnięty podstawowy tooling w danym języku,
  • musisz współdzielić środowisko z zespołem lub odtwarzać je na wielu maszynach,
  • chcesz lokalnie testować aplikację w konfiguracji zbliżonej do produkcji (np. kilka usług).

Wtedy przeniesienie projektu do Dockera ma sens i zwykle od razu widać korzyści.

Jaka jest różnica między Dockerem a Kubernetesem dla programisty?

Docker to runtime kontenerów i narzędzie developerskie: budujesz obraz, uruchamiasz kontener, spinasz kilka usług Docker Compose. Wszystko dzieje się na jednym hoście (lub w ramach Docker Desktop), więc krzywa nauki jest relatywnie łagodna.

Kubernetes to platforma do orkiestracji wielu kontenerów na wielu maszynach: klastry, deploymenty, autoscaling, load balancery, ingressy, configmapy. Dla większości projektów na etapie „pierwszej wersji” jest to armatą na muchę. Zdrowsze podejście: najpierw dobrze opanować Dockera lokalnie, a dopiero gdy projekt realnie potrzebuje klastra (skalowanie, HA, rozproszona infrastruktura), wchodzić w Kubernetes.

Czym się różni obraz Dockera od kontenera w praktyce?

Obraz (image) to niezmienny „szablon” – snapshot systemu plików plus metadane uruchomieniowe. Raz zbudowany obraz się nie zmienia; możesz zbudować nową wersję pod innym tagiem, ale starej nie modyfikujesz. W repozytorium zwykle wersjonujesz Dockerfile, a obrazy tylko publikujesz w rejestrze.

Kontener to działający (lub zatrzymany) proces uruchomiony na bazie obrazu. Ma własną, odizolowaną przestrzeń nazw i system plików wyprowadzony z obrazu plus ewentualne wolumeny. Kontener możesz startować, zatrzymywać i usuwać – nie zmienia to samego obrazu, tylko jego instancję. To dlatego „poprawianie” środowiska ręcznie wewnątrz kontenera jest pułapką: zmiany znikną przy kolejnym uruchomieniu nowego kontenera z tego samego obrazu.

Kiedy lepszy będzie virtualenv / nvm zamiast Dockera?

Klasyczne narzędzia zarządzania wersjami sprawdzają się świetnie, gdy projekt jest prosty: jeden backend, jedna baza, brak egzotycznych zależności systemowych. Przykładowo, API w Pythonie + Postgres na lokalnym Dockerze (tylko do bazy), a sam kod w virtualenv – to często sensowny kompromis.

virtualenv/pyenv lub nvm/volta wygrywają, gdy:

  • nie potrzebujesz od razu odtwarzać pełnego „produkcji” na lokalnym devie,
  • zespół pracuje na podobnych systemach i ustalone wersje narzędzi są łatwe do odtworzenia,
  • ważniejsza jest szybkość iteracji niż pełna izolacja środowiska.

Docker zaczyna wygrywać dopiero wtedy, gdy kombinacja wersji i usług jest na tyle złożona, że dokumentacja w wiki przestaje wystarczać.

Dlaczego kolejność instrukcji w Dockerfile ma znaczenie przy buildzie?

Każda instrukcja w Dockerfile (RUN, COPY, ADD) tworzy nową warstwę obrazu. Docker agresywnie cache’uje te warstwy: jeśli zawartość i kolejność wcześniejszych kroków się nie zmieni, build ich nie przebudowuje. Zmiana wczesnego kroku unieważnia cały cache „nad nim”, więc następne warstwy budują się od nowa.

Praktyczna konsekwencja: najpierw umieszczaj w Dockerfile rzadko zmieniające się kroki (instalacja systemowych pakietów, dependency w package managerze), a dopiero potem często zmieniający się kod aplikacji. Dzięki temu drobna zmiana w jednym pliku źródłowym nie wymusi pełnego, długiego builda całego obrazu.

Najważniejsze wnioski

  • Docker rozwiązuje konkretne, powtarzalne problemy programistów: „u mnie działa”, konflikty wersji narzędzi i bibliotek, różnice między systemami oraz mozolne odtwarzanie środowiska na nowych maszynach.
  • Największy zysk z Dockera jest tam, gdzie projekt ma kilka usług (np. backend, baza, cache, reverse proxy) albo wymaga specyficznego zestawu systemu i bibliotek – wtedy dobrze przygotowany obraz i docker-compose realnie stabilizują środowisko pracy.
  • „Konteneryzuj wszystko” to zła rada w prostych przypadkach: jednorazowe skrypty, nauka języka od zera, nieskomplikowane aplikacje desktopowe czy mały monolit z prostym stackiem zwykle szybciej uruchomisz bez Dockera.
  • Docker to runtime i wygodne narzędzie developerskie na pojedynczym hoście, a Kubernetes to osobna liga – orkiestracja wielu kontenerów na wielu maszynach, z ogromnie większą złożonością i zupełnie innym momentem w życiu projektu.
  • Sensowna ścieżka to: najpierw opanować Dockera lokalnie (obrazy, kontenery, compose), a dopiero przy realnej potrzebie skalowania i wysokiej dostępności wchodzić w Kubernetes; nauka obu naraz zwykle kończy się chaosem.
  • W wielu małych i średnich projektach proste narzędzia (virtualenv/pyenv dla Pythona, nvm/volta dla Node, systemd dla małych usług w Go) zapewniają wystarczającą przewidywalność bez dokładania warstwy kontenerów.
  • Kluczowy filtr decyzyjny: używać Dockera tylko tam, gdzie redukuje odczuwalny ból (konflikty środowisk, trudne odtwarzanie setupu, wiele usług), a nie dlatego, że „wszyscy tak robią” albo „kiedyś będzie Kubernetes”.
Poprzedni artykułJak genialne idee zmieniały bieg historii nauki i naszego codziennego życia
Następny artykułMicroLED, miniLED czy OLED: który ekran wybrać w 2025 roku
Szymon Adamczyk
Szymon Adamczyk to pasjonat AI/ML i analizy danych, który łączy doświadczenie akademickie z pracą nad komercyjnymi projektami uczenia maszynowego. Na Pirat-Pirat.pl tłumaczy złożone zagadnienia sztucznej inteligencji na język praktycznych zastosowań – od prostych modeli po wdrożenia w środowiskach produkcyjnych. W artykułach pokazuje krok po kroku, jak budować i testować modele, zwracając uwagę na jakość danych, metryki i ograniczenia algorytmów. Korzysta z otwartych repozytoriów, dokumentacji frameworków i własnych eksperymentów. Szczególnie dba o etyczny wymiar AI oraz transparentne przedstawianie ryzyk i korzyści.