Najlepsze praktyki konteneryzacji aplikacji Django

Najlepsze praktyki konteneryzacji aplikacji Django
Photo by Mike Petrucci / Unsplash

Konteneryzacja aplikacji Django pozwala na łatwe wdrażanie i skalowanie, ale aby osiągnąć maksymalną wydajność i minimalny rozmiar, warto stosować sprawdzone praktyki. Poniżej przedstawiamy kluczowe techniki i ustawienia, które pomogą zoptymalizować obraz Dockera dla aplikacji Django, od minimalizacji rozmiaru po dostosowanie do środowisk Kubernetes i Cloud Run. Każdy aspekt zawiera konkretne porady, przykłady kodu i omówienie potencjalnych wyzwań.

Minimalizacja rozmiaru obrazu

Wybierz lekki obraz bazowy – Podstawą optymalizacji jest wybór odpowiedniego obrazu bazowego Pythona. Popularne opcje to python:slim (Debian/Ubuntu Slim), python:alpine (Alpine Linux) oraz obrazy Distroless. Slim oparty na Debianie ma większy rozmiar niż Alpine, ale zawiera standardową bibliotekę C (glibc) i wiele paczek dostępnych jako prekompilowane koła (wheels) PyPI. Alpine jest wyjątkowo mały (~5-50 MB), lecz używa musl libc, co może powodować problemy: brak zgodnych kół pip i konieczność kompilacji zależności ze źródeł. W efekcie budowanie obrazu na Alpine bywa wolniejsze i paradoksalnie prowadzi do większych obrazów dla aplikacji Python (Using Alpine can make Python Docker builds 50× slower) (Using Alpine can make Python Docker builds 50× slower). Badanie wykazało, że obraz z Pandas i Matplotlib na Alpine (Python 3.8) ważył ~851 MB i budował się 25 minut, podczas gdy na Debian Slim ~363 MB w 30 sekund (Using Alpine can make Python Docker builds 50× slower) (Using Alpine can make Python Docker builds 50× slower). Dodatkowo musl vs glibc może wpływać na wydajność – Python pod musl bywa znacznie wolniejszy dla pewnych operacji oraz narażony na subtelne błędy (np. inne zachowanie DNS, mniejsze domyślne stosy wątków) (Using Alpine can make Python Docker builds 50× slower) (Using Alpine can make Python Docker builds 50× slower). Z tego powodu, jeśli priorytetem jest wydajność i bezproblemowe instalowanie bibliotek, często lepszym wyborem jest obraz slim lub standardowy (oparty na Debianie/Ubuntu), pomimo nieco większego rozmiaru.

Rozważ obrazy Distroless – Distroless to podejście Google polegające na maksymalnym odchudzeniu obrazu poprzez usunięcie warstwy systemu operacyjnego (brak powłoki, menedżera pakietów itp.). Obrazy Distroless dla Pythona zawierają tylko interpretor i minimalne biblioteki potrzebne do uruchomienia aplikacji. Dzięki temu mają one niewielki rozmiar (rzędu kilkudziesięciu MB) oraz bardzo małą powierzchnię ataku – brak powłoki i narzędzi systemowych utrudnia atakującemu eskalację w razie włamania (Creating an up-to-date Distroless Python Image - alexos.dev) (Creating an up-to-date Distroless Python Image - alexos.dev). Alpine również redukuje obraz i powierzchnię ataku, ale wciąż zawiera powłokę (BusyBox). Distroless oferuje te same korzyści bezpieczeństwa co Alpine (a nawet większe, bo bez powłoki), przy zachowaniu zgodności z glibc (obrazy Distroless Pythona bazują na Debianie) – to oznacza dostępność większości prekompilowanych pakietów Pythona i mniej problemów z budowaniem. Wadą może być utrudnione debugowanie (brak sh) oraz konieczność budowania aplikacji w trybie multi-stage (o czym niżej).

Podsumowanie: Dla większości aplikacji Django optymalnym punktem startowym jest python:<wersja>-slim, ewentualnie użycie multi-stage build z finalnym etapem Distroless. Alpine kusi rozmiarem, ale zastosowanie go w świecie Pythona często wydłuża budowę i może wprowadzić nieoczywiste problemy (Using Alpine can make Python Docker builds 50× slower). Jeśli zdecydujesz się na Alpine, upewnij się, że rzeczywiście nie potrzebujesz pakietów wymagających kompilacji C lub przygotuj się na dostrojenie obrazu (np. instalacja musl-dev, gcc i czyszczenie warstw). W każdym razie, mniejszy obraz to szybsze pobieranie i start kontenera (mniej danych do przetworzenia) oraz mniej podatności (mniej pakietów w systemie) (Creating an up-to-date Distroless Python Image - alexos.dev), co jest korzystne w środowiskach cloud.

Geranimo - Unsplash

Prekompilacja bytecode’u Pythona

Python domyślnie kompiluje pliki .py do bytecode (.pyc) przy pierwszym imporcie, zapisując je w katalogu __pycache__. Aby przyspieszyć czas startu aplikacji w kontenerze, można wykonać tę kompilację podczas budowania obrazu, zamiast przy starcie. Ma to szczególne znaczenie, gdy kontener jest uruchamiany często lub gdy system plików kontenera jest tylko do odczytu. W wielu deploymentach Kubernetes stosuje się politykę bezpieczeństwa read-only root filesystem (system plików kontenera tylko do odczytu) (python, bytecode, and read-only containers – Digital Ramblings). W takiej sytuacji Python nie może zapisać plików .pyc, więc za każdym uruchomieniem interpretuje kod źródłowy od zera, co opóźnia start aplikacji (python, bytecode, and read-only containers – Digital Ramblings). Rozwiązaniem jest użycie python -m compileall podczas budowania obrazu, aby wygenerować pliki bytecode zawczasu.

Jak to zrobić? W Dockerfile, po skopiowaniu kodu do obrazu, dodaj krok kompilacji bytecode, np.:

# Załóżmy, że kod aplikacji jest w katalogu /app
WORKDIR /app
COPY . /app

# Prekompilacja wszystkich plików .py do .pyc:
RUN python -m compileall /app

Powyższa instrukcja wygeneruje .pyc dla całego projektu. Dzięki temu narzut uruchomienia zostanie przeniesiony na etap budowania obrazu, a kontener startuje szybciej – interpreter od razu korzysta z gotowego bytecode (python, bytecode, and read-only containers – Digital Ramblings). Według zaleceń, taka praktyka przyspiesza startup, choć nie wpływa na samą wydajność działania kodu (bytecode czy źródło – wykonanie jest tak samo szybkie po załadowaniu) (python, bytecode, and read-only containers – Digital Ramblings). W środowiskach serverless (np. Cloud Run), gdzie zależy nam na jak najkrótszym czasie zimnego startu, prekompilacja może zaoszczędzić cenne setki milisekund.

Warto zauważyć, że prekompilacja minimalnie zwiększy rozmiar obrazu (dodajemy pliki .pyc obok .py). Jeśli zależy nam na każdym megabajcie, można rozważyć usunięcie plików źródłowych .py z obrazu i pozostawienie tylko .pyc – jednak w przypadku Pythona nie jest to zazwyczaj praktykowane (utrudnia debugowanie i introspekcję). Zysk rozmiaru będzie znikomy, bo pliki .pyc są zwykle tylko odrobinę mniejsze niż odpowiadające im .py.

Czy zawsze warto? Jeśli nasz kontener to proces długotrwały (np. serwer web startuje raz i działa ciągle), zysk z prekompilacji jest jednorazowy przy starcie. Mimo to, przy wdrożeniach na skalę (np. automatyczne skalowanie wielu instancji) lub w scenariuszach gdzie proces jest uruchamiany cyklicznie, prekompilacja jest dobrą praktyką – kosztem nieco dłuższego builda obrazu przyspieszamy każdy runtime. Biorąc pod uwagę, że to tylko jedna linijka w Dockerfile, warto ją dodać, zwłaszcza że współgra to z zabezpieczaniem kontenera (tryb read-only) (python, bytecode, and read-only containers – Digital Ramblings) (python, bytecode, and read-only containers – Digital Ramblings).

Efektywne zarządzanie zależnościami

Instalacja zależności Pythona (pakietów pip) oraz zależności systemowych w Dockerze może łatwo prowadzić do nadmiernego rozrostu obrazu i wydłużenia czasu budowy. Oto praktyki, które pozwolą tego uniknąć:

1. Używaj plików wymagań i instaluj zależności przed kodem aplikacji. Zamiast kopiować cały projekt i dopiero uruchamiać pip install, lepiej najpierw skopiować plik requirements.txt (lub poetry.lock itp.), zainstalować zależności, a dopiero potem dodać resztę kodu. Dzięki temu warstwa z zależnościami będzie budowana tylko wtedy, gdy zmieni się plik wymagań, a nie przy każdej modyfikacji kodu. Przykład fragmentu Dockerfile:

FROM python:3.12-slim

WORKDIR /app
# Kopiujemy tylko plik wymagań
COPY requirements.txt .
# Instalujemy zależności (używamy --no-cache-dir, patrz niżej)
RUN pip install --no-cache-dir -r requirements.txt

# Teraz dopiero kopiujemy resztę kodu
COPY . .
...

Taki układ powoduje, że zmiana kodu .py nie unieważnia cache warstwy z zainstalowanymi pakietami. Gdy zmienisz np. jeden moduł aplikacji, Docker nie będzie ponownie instalował wszystkich pakietów – wykorzysta warstwę z cache. Jeśli jednak trzymalibyśmy kolejność odwrotną (najpierw kopiujemy kod, potem instalujemy), to każda zmiana w kodzie spowoduje ponowne pip install, co jest bardzo nieefektywne (Docker Best Practices for Python Developers | TestDriven.io). Ogólna zasada: “rzadko zmieniające się warstwy umieszczaj wysoko (na początku Dockerfile), często zmieniające – nisko” (Docker Best Practices for Python Developers | TestDriven.io). Plik z zależnościami zwykle zmienia się rzadziej niż kod aplikacji, więc powinien być kopiowany wcześniej.

2. Unikaj zbędnych warstw – łącz komendy RUN. Każda instrukcja RUN tworzy nową warstwę w obrazie. Aby zmniejszyć ich liczbę, łącz polecenia, które się ze sobą wiążą, w jeden RUN. Typowym przykładem jest instalacja pakietów systemowych: zamiast:

RUN apt-get update
RUN apt-get install -y gcc python-dev
RUN rm -rf /var/lib/apt/lists/*

lepiej wykonać:

RUN apt-get update && apt-get install -y --no-install-recommends gcc python-dev && \
    apt-get clean && rm -rf /var/lib/apt/lists/*

Takie łańcuchowanie zmniejsza liczbę warstw (tutaj z trzech do jednej) i od razu czyści pamięć podręczną menedżera pakietów APT, żeby nie pozostawiać jej w obrazie. Dockerfile staje się krótszy, a obraz lżejszy (Docker Best Practices for Python Developers | TestDriven.io). Podobnie postępuj z innymi pakietami systemowymi potrzebnymi do budowy (np. biblioteki dev, narzędzia). Po użyciu – usuń je, jeśli nie są potrzebne w runtime (można to zrobić manualnie w tym samym RUN lub wykorzystać multistage build, opisany za chwilę).

3. Stosuj pip install --no-cache-dir i inne opcje oszczędzające miejsce. Domyślnie pip podczas instalacji może buforować pobrane pakiety (w katalogu cache pip). Użycie opcji --no-cache-dir zapobiega zapisywaniu cache pip w obrazie (Optimizing Docker Image Sizes: Advanced Techniques and Tools). Dzięki temu warstwa po pip install nie będzie zawierać zbędnych plików .whl i cache, które i tak nie są potrzebne w uruchomionym kontenerze. Dodatkowo, jeśli instalujesz z requirements.txt, możesz dodać && rm -rf ~/.cache/pip na końcu komendy RUN dla pewności wyczyszczenia cache (przy aktualnych wersjach pip nie powinno to być potrzebne z --no-cache-dir, ale to zależność od wersji). W przypadku używania narzędzi takich jak Poetry czy Pipenv, również upewnij się, że nie zostawiają one niepotrzebnych plików (np. katalog .venv wewnątrz obrazu, jeśli nie jest wymagany).

4. Pilnuj wersji i używaj sprawdzonych źródeł pakietów. Choć to bardziej kwestia deterministyczności budowy niż rozmiaru, warto w pliku wymagań zamrażać wersje (==1.2.3) – to zapewni powtarzalność budowy i lepsze wykorzystanie cache. Gdy wersje “dryfują” (np. Django>=4.0), każdy build może ściągać nowe wersje paczek, co unieważnia cache i może zwiększać rozmiar.

5. Multi-stage builds (wielostopniowe budowanie) – to potężna technika pozwalająca usunąć z finalnego obrazu wszystkie zależności potrzebne tylko na etapie budowania. Idea jest prosta: w pierwszym etapie obrazu instalujemy rzeczy potrzebne do kompilacji (np. kompilator, build-base, nagłówki, ciężkie biblioteki), budujemy naszą aplikację lub zależności, a następnie w drugim etapie startujemy od czystego, lekkiego obrazu i kopiujemy do niego jedynie wyniki budowania (np. skompilowane pakiety, skopiowany kod). W kontekście Pythona popularnym wzorcem jest budowanie kół (wheel):

Przykład Dockerfile wielostopniowego dla Django:

# Etap 1: builder – instalacja zależności wraz z kompilacją
FROM python:3.12-slim AS builder
WORKDIR /app

# Zainstaluj kompilator i potrzebne biblioteki do budowy (np. psycopg2 potrzebuje libpq-dev)
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && \
    rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
# Zbuduj koła .whl dla wszystkich zależności (bez instalowania ich globalnie)
RUN pip wheel --no-cache-dir --wheel-dir /app/wheels -r requirements.txt

# Etap 2: finalny – tylko uruchamianie aplikacji, bez kompilatora
FROM python:3.12-slim
WORKDIR /app

# Kopiujemy z buildera zbudowane koła i plik wymagań
COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .
# Instalujemy zależności z wcześniej zbudowanych wheeli (szybko, bez kompilacji)
RUN pip install --no-cache-dir /wheels/*

# Kopiujemy resztę kodu aplikacji
COPY . .
# (opcjonalnie) Uruchamiamy kompilację bytecode
RUN python -m compileall /app

# Określamy domyślne polecenie uruchomienia serwera (Gunicorn w tym wypadku)
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000"]

W powyższym przykładzie w warstwie build (builder) instalujemy gcc i inne dev pakiety oraz budujemy wheel'e dla wszystkich zależności. W finalnej warstwie już nie potrzebujemy kompilatora – korzystamy z gotowych binarnych wheeli do zainstalowania bibliotek. Dzięki temu finalny obraz jest dużo mniejszy (nie zawiera gcc, headerów, itp.) i bezpieczniejszy (mniej pakietów systemowych, mniejsza szansa na lukę) (Docker Best Practices for Python Developers | TestDriven.io). Różnice mogą być drastyczne – TestDriven.io pokazało redukcję obrazu z 259 MB do 156 MB dla prostej aplikacji po zastosowaniu multi-stage (Docker Best Practices for Python Developers | TestDriven.io), a w przypadku aplikacji data science (Jupyter, pandas) z ~969 MB do ~357 MB (Docker Best Practices for Python Developers | TestDriven.io). To zarówno oszczędność miejsca, jak i kosztów transferu oraz szybsze wdrożenia.

6. Usuń niepotrzebne pliki z obrazu. Stosuj .dockerignore aby wykluczyć z kontekstu budowania pliki, które nie są potrzebne w obrazie (np. dokumentację, testy, pliki konfiguracyjne dla CI, katalog .git, lokalne pliki .env z hasłami). Im mniej plików Docker musi przetworzyć, tym szybciej zbuduje obraz i tym mniej przypadkowych rzeczy trafi do finalnego kontenera. Przykład wpisów w .dockerignore:

.git
.env
*.pyc
__pycache__/
.env.local

To zabezpiecza nas przed przypadkowym dodaniem tajnych danych do obrazu oraz zmniejsza rozmiar i ryzyko bustowania cache przez nieistotne zmiany (Docker Best Practices for Python Developers | TestDriven.io).

Cache’owanie warstw w Dockerfile

Docker korzysta z mechanizmu cache warstw, aby przyspieszać kolejne budowania obrazów. Projektując Dockerfile dla aplikacji Django, możemy znacząco skrócić czas rebuildów i zmniejszyć obciążenie, maksymalnie wykorzystując cache. Kilka kluczowych porad (częściowo zahaczają o kwestie omówione w sekcji zależności, ale tutaj skupimy się stricte na cache):

1. Odpowiednia kolejność instrukcji – Jak wspomniano, zawsze umieszczaj warstwy, które zmieniają się najrzadziej, na początku. Bazowy obraz Pythona zmienia się rzadko (tylko przy aktualizacji wersji/patchy), potem instalacja systemowych zależności i pip. Kod aplikacji zmienia się często – więc COPY kodu powinno być jak najniżej. Dzięki temu, dopóki nie zmienisz czegoś w requirements.txt, Docker użyje istniejącej warstwy z zainstalowanymi pakietami. Jeśli natomiast modyfikujesz tylko kod aplikacji, przebuduje się tylko ostatnia warstwa z kopiowaniem kodu i ewentualnie kompilacją bytecode. Błędna kolejność (najpierw kod, potem pip install) powoduje, że każda zmiana w kodzie unieważnia cache instalacji pakietów (Docker Best Practices for Python Developers | TestDriven.io).

2. Wykorzystuj .dockerignore. Plik .dockerignore (analogiczny do .gitignore) pozwala wykluczyć niepotrzebne pliki z kontekstu budowania. Ma to wpływ nie tylko na rozmiar, ale i na cache. Jeśli do kontekstu trafi np. plik z danymi lub logi, które często się zmieniają, to nawet jeśli ich nie kopiujesz w Dockerfile, zmiana w nich może zmodyfikować sumę kontrolną kontekstu i unieważnić niektóre warstwy cache. Dlatego upewnij się, że w .dockerignore masz wykluczone wszystko, co nie jest potrzebne do zbudowania aplikacji (np. katalogi z danymi, venv, pliki .pytest_cache, itp.). Dzięki temu build będzie bardziej deterministyczny.

3. Uważaj na mechanizmy cache podczas instalacji zależności. Wspomniane pip install --no-cache-dir zapobiega zapisywaniu cache pip wewnątrz warstwy – to zmniejsza obraz, ale uniemożliwia wykorzystanie pip cache między budowaniami. Jeśli budujesz często podczas developmentu, możesz zastosować trik: mapowanie katalogu cache pip jako wolumenu podczas budowy. Docker BuildKit umożliwia tzw. mount cache – np.:

# syntax=docker/dockerfile:1.2
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

Dzięki temu podczas budowania obraz może korzystać z cache pip z poprzednich buildów (który jest na hostcie, a nie w obrazie) (Docker Best Practices for Python Developers | TestDriven.io). Alternatywnie, przy tradycyjnym docker build, można uruchamiać budowanie z docker build --mount ... lub po prostu podczas developmentu budować obrazy, korzystając z globalnego cache pip. Niezależnie od podejścia, w środowisku CI/CD produkcyjnym lepiej włączyć --no-cache-dir, by finalny obraz nie zawierał nic zbędnego. W środowisku dev można pominąć tę opcję dla szybszych buildów lokalnych, ale pamiętać o wyczyszczeniu przed wypchnięciem do registry.

4. Unikaj niepotrzebnego bustowania cache. Czasem drobna zmiana może wpłynąć na warstwy, których byśmy się nie spodziewali. Przykład: jeśli wykonujesz COPY . . bez .dockerignore, to zmiana w dowolnym pliku projektu (nawet w dokumentacji) spowoduje unieważnienie wszystkich kolejnych warstw. Lepiej kopiować selektywnie (np. najpierw COPY requirements.txt osobno, a na końcu COPY . . lub lepiej: COPY . /app z odpowiednim .dockerignore). Również, kopiując pliki statyczne czy assety, zastanów się czy nie lepiej wydzielić je, by zmiany w nich nie ruszały np. warstwy z danymi aplikacji. Bycie eksplicytnym co do kopiowanych plików zwiększa szansę, że zmiany nie wpłyną na niepowiązane warstwy (Docker Best Practices for Python Developers | TestDriven.io) (Docker Best Practices for Python Developers | TestDriven.io).

5. Wykorzystuj warstwę bazową z odpowiednim tagiem. Jeśli używasz np. python:3.12-slim, upewnij się, że używasz stałego tagu (np. 3.12-slim-bullseye vs 3.12-slim-bookworm zależnie od potrzeb), by Docker nie musiał za każdym razem sprawdzać/ściągać nowej wersji podstawy. Pinując zarówno wersję Pythona jak i dystrybucji (bullseye/bookworm itp.), unikasz niekontrolowanych zmian. Oczywiście, pamiętaj o okresowym aktualizowaniu bazowego obrazu dla poprawek bezpieczeństwa, ale rób to świadomie.

Podsumowując: pisz Dockerfile “pod cache”, czyli: logiczna kolejność (baza -> zależności -> kod), jak najmniej plików w kontekście, łączenie instrukcji RUN, no i testuj docker build kilkukrotnie, by upewnić się, które warstwy są keszowane, a które nie. W logach budowy Docker wypisuje kiedy używa cache (“Using cache”) – obserwuj to i koryguj Dockerfile, jeśli widzisz, że coś niepotrzebnie przebudowuje się przy małej zmianie.

Dostosowanie Django do kontenerów

Uruchomienie Django w kontenerze wymaga często drobnych modyfikacji konfiguracji samej aplikacji, tak aby dobrze współgrała z filozofią “kontenera” i 12-factor app. Oto najważniejsze obszary: ustawienia konfiguracyjne, logowanie, pliki statyczne (i media) oraz specyficzne ustawienia pod kontenery (np. adresy, porty).

1. Konfiguracja przez zmienne środowiskowe. Kontenery preferują przekazywanie konfiguracji przez zmienne środowiskowe, zamiast trzymania ich na sztywno w plikach. Upewnij się, że Twój settings.py potrafi czytać wartości z os.environ. Typowe parametry do ustawienia przez env to: DEBUG (np. DEBUG = os.getenv('DEBUG', 'False') == 'True'), SECRET_KEY, dane dostępu do bazy (DATABASE_URL lub poszczególne DB_HOST, DB_NAME, DB_USER, DB_PASSWORD), ALLOWED_HOSTS, klucze API itp. Możesz skorzystać z bibliotek jak django-environ czy decouple, żeby łatwo wczytać plik .env lub zmienne – ale w środowisku produkcyjnym często po prostu ustawia się je w systemie orkiestracji (Compose/Kubernetes). Przykład użycia w settings:

import os
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key')
DEBUG = os.environ.get('DEBUG', '') == '1'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'HOST': os.environ.get('DB_HOST', 'localhost'),
        'NAME': os.environ.get('DB_NAME', 'postgres'),
        'USER': os.environ.get('DB_USER', 'postgres'),
        'PASSWORD': os.environ.get('DB_PASSWORD', ''),
    }
}

Powyższy kod to tylko przykład – dopasuj do swoich zmiennych. Ważne jest, by ALLOWED_HOSTS nie pozostawić pustego w produkcji (możesz tam wpisać ['*'] dla testów, ale docelowo lepiej określić domenę lub adres). Przy uruchamianiu kontenera np. przez Docker Compose, przekażesz te zmienne w sekcji environment lub w pliku .env. W Kubernetes użyjesz obiektu Secret/ConfigMap i sekcji env w definicji Pod.

2. Adresy i porty. Domyślnie, manage.py runserver w Django nasłuchuje na 127.0.0.1:8000, co wewnątrz kontenera oznacza tylko ten kontener. Aby aplikacja była dostępna spoza kontenera, trzeba nasłuchiwać na 0.0.0.0. Jeśli używasz Gunicorn (zalecany w produkcji), ustaw bind na 0.0.0.0:<port>. W Dockerze najczęściej portem jest 8000, ale w usługach typu Cloud Run, port jest przydzielany dynamicznie przez platformę i przekazywany w zmiennej środowiskowej $PORT. W Cloud Run upewnij się, że używasz tej zmiennej – np. port = int(os.environ.get("PORT", 8000)) i potem gunicorn ... --bind 0.0.0.0:{port}. Przykład w Dockerfile dla Cloud Run:

CMD exec gunicorn myproject.wsgi:application --bind 0.0.0.0:$PORT

W Kubernetes zazwyczaj masz stały port kontenera (np. 8000) i serwis, więc tam nie ma dynamicznego portu, ale również adres 0.0.0.0 jest wymagany.

3. Logowanie do stdout/stderr. W tradycyjnych wdrożeniach Django często logi zapisywały się do plików na dysku. W kontenerach lepszym podejściem jest logowanie do konsoli (stdout/stderr), a następnie korzystanie z mechanizmów dockera/kubernetes do agregowania logów. Kieruj wszystkie logi aplikacji do standardowego wyjścia – Docker automatycznie zbiera stdout/stderr każdego kontenera. Django domyślnie loguje zapytania HTTP i błędy do konsoli, ale jeśli masz własne loggery, skonfiguruj je w LOGGING tak, by używały handlera StreamHandler na stdout. Przykład minimalnej konfiguracji w settings.py:

LOGGING = {
    'version': 1,
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'formatter': 'simple',
            'stream': 'ext://sys.stdout',
        },
    },
    'formatters': {
        'simple': {'format': '[{levelname}] {message}', 'style': '{'},
    },
    'root': {
        'handlers': ['console'],
        'level': 'INFO',
    },
}

Taka konfiguracja przekieruje wszystkie logi root (np. printy Pythona czy logi Django) do konsoli na poziomie INFO. Możesz dostosować format i poziomy. Najważniejsze, by nie pisać do plików. W kontenerach pliki logów mogłyby zapełnić system plików lub nie być dostępne poza kontenerem. Zgodnie z 12factor app, logi traktujemy jak strumień zdarzeń i pozwalamy platformie je przechwycić (Docker Best Practices for Python Developers | TestDriven.io). Np. w Kubernetes możemy podłączyć stdout do Elasticsearch/Kibany, a w Cloud Run logi są automatycznie eksportowane do StackDriver. Jeśli używasz Gunicorn, on też loguje do stdout domyślnie (błędy workera do stderr) – więc integruje się to z powyższym.

4. Obsługa plików statycznych. Django wymaga obsługi plików statycznych (STATICFILES). W środowisku kontenerowym masz kilka opcji:

  • Serwer WWW w kontenerze obok: Bardziej tradycyjne podejście – użyć np. Nginx w oddzielnym kontenerze, który będzie serwował pliki statyczne, a ruch dynamiczny proxy'ował do kontenera Django. W praktyce: po collectstatic (może być wykonane podczas budowy lub wejścia kontenera), montujesz katalog STATIC_ROOT jako wolumen współdzielony między kontenerem Django a Nginx, albo używasz mechanizmu typu Cloud Storage. Nginx nasłuchuje na porcie 80 i serwuje np. URL /static/ z volume, a resztę reversuje do Gunicorna. Takie rozwiązanie jest wydajne i często spotykane, ale wprowadza większą złożoność (dwa kontenery, konfiguracja Nginx). W środowisku Kubernetes jest to łatwe do zrealizowania (Deployment Django + Deployment Nginx + wspólny PersistentVolume lub budowanie obrazu z już zebranymi statikami i użycie InitContainer żeby skopiować je do volumenu). Jeśli latencja i obciążenie statycznych plików jest krytyczne, Nginx będzie bardziej efektywny niż Django+WhiteNoise, ale dla większości aplikacji różnica jest mało odczuwalna, a WhiteNoise jest prostsze.
  • CDN / storage zewnętrzny: w architekturach cloud-native często pliki statyczne (a tym bardziej media – pliki użytkowników) trzymamy na zewnętrznych usługach (S3, Cloud Storage) i serwujemy przez CDN. Wtedy kontener Django po collectstatic przesyła pliki na zewnątrz (np. do bucketu), a STATIC_URL jest ustawiony na adres CDN. To najlepsze rozwiązanie pod względem skalowalności – odciąża aplikację całkowicie od serwowania assetów. Minusem jest konieczność konfiguracji dodatkowego procesu (upload podczas build/deploy) i potencjalne koszty transferu. W kontekście Docker/K8s, warto to rozważyć, gdy aplikacja rośnie.

WhiteNoise: Najprostsze podejście to użycie biblioteki WhiteNoise, która pozwala Django serwować pliki statyczne efektywnie bez potrzeby osobnego serwera WWW. Jak pisze Heroku, Django out-of-the-box nie nadaje się do serwowania static w produkcji, ale WhiteNoise został stworzony dokładnie w tym celu (Django and Static Assets | Heroku Dev Center). Dodajesz WhiteNoise do MIDDLEWARE (zaraz po SecurityMiddleware) i ustawiasz STATIC_ROOT oraz uruchamiasz manage.py collectstatic podczas budowania lub uruchamiania kontenera. WhiteNoise może nawet automatycznie wersjonować i kompresować pliki (gzip, brotli). To rozwiązanie idealne dla kontenerów na platformach typu Heroku, Cloud Run, gdzie chcemy uniknąć stawiania osobnego Nginxa do serwowania statycznych plików. Przykład w Dockerfile:

# ... po zainstalowaniu zależności i skopiowaniu kodu:
RUN python manage.py collectstatic --noinput

To polecenie zbierze pliki statyczne do katalogu STATIC_ROOT (np. /app/staticfiles). Następnie, po odpaleniu kontenera, WhiteNoise zacznie obsługiwać żądania do /static/... serwując te pliki z pamięci. Upewnij się tylko, że DEBUG=False (inaczej Django nie będzie używać WhiteNoise, bo w trybie debug statiki serwuje devserver) i że STATIC_URL jest poprawnie ustawione (domyślnie /static/).

Podsumowując: dla prostoty, WhiteNoise wbudowane w aplikację to świetny wybór (mniej komponentów). Należy jedynie pamiętać, by w pliku Dockerfile wykonać collectstatic przed uruchomieniem. Gdy skalujesz aplikację na wiele instancji, upewnij się, że każda instancja ma te same statyczne pliki (przy buildzie to osiągasz naturalnie). Jeśli korzystasz z CI/CD, zwykle collectstatic jest częścią procesu budowania obrazu, dzięki czemu wizerunek zawiera statyczne zasoby gotowe do serwowania.

5. Pliki mediów (uploady użytkowników) – Chociaż pytanie dotyczy statycznych, warto wspomnieć: pliki wgrywane przez użytkowników (MEDIA) nie powinny być trzymane wewnątrz kontenera, bo kontenery są efemeryczne (mogą zostać zrestartowane, zniszczone, zduplikowane). Najlepszą praktyką jest trzymanie mediów na zewnętrznym storage (S3, Azure Blob, NFS, itp.) i dostęp do nich również przez URL. W Django można użyć np. django-storages do integracji z S3. Jeśli jednak z jakiegoś powodu musisz trzymać media lokalnie, to użyj Volume (w Docker Compose) lub PersistentVolumeClaim (na Kubernetes), aby te dane przetrwały restarty kontenerów i były współdzielone. Ale to temat na inną dyskusję – główna myśl: kontener ma być stateless, czyli żadnych trwałych danych w swoim systemie plików.

6. Inne ustawienia – W Django 4+ warto zwrócić uwagę na asynchroniczność i obsługę ASGI. Jeśli planujesz używać async views czy WebSockets (Django Channels), dostosuj punkt wejścia (np. Daphne zamiast Gunicorn WSGI). Konteneryzacja tu wygląda podobnie, ale może wymagać innego command. Kolejna rzecz: ustaw PYTHONUNBUFFERED=1 i PYTHONDONTWRITEBYTECODE=1 w Dockerfile (ENV), aby Python nie buforował wyjścia (ważne dla logowania w czasie rzeczywistym) i nie próbował pisać .pyc w runtime (jeśli już prekompilowaliśmy, to nie ma potrzeby; a jeśli nie prekompilowaliśmy, to i tak w kontenerze produkcyjnym najpewniej nie chcemy generować .pyc w trakcie). Te zmienne środowiskowe to drobne ulepszenia często dodawane w oficjalnych wzorcach.

Na koniec upewnij się, że aplikacja poprawnie reaguje na sygnały systemowe – np. Kubernetes wysyła SIGTERM do kontenera przy jego usuwaniu. Django w połączeniu z Gunicornem zazwyczaj radzi sobie z tym (Gunicorn przechwyci sygnał i zakończy workerów), ale jeśli masz własne wątki lub zadania, miej to na uwadze. W razie potrzeby, Gunicorn umożliwia ustawienie timeoutów i gracful shutdown.

Bezpieczeństwo i zarządzanie sekretami

Bezpieczne przechowywanie i przekazywanie sekretów (haseł, kluczy API, sekret_key Django itp.) w środowisku kontenerowym jest krytyczne. Zaniedbanie może skutkować wyciekiem wrażliwych danych. Oto najlepsze praktyki:

1. Nigdy nie zapisuj sekretów na stałe w obrazie ani repozytorium. Unikaj wpisywania haseł w settings.py, Dockerfile czy docker-compose.yml w postaci jawnej. Obraz kontenera może być łatwo zbadany – każdy kto go pobierze, może odczytać warstwy i znaleźć w nich np. hasło do bazy, jeśli było dodane przez ENV w Dockerfile (Docker Best Practices for Python Developers | TestDriven.io). Podobnie, jeśli wypchniesz kod z sekretami na GitHub – traktuj to jak incydent bezpieczeństwa (natychmiastowa rotacja kluczy). Zasada: Obraz powinien być pozbawiony tajnych danych; dostarcz je dopiero w momencie uruchomienia.

2. Używaj zmiennych środowiskowych lub mechanizmów orkiestracji do przekazywania sekretów w runtime. Docker Compose i Kubernetes umożliwiają wstrzyknięcie sekretów do kontenera w trakcie uruchamiania. Np. w docker-compose:

services:
  web:
    image: myapp:latest
    environment:
      - SECRET_KEY=${DJANGO_SECRET_KEY}
      - DB_PASSWORD=${DB_PASSWORD}

A same wartości trzymasz lokalnie w .env (którego nie commiatujesz do repo, a dodajesz do .dockerignore (Docker Best Practices for Python Developers | TestDriven.io)). W Kubernetes definiujesz obiekt Secret, który może być załączony jako zmienne lub wolumen. Przykład (env z Secret):

apiVersion: v1
kind: Secret
metadata:
  name: myapp-secret
type: Opaque
data:
  DJANGO_SECRET_KEY: <BASE64 zahashowany klucz>
---
apiVersion: apps/v1
kind: Deployment
...
    spec:
      containers:
      - name: web
        image: myapp:latest
        env:
        - name: SECRET_KEY
          valueFrom:
            secretKeyRef:
              name: myapp-secret
              key: DJANGO_SECRET_KEY

Dzięki temu hasła nie są zapisane w manifeście jawnym (są base64 w obiekcie Secret) i nie trafiają do obrazu.

3. Unikaj przekazywania sekretów przez ARG/ENV w Dockerfile podczas budowy. Czasami kusi, by w trakcie budowania obrazu pobrać coś prywatnego (np. z prywatnego repozytorium pip) i podać w docker build sekret jako --build-arg. Choć zmienna ARG nie trafi do finalnego obrazu bezpośrednio, to jeśli użyjesz jej np. w RUN (np. RUN pip install git+https://user:[email protected]/repo), to ten URL z hasłem może znaleźć się w historii warstwy. BuildKit oferuje mechanizm --mount=type=secret do bezpiecznego użycia sekretów przy buildzie bez zapisywania w warstwach – warto go użyć w takich przypadkach. Generalnie jednak, trzymaj się zasady, że sekrety wprowadzamy przy uruchomieniu, a nie podczas builda.

4. Zwróć uwagę na widoczność zmiennych środowiskowych. Jeśli używasz zwykłych zmiennych środowiskowych do sekretów, pamiętaj, że w pewnych okolicznościach mogą być one dostępne do odczytu. Np. ktoś z dostępem do hosta dockera może zrobić docker inspect i zobaczyć zmienne kontenera (Docker Best Practices for Python Developers | TestDriven.io). W Kubernetes, ktoś z uprawnieniami do odczytu Secretów również może je podejrzeć (Secret w etcd nie jest szyfrowany domyślnie, tylko base64 – warto włączyć szyfrowanie Secretów na poziomie etcd w kube-api). Dlatego, jeśli to możliwe, ogranicz uprawnienia osób i usług do tych zmiennych. W krytycznych przypadkach rozważ mechanizmy jak HashiCorp Vault lub AWS KMS/Secrets Manager, które dostarczają sekret na żądanie do aplikacji (Vault potrafi nawet dynamicznie generować hasła do bazy). Możesz np. mountować plik z sekretem (za pomocą Docker secrets lub K8s secrets jako plik) – wtedy aplikacja czyta sekret z pliku, a plik ten nigdy nie jest zapisany w obrazie i łatwiej kontrolować dostęp (plik może być tylko czytelny przez root itp.).

5. Usuń sekret z miejsca, gdzie nie jest potrzebny. Jeśli Twoja aplikacja przekazuje gdzieś dalej sekret (np. do frontendu? – nie powinno tak być), staraj się tego unikać. Sekret powinien być użyty tylko tam, gdzie konieczne (np. SECRET_KEY tylko w backendzie do podpisywania ciastek i generowania hashy – nie wysyłaj go nigdzie).

6. Minimalizuj uprawnienia kontenera. To nie tyle o sekretach, co ogólnie o bezpieczeństwie: kontener powinien uruchamiać się jako użytkownik nieuprzywilejowany. Domyślnie Docker uruchamia procesy jako root wewnątrz kontenera, co oznacza, że w razie przejęcia aplikacji, atakujący ma uprawnienia root w kontenerze i może spróbować eskalować na hosta (Docker Best Practices for Python Developers | TestDriven.io). Dodaj w Dockerfile użytkownika nie-root i uruchamiaj aplikację pod nim (instrukcja USER). Przykład:

RUN adduser --system --group appuser
USER appuser

Teraz procesy Pythona będą biegły z dużo mniejszymi prawami. W Kubernetes warto też dodać w spec kontenera: securityContext: runAsNonRoot: true. Ponadto można ustawić readOnlyRootFilesystem: true (jak wspomniano wcześniej) dla zwiększenia bezpieczeństwa – wówczas nawet jak ktoś się włamie, nie będzie mógł nic zapisać na dysku kontenera. Te ustawienia to element twardnienia kontenerów w produkcji.

7. Aktualizuj obrazy i zależności. Sekrety mogą być bezpieczne, ale co z podatnościami? Dbaj o regularne przebudowanie obrazu na nowszej podstawie (gdy wychodzą obrazy Pythona z łatkami bezpieczeństwa) oraz aktualizuj zależności Pythona, jeśli pojawiają się alerty. Wykorzystuj narzędzia skanujące obrazy (Trivy, Snyk, Docker Scout) by wykryć znane podatności w obrazach i szybko reagować. To nie dotyczy bezpośrednio sekretów, ale jest kluczowe dla bezpieczeństwa całości.

Podsumowując: sekrety trzymaj z dala od obrazu – dostarczaj je w momencie uruchomienia poprzez mechanizmy kontenera/orchestratora. Kontener i tak będzie konfigurowany dynamicznie (różne env per środowisko: dev/stage/prod), więc to naturalne miejsce na wstrzyknięcie haseł. Dobrze ustawione .dockerignore, zmienne środowiskowe i ewentualnie systemy zarządzania sekretami dadzą Ci pewność, że w repozytorium ani w obrazie nikt nie znajdzie Twoich kluczy (Docker Best Practices for Python Developers | TestDriven.io) (Docker Best Practices for Python Developers | TestDriven.io).

Obsługa Celery w kontenerach

Celery to popularny mechanizm kolejek zadań w Django (i nie tylko). Konteneryzacja aplikacji Django zwykle obejmuje również konteneryzację workerów Celery oraz brokera (Redis/RabbitMQ). Jak poprawnie to zrobić?

1. Oddziel procesy – jedna usługa per kontener. Zgodnie z zasadą “one process per container”, nie uruchamiamy Celery i serwera Django w tym samym kontenerze. Lepiej mieć osobny kontener na web (Django + Gunicorn) i osobny na worker Celery. W Docker Compose konfigurujemy to jako dwa serwisy korzystające z tego samego obrazu (bo zarówno web jak i worker potrzebują naszego kodu i zależności):

services:
  web:
    build: .
    command: gunicorn myproject.wsgi:application --bind 0.0.0.0:8000
    environment:
      - DJANGO_SETTINGS_MODULE=myproject.settings.prod
      - CELERY_BROKER_URL=redis://redis:6379/0
    depends_on:
      - redis
  celery:
    build: .
    command: celery -A myproject worker --loglevel=INFO
    environment:
      - DJANGO_SETTINGS_MODULE=myproject.settings.prod
      - CELERY_BROKER_URL=redis://redis:6379/0
    depends_on:
      - redis
  redis:
    image: redis:6-alpine

Powyżej używamy jednego obrazu Dockera zawierającego naszą aplikację, ale uruchamiamy go z różnymi komendami. Dzięki temu utrzymujemy jeden punkt build (łatwiej zapewnić spójność wersji kodu między web a worker). Celery odczytuje z DJANGO_SETTINGS_MODULE ustawienia Django (żeby móc załadować aplikację) – upewnij się, że warunki inicjalizujące Django (np. w celery.py w projekcie) są poprawnie ustawione na odbieranie konfiguracji z env.

Zalety oddzielenia są oczywiste: możemy skalować web i worker niezależnie (np. gdy potrzeba więcej mocy do zadań asynchronicznych, skalujemy usługę Celery) (The Definitive Guide to Celery and Django - Dockerizing Celery and Django | TestDriven.io). Łatwiej również debugować i monitorować – logi są osobno, restarty nie wpływają na siebie. Nigdy nie używaj supervisord czy bashowych sztuczek, by w jednym kontenerze odpalać wiele procesów (Gunicorn + Celery) – to komplikuje zarządzanie i kłóci się z ideą kontenerów. Docker/K8s zakłada, że jeden kontener to jedna rola.

2. Broker i backend w kontenerach. W powyższym przykładzie uruchomiliśmy Redis jako serwis w Compose. Podobnie można w Kubernetes (stosując Deployment/StatefulSet dla Redisa lub po prostu skorzystać z oferty managed, np. Cloud MemoryStore czy AWS Elasticache, by nie zarządzać samemu). Pamiętaj, by adresy brokera były dostępne dla kontenerów: w Compose użyliśmy nazwy serwisu redis (Docker Compose ustawia DNS i nazwę hosta), w Kubernetes byłby to np. adres serwisu Redis (redis.default.svc.cluster.local itp.). W Cloud Run nie postawimy Redisa w tym samym środowisku (Cloud Run nie wspiera wewnętrznej komunikacji między usługami bez dodatkowego konfigurowania VPC), więc tam raczej skorzystamy z zewnętrznego (np. Redis na Compute Engine lub Cloud Memorystore).

3. Konfiguracja Celery pod kontenery. W samym projekcie Django upewnij się, że masz skonfigurowane Celery zgodnie z dokumentacją (np. plik proj/proj/celery.py z definicją app, autodiscovery tasks itp.). Ustaw broker URL i (opcjonalnie) result backend poprzez zmienne środowiskowe. Np. w settings.py:

CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')

W ten sposób możesz w zależności od środowiska wskazać inny broker. Nie hardkoduj adresów – w kontenerach adresy usług mogą być różne (np. w Compose redis, w K8s redis.default.svc..., w produkcji jakiś URL).

4. Skalowanie i zarządzanie workerami. W Kubernetes możemy utworzyć oddzielny Deployment dla workerów Celery, z własnym replicaCount. Ważne, aby uwzględnić że tasks w Celery są wykonywane w pamięci – jeśli skalasz wielu workerów, zadania zostaną rozdzielone, ale również obciążenie bazy/zasobów generowane przez te zadania się zwiększy. K8s nie ma natywnego mechanizmu autoskalowania po kolejce zadań (HPA nie zna stanu kolejki Redis), ale można kombinować np. z metrykami długości kolejki (są projekty do autoskalowania Celery na K8s).

5. Celery Beat i Flower. Jeśli używasz Celery Beat (harmonogram zadań okresowych), również wydziel go do osobnego kontenera. Możesz użyć tego samego obrazu, ale z komendą celery -A myproject beat (i ewentualnie parametry planera). Beat zazwyczaj jest tylko jeden w klastrze (nie można odpalać wielu, bo każde by duplikowało zadania). Co do Flower (monitoring Celery), też można go uruchomić jako osobny kontener, przekazując adres brokera. Na Compose byłby to kolejny serwis, na K8s – Deployment/Service.

6. Zamykanie i odporność na błędy. Upewnij się, że Celery gracefuly się zamyka na sygnały SIGTERM/SIGINT (domyślnie tak jest – worker dokończy bieżące zadanie i się wyłączy). W Kubernetes można rozważyć dodanie livenessProbe do workera, choć bywa trudno zrobić sensowny healthcheck (Celery worker nie ma wbudowanego HTTP endpointu; można ewentualnie użyć komendy celery inspect ping przez Flower, ale to zaawansowane). W praktyce, jeśli proces Celery się zawiesi, K8s to wykryje jeśli process padnie; jak się deadlockuje to bardziej problematyczne. W Cloud Run natomiast nie uruchomimy Celery w typowy sposób, bo Cloud Run spodziewa się aplikacji web (HTTP). Co prawda można uruchomić worker jako Cloud Run Job (do jednorazowego procesu) lub jako zawsze działającą usługę z concurrency=1 i np. pseudo-serwerem tylko po to, by nie ubili kontenera (to hack). Generalnie jednak, do Celery potrzebujemy tradycyjnego środowiska z ciągłym działaniem – Kubernetes, ECS, VM, itp., albo przerzucić się na inne mechanizmy chmurowe (np. AWS SQS + Lambda zamiast Celery, lub Cloud Tasks w GCP).

7. Przykład konfiguracji Celery (code): Dla kompletności, przypomnijmy jak może wyglądać plik inicjalizacyjny Celery w projekcie Django (np. myproject/celery.py):

import os
from celery import Celery

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

I w __init__.py projektu:

from .celery import app as celery_app
__all__ = ('celery_app',)

To standardowy kod, który sprawia, że Celery używa ustawień z pliku settings (prefiksowanych CELERY_). Dzięki temu te ustawienia możemy sterować env jak wyżej. Nie zapomnij, by wszystkie moduły z zadaniami (tasks.py) były włączone do autodiscovery.

8. Testuj lokalnie Compose. Wygodnie jest przetestować cały zestaw: Django + Celery + broker np. za pomocą Docker Compose. Uruchom kilka instancji workera i sprawdź, czy zadania się rozdzielają. Sprawdź, co się stanie, jak worker zostanie ubity w trakcie wykonywania (Celery powinien zadanie włożyć z powrotem do kolejki). Takie testy pomogą upewnić się, że konfiguracja kontenerów jest poprawna przed wdrożeniem.

Reasumując, konteneryzacja Celery sprowadza się do: oddzielenia roli workera od web, zapewnienia im wspólnego kodu i konfigu (np. przez jeden obraz), oraz prawidłowego ustawienia brokerów i backendów przez zmienne środowiskowe. Stosując Compose lub Kubernetes, łatwo uruchomisz potrzebne komponenty i zyskasz elastyczność skalowania (The Definitive Guide to Celery and Django - Dockerizing Celery and Django | TestDriven.io) (Docker Best Practices for Python Developers | TestDriven.io).

Optymalizacja pod Kubernetes i Cloud Run

Na koniec skupmy się na specyficznych wskazówkach dla uruchamiania konteneryzowanej aplikacji Django w środowiskach Kubernetes oraz Cloud Run (Google Cloud). Choć obie platformy używają kontenerów, różnią się charakterystyką:

Kubernetes (K8s)

1. Readiness i Liveness Probes. Kubernetes potrafi monitorować stan aplikacji poprzez probe (sprawdzenia). Warto zaimplementować endpoint health-check w Django, który będzie zwracał np. status bazy danych. Najprostsze podejście: utwórz URL, który zwraca np. “OK” gdy aplikacja działa (ew. sprawdź połączenie z DB w środku). Możesz też użyć gotowych pakietów (django-health-check). W deployment YAML zdefiniuj readinessProbe np.:

readinessProbe:
  httpGet:
    path: /healthz
    port: 8000
  initialDelaySeconds: 5
  periodSeconds: 10

Readiness probe decyduje, czy pod może przyjmować ruch (np. nie, dopóki nie wykona migracji lub nie załaduje modeli). Liveness probe sprawdza czy aplikacja się nie zawiesiła – tu można użyć tego samego endpointu lub czegoś prostszego. Jeżeli Gunicorn pracuje stabilnie, liveness może nie być potrzebny, ale readiness warto mieć, aby np. wstrzymać ruch do pody w trakcie restartu czy rolloutu, dopóki Django w pełni się nie podniesie. Pamiętaj, że jeśli używasz Gunicorn, możesz mieć chwilę czasu zanim workerzy się uruchomią – stąd initialDelaySeconds.

2. Zasoby i autoskalowanie. Ustal limity zasobów dla kontenerów: resources.requests i limits dla CPU/RAM. Np.:

resources:
  requests:
    cpu: "500m"
    memory: "512Mi"
  limits:
    cpu: "1"
    memory: "1Gi"

To zapewnia, że scheduler K8s odpowiednio przydzieli węzły i nie upchnie zbyt wiele na jednym. Dodatkowo, definiuje to bazę dla autoskalera HPA (Horizontal Pod Autoscaler) – np. możesz skalować gdy CPU > 80%. Django z Gunicornem skaluje się przez dodawanie replik (zwykle zamiast zwiększać liczbę workerów w jednym podzie, zwiększamy liczbę podów, co daje lepszą redundancję). Stosuj HPA z głową – aplikacje webowe często skalujemy manualnie lub metryką opóźnień, ale CPU to dobry start.

3. Non-root user i inne SecurityContext. Jak wspomniano, w produkcyjnym klastrze K8s często wymusza się nie-root (np. przez Pod Security Policies/Admission Controllers). Ustaw securityContext: runAsUser: 1000 (i stwórz takiego użytkownika w Dockerfile) oraz runAsNonRoot: true. Dodatkowo możesz ustawić readOnlyRootFilesystem: true (python, bytecode, and read-only containers – Digital Ramblings), wtedy warto montować ewentualne potrzebne miejsca zapisu (np. katalog na pliki tymczasowe, jeśli używasz, chociaż /tmp zwykle może być tmpfs).

4. ConfigMap i Secret w Kubernetes. Konfiguracje niezawierające sekretów (np. DEBUG, parametry cachingu) umieść w ConfigMap i doładuj jako zmienne. Sekrety – w Secret, jak opisano wcześniej. Staraj się, aby Deployment nie miał na sztywno wbudowanych wartości – używaj odniesień do ConfigMap/Secret, bo to ułatwia zmiany bez przebudowy obrazu.

5. Serwowanie statycznych plików. W K8s często stawia się Ingress z regułami. Jeśli używasz WhiteNoise, Ingress po prostu kieruje cały ruch do aplikacji. Jeśli używasz Nginx jako sidecar do statyk, możesz zrobić tzw. pod multi-container (gdzie dwa kontenery współdzielą wolumen). To jednak rzadziej spotykane – częściej po prostu dwa Deployment (Nginx i Django) i konfiguracja Ingress/Service. W dobie prostoty, WhiteNoise jest kuszący również w K8s, ale przy większym ruchu warto rozważyć Nginx (np. korzystając z gotowych helm chartów).

6. Monitoring i obserwowalność. Kontenery ułatwiają logowanie (stdout) – w K8s użyj EFK (Elasticsearch+Fluentd+Kibana) lub np. Datadog agenta do zbierania logów. Do metryk użyj Prometheusa i eksportera (można np. korzystać z metryk Gunicorn/uwsgi lub wręcz instrumentować Django). Dobrą praktyką jest ustawienie Gunicorn --statsd lub podobnych mechanizmów by zebrać czasy odpowiedzi, liczbę requestów itp.

7. Migracje i inicjalizacja. Zastanów się, jak chcesz robić migracje bazy przy wdrożeniach. Popularnie: wykonywać migracje przed zaktualizowaniem aplikacji. Można to zrobić poprzez Job Kubernetesa, initContainer albo po prostu wywołanie manage.py migrate w pipeline CI/CD przed przełączeniem ruchu na nową wersję. Unikaj sytuacji, gdzie każda replika sama z siebie próbuje robić migracje na starcie – przy wielu replikach to wyścig i ryzyko konfliktów. Lepiej aby migracje były odpalone raz.

Cloud Run (Google Cloud Run)

Cloud Run to zarządzana platforma kontenerowa serverless, co oznacza pewne różnice:

1. Aplikacja musi być stateless i reagować na HTTP. Cloud Run uruchamia kontener tylko gdy przychodzi żądanie (chyba że ustawimy minimalną liczbę instancji). Po okresie bezczynności instancja może zostać wyłączona (scale to zero). To oznacza, że nie nadaje się do ciągle działających procesów bez żądań – np. nie uruchomimy sobie workera Celery klasycznie, bo Cloud Run go ubije gdy nie będzie ruchu HTTP. Cloud Run jest idealny dla aplikacji web/API. Jeśli potrzebujemy Celery na GCP, można albo użyć Cloud Run Jobs (do jednorazowych batch jobs), albo uruchomić workera na Cloud Run z hackiem (np. co pewien czas pingować endpoint żeby nie zdechł – zdecydowanie lepiej użyć GKE lub Compute Engine w takiej sytuacji, albo zamienić Celery na pub/sub + Cloud Functions).

2. Start aplikacji (cold start) – optymalizuj. Ponieważ Cloud Run może często startować nowe instancje (np. w skoku ruchu, albo po wygaszeniu z powodu bezczynności), czas zimnego startu jest krytyczny. Należy dążyć do tego, by kontener startował w sekundach, nie dziesiątkach sekund. Kilka rad:

  • Użyj mniejszego obrazu (tu plus dla slim/distroless – mniej do pobrania przy pierwszym starcie, choć Google twierdzi, że rozmiar obrazu nie wpływa na cold start wprost, bo oni cache'ują warstwy blisko wykonawcy (3 Ways to Optimize Cloud Run Response Times | by Stephanie Wong), to jednak mniejszy obraz = szybszy pull gdy cache brak).
  • Prekompiluj bytecode, jak wspomniano, aby nie tracić czasu na kompilację.
  • Unikaj wykonywania ciężkich operacji przy starcie aplikacji. Np. nie generuj ogromnych struktur, nie ładuj zbędnych danych do pamięci globalnie. Łącz się z bazą leniwie – tzn. dopiero przy pierwszym requestcie. Django z reguły tak działa (otwiera połączenie przy pierwszym zapytaniu). Jeśli jednak masz jakieś inicjalizacje globalne (np. w apps.py dużo logiki), rozważ przeniesienie ich na moment pierwszego użycia.
  • Cloud Run przydziela CPU domyślnie tylko podczas obsługi requestu (to ważne: w czasie bezczynności proces dostaje 0 CPU). To znaczy, że wszelkie prace tła (wątek pingujący? zapomnij – nie dostanie CPU jak nie ma ruchu). Można wymusić --cpu-always-allocated (ale to już odbiega od typowego serverless i generuje koszty). W kontekście Django oznacza to, że background threads w Django nie będą działać chyba że obsługujesz akurat request. Ale standardowy Django raczej nic w tle nie robi, więc jest ok.

3. Concurrency i workers. Cloud Run domyślnie pozwala na 80 równoległych requestów na instancję. Oznacza to, że jeśli Twój kontener jest single-thread (np. Django dev server) to w praktyce 79 requestów będzie czekać lub Cloud Run ewentualnie uruchomi kolejne instancje (nie pamiętam, jak on reaguje – zdaje się, że po wyczerpaniu CPU intensywności skaluje). Aby lepiej wykorzystać concurrency, uruchamiaj aplikację w trybie wielowątkowym lub wieloprocesowym. Najprościej: Gunicorn, np. 2 worker procesy * 2 wątki każdy, co da obsługę ~4 równoległych zapytań w jednym kontenerze. Możesz też ustawić concurrency Cloud Run na 1, wymuszając jednowątkowość i skalowanie instancji – ale to bywa mniej opłacalne (każda instancja ma narzut pamięci). Być może optymalnie jest concurrency 5-10 z kilkoma workerami. Musisz przetestować: Cloud Run mierzy obciążenie CPU i czas odpowiedzi; jeżeli pojedyncza instancja nie nadąża (przekracza limity czasowe lub CPU), uruchomi następną. Dla prostoty często się ustawia concurrency=1 i polega na skalowaniu (to na wzór AWS Lambda – wtedy każda instancja obsługuje jeden request na raz). Jednak kosztowo i wydajnościowo bardziej efektywnie jest obsłużyć kilka na jednym – byle nie przesadzić, bo jak dasz concurrency 80 i Gunicorna np. 4 workers, to 76 requestów będzie czekać w kolejce u Ciebie zamiast wywołać skalowanie.

4. Port i adres. Jak już wspomniano, Cloud Run wymaga nasłuchu na $PORT. Upewnij się, że nie nasłuchujesz na stałym porcie innym niż ten. W praktyce zazwyczaj Cloud Run daje PORT=8080, ale nie hardkoduj tego – użyj zmiennej. 0.0.0.0 również wymagane.

5. Zarządzanie sekretami w Cloud Run. Cloud Run integruje się z Secret Manager GCP – możesz w prosty sposób dodać sekret jako zmienną środowiskową (w konsoli GCP wskazujesz sekret i klucz, on go mapuje do env, ewentualnie z automatycznym odświeżaniem). To zalecany sposób – nie wpisuj tajnych wartości bezpośrednio, tylko korzystaj z Secret Managera (daje to centralne zarządzanie i rotację). Alternatywnie, Cloud Run obsługuje tzw. Konfiguracje, gdzie można ustawić zmienne, ale to jawne przechowywanie – Secret Manager jest bezpieczniejszy.

6. Skalowanie i limity Cloud Run. Cloud Run ma ustawienia max instances. Upewnij się, że ustawiłeś rozsądnie, żeby nie zaskoczył Cię rachunek lub limity. Np. możesz ograniczyć do 10 instancji max, co przy concurrency 10 da max 100 równoległych requestów – powyżej będą kolejki (albo błędy 429 jeśli kolejka pełna). To w małej skali ok, ale przy dużym ruchu trzeba monitorować. Pamiętaj, że Cloud Run ma timeout 15 minut na request – raczej nieproblemowe dla typowych stron (chyba że generujesz raporty itp., wtedy asynchronicznie).

7. Observability w Cloud Run. Google Cloud zapewnia Cloud Logging i Cloud Monitoring out-of-the-box. Zadbaj tylko, by logi były na stdout/stderr (co już mamy), wtedy pojawią się w Cloud Logging. Możesz dorzucić integrację z Error Reporting (np. nieschwycone wyjątki HTTP 500 zostaną tam zebrane – wystarczy odpowiednio sformatować log, GCP to wyłapie). Do profilowania i APM możesz użyć Google Cloud Profiler/Trace, integrując odpowiedni agent w aplikacji.

8. Architektura wokół. W Cloud Run często używa się Cloud SQL do bazy danych (zarządzana Postgres/MySQL). Pamiętaj, że połączenie do Cloud SQL wymaga użycia proxy lub przynajmniej odpowiedniej autoryzacji (Cloud SQL Auth Proxy jako sidecar lub bezpośrednio przez unix socket dzięki specjalnemu buildpackowi). W przypadku Django najlepiej użyć Cloud SQL Proxy w kontenerze (Google oferuje obraz proxy, który można połączyć z naszym przez Compose do testów lub w Cloud Run jako dodatkowy kontener – choć w Cloud Run długo dodatkowych kontenerów nie obsługiwano, obecnie jest composable services w niektórych wersjach). Możesz też wystawić Cloud SQL na publiczny adres i łączyć się normalnie (ale to mniej bezpieczne).

9. Domain i HTTPS. Na K8s wystawiasz przez Ingress lub Service typu LoadBalancer i sam dbasz o certyfikaty (np. cert-manager). W Cloud Run – dostajesz automatycznie domenę *.run.app i HTTPS. Możesz podłączyć własną domenę – zrób to, jeśli to produkcja, by nie korzystać z brzydkiego URL. To kwestia kilku kliknięć i ustawień DNS.

10. Testing – Przetestuj aplikację lokalnie (Docker) i w środowisku docelowym. Cloud Run pozwala np. odpytać url health, warto to zrobić po wdrożeniu. Sprawdź czy zmienne środowiskowe z Secret Managera się wczytały (np. zrób endpoint debugowy który wypisuje konfigurację – ale oczywiście nie pokazuj go publicznie w prod).

Na koniec: Cloud Run vs Kubernetes – Cloud Run wygrywa prostotą zarządzania (zero administrowania klastrem, autoskalowanie automagiczne), ale ma ograniczenia (tylko stateless HTTP apps). Kubernetes daje pełną kontrolę (możesz tam odpalić wszystko: Celery, Redis, cokolwiek), ale wymaga więcej utrzymania. Dobrze konteneryzowana aplikacja Django może działać na obu – co pokazuje elastyczność podejścia. W istocie, Cloud Run działa na KubernetES Knative pod spodem, więc wiele konceptów jest podobnych.

Podsumowanie dla Kubernetes: Doprecyzuj manifesty (Deployment, Service, Ingress), użyj readiness probe, trzymaj się zasad bezpieczeństwa (non-root, secrets, etc.). Podsumowanie dla Cloud Run: Zadbaj o szybki start, poprawną konfigurację portu i zmiennych, i pamiętaj o ograniczeniach (żadnych background jobs w tym samym kontenerze). W obu przypadkach konteneryzacja Django powinna odciążyć Cię od problemów "works on my machine" – raz zbudowany obraz będzie działał spójnie w każdym środowisku.

Podsumowanie

Konteneryzacja Django niesie wiele korzyści, ale wymaga świadomego podejścia do optymalizacji. Stosując powyższe praktyki – od lekkich obrazów bazowych (slim/Distroless) przez wielostopniowe budowanie i keszowanie warstw, po konfigurację aplikacji i orkiestrację – osiągniesz mniejszy obraz, szybsze wdrożenia, większą wydajność i bezpieczeństwo. Pamiętaj, że każda aplikacja może mieć swoją specyfikę, ale zasady takie jak “nie duplikuj warstw”, “traktuj config jako env, a logi jako strumień” (Docker Best Practices for Python Developers | TestDriven.io), “jedna usługa = jeden kontener” (The Definitive Guide to Celery and Django - Dockerizing Celery and Django | TestDriven.io), czy “nie umieszczaj sekretów w obrazie” (Docker Best Practices for Python Developers | TestDriven.io) (Docker Best Practices for Python Developers | TestDriven.io) są uniwersalne.