Blog
PLEN

Docker Compose w homelabie — 6 pułapek które mnie kosztowały

Docker compose to mój główny sposób na orchestrację serwisów u siebie. Pokazuję 6 konkretnych pułapek na które wpadłem przez ostatni rok i jak ich uniknąć.

·4 min read
Docker Compose w homelabie — 6 pułapek które mnie kosztowały

Mam u siebie ~25 docker-compose'ów. Każdy reprezentuje serwis. Po roku regularnego użycia zebrałem listę pułapek na które wpadłem. Każda kosztowała mi co najmniej godzinę. Pokazuję je tutaj żeby zaoszczędzić Tobie te godziny.

Pułapka #1: Python rebuild vs restart

Najgłośniejsza w mojej liście. Edytuję plik .py w kontenerze, robię docker compose restart, kod nadal stary.

# ŹLE - restart nie ładuje zmian w .py
docker compose restart backend
 
# DOBRZE - rebuild zmusza do nowego image'a
docker compose up -d --build backend

Powód: Python kompiluje do bytecode przy buildzie, zostaje w image. Restart container = stary bytecode. Rebuild = nowy bytecode.

To samo dotyczy: TypeScript po tsc, Java po mvn package, Go po go build, wszystko co jest pre-compiled w image.

Pułapka #2: bind mount + per-inode reload

Caddy. Edytuję Caddyfile na hoście, robię docker exec caddy caddy reload. Nic.

Powód: większość edytorów (vim, VSCode) zapisuje przez "write to temp + rename". Rename zmienia inode. Bind mount w Dockerze pamięta inode oryginalny. Docker widzi stary plik.

# Workaround:
docker restart caddy

Albo edytuj przez docker exec caddy vi (mało ergonomiczne). Albo użyj tmpfs mountu zamiast bind (wtedy rename działa, ale tracisz persystencję).

Pułapka #3: NO_PROXY i wewnętrzny DNS

Mam SOCKS5 proxy w niektórych projektach. Pewnego dnia wszystkie wewnętrzne hostname'y w docker compose zaczęły zwracać "name not resolved".

Powód: SOCKS5 proxy próbuje resolve'ować zewnętrznie. Wewnętrzne (np. bh-backend, hostname w docker network) tam nie istnieją.

# .bashrc lub /etc/environment
export NO_PROXY="172.16.0.0/12,localhost,127.0.0.1,*.local,bh-*,*.kamilkaletka.dev"

Każdy nowy projekt z dockerową siecią dopisuję wzorzec hostname'ów do NO_PROXY.

Pułapka #4: depends_on a wait-for-ready

depends_on: [postgres] mówi Dockerowi "uruchom postgres pierwszy". NIE mówi "poczekaj aż postgres będzie gotowy do połączeń".

Aplikacja startuje 2 sekundy po postgresie, postgres potrzebuje 5 sekund na initialize → app crash na "connection refused".

services:
  postgres:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10
 
  app:
    depends_on:
      postgres:
        condition: service_healthy

Healthcheck + condition: service_healthy rozwiązuje. Dłuższy boot, ale brak race condition'ów.

Pułapka #5: volumes vs bind mount dla danych

Pierwsza wersja:

volumes:
  - ./data:/app/data

Po roku przyszedł czas na backup. Robię tar ./data, ważyło 80GB. OK. Restoruję na drugiej maszynie. Permissions wszystko popsute.

Powód: bind mount dziedziczy permissions hosta. Postgres (UID 999 w container) zapisał z UID 999, na drugim hoście tego user'a może nie być.

Lepsza wersja:

volumes:
  postgres-data:
    driver: local
 
services:
  postgres:
    volumes:
      - postgres-data:/var/lib/postgresql/data

Named volume. Docker zarządza permissions. Backup przez docker run --rm -v postgres-data:/data alpine tar czf - /data | gzip > backup.tar.gz.

Pułapka #6: networks i domyślny bridge

Mam dwa compose'y. Jeden uruchamia service-a, drugi service-b. Chcę żeby się widziały.

Domyślnie każdy compose tworzy własną sieć. service-a nie widzi service-b.

# compose-a.yml
services:
  service-a:
    networks:
      - shared-net
 
networks:
  shared-net:
    external: true
# compose-b.yml — to samo
services:
  service-b:
    networks:
      - shared-net
 
networks:
  shared-net:
    external: true

Network shared-net muszę utworzyć ręcznie:

docker network create shared-net

Po tym oba compose'y używają tej samej sieci, hostname'y się resolve'ują między.

Bonus: porty i firewall

Domyślnie ports: ["3000:3000"] exposuje na WSZYSTKIE interfejsy hosta. W homelabie zwykle nie chcesz.

# Tylko localhost — bezpieczniej
ports:
  - "127.0.0.1:3000:3000"
 
# Tylko LAN, nie publiczny WAN
ports:
  - "192.168.1.100:3000:3000"

Jak masz Cloudflare tunnel jako jedyny exposure, używaj 127.0.0.1: żeby nawet host firewall nie był single point of failure.

Anti-pattern: latest tag

image: postgres:latest    # ŹLE
image: postgres:15.4      # DOBRZE

:latest znaczy "co Docker akurat ma cached". W homelabie to czasem wstrzyknie ci postgres 16 zamiast 15 i migracja nie zadziała. Pin major.minor minimum.


Docker compose to świetne narzędzie na poziomie homelab. Ale każde z tych 6 pułapek mnie kosztowało godzinę. Razem ~6 godzin straconego życia. Jeśli oszczędzę Tobie chociaż 2 godziny, post warto pisać.