Перейти до змісту

Docker — пакування та поставка ESWF / DOP

Все про Docker у цьому проекті: які образи бувають, як їх збирати, запускати, налаштовувати, дебажити, і як зробити нову поставку.


Зміст


Огляд

У репозиторії є один Docker-образ — Dockerfile.demo у корені. Це самодостатній односкладовий образ для оцінки продукту колегою / клієнтом: один контейнер, один порт, попередньо засіяна SQLite-база. Не призначений для production — для production використовується render.yaml (Render.com) та власні Postgres/CDN.

Файл Призначення
Dockerfile.demo Multi-stage білд: frontend (node:20) + runtime (python:3.13-slim)
.dockerignore Виключає mobile/, інші фронтенди, node_modules, venv, db.sqlite3
docker/nginx.conf nginx як reverse proxy на Daphne + статика
docker/supervisord.conf Менеджер процесів: запускає Daphne + nginx
backend/eswf/settings/demo.py Settings для контейнера: SQLite, ALLOWED_HOSTS=*, CORS відкритий
README.colleague.md Колезі-faced quick-start

Dockerfile.demo — мінімальний демо-образ

Що в ньому є

  • Frontend: тільки ERP (React 19 + Mantine 8). Portal / News / Shop не в образі.
  • Backend: усі core Django apps + плагін essentials_quality.
  • Плагіни не входять: logistic (немає в backend/), containerhub (хардово залежить від logistic.models.ContainerType, тому виключений у демо — інакше падає ModuleNotFoundError).
  • База: SQLite, файл /app/backend/db.sqlite3, попередньо засіяна через seed_demo --reset під час docker build. Перший запуск контейнера миттєвий — нічого не мігрується.
  • Дані: 6 модельних організацій, ~10 водіїв, ~11 ТЗ, 30 ПЛ, 12 замовлень, 3 cross-org sales scenarios + 1 purchase scenario з PartyLedger проводками, App Store з активаційними кодами і fake-розсилкою email. Усе з description українською (див. seed-methodology.md §2-quinquies).
  • Логін: admin / admin.

Ключові ENV

Змінна Default Призначення
DJANGO_SETTINGS_MODULE eswf.settings.demo Settings-модуль
DJANGO_SECRET_KEY demo-not-a-secret-... Не для production!
ESWF_PLUGINS essentials_quality Перелік активних плагінів (CSV або *)
CSRF_TRUSTED_ORIGINS (порожньо) За потреби — якщо колега виставляє образ за зовнішнім доменом

Структура образу

Stage 1: frontend-build (node:20-alpine)

WORKDIR /build/erp
COPY frontend/erp/package*.json → npm ci
COPY frontend/erp/ → npx vite build
                    └─→ /build/erp/dist/

Чому npx vite build, а не npm run build: у package.json сценарій build = tsc -b && vite build, а в репо є pre-existing TS-помилки. npx vite build пропускає type-check і збирає завжди (це задокументовано в CLAUDE.md, розділ Launchers).

Stage 2: runtime (python:3.13-slim)

apt-get → nginx + supervisor + libpq5 + curl
python -m venv /opt/venv     ← deps в окремий venv (predictable PATH)
pip install -r requirements.txt

WORKDIR /app/backend
COPY backend/ → /app/backend/
COPY --from=frontend-build /build/erp/dist/ → /var/www/erp/
COPY docker/nginx.conf       → /etc/nginx/nginx.conf
COPY docker/supervisord.conf → /etc/supervisor/conf.d/eswf.conf

ENV ESWF_PLUGINS="essentials_quality"
RUN: migrate core → bootstrap-tenant → migrate → drop bootstrap → seed_demo --reset → collectstatic

EXPOSE 80
CMD supervisord

Bootstrap-tenant dance

У RUN-кроці є нетривіальний фінт:

python manage.py migrate core --noinput              # тільки core (Tenant table)
python manage.py shell -c "...create _bootstrap_..."  # тимчасовий тенант
python manage.py migrate --noinput                    # решта
python manage.py shell -c "...delete _bootstrap_..."  # прибрати
python manage.py seed_demo --reset                    # реальні дані

Навіщо. Міграція essentials/0024_taxrate_predefined_item_tax_rate.py створює predefined TaxRate. На свіжій БД вона тригерить гілку "no tenants exist" і пише tenant=None, але поле tenant стане nullable лише в міграції 0025. На fresh DB → IntegrityError: NOT NULL constraint failed. Bootstrap-тенант форсує міграцію 0024 у per-tenant гілку, а 0025 потім переплавляє ці записи в глобальний (tenant=NULL).

Виправляти саму міграцію 0024 ризиковано (історія міграцій), тому фінт у Dockerfile є чистим обхідним шляхом для одноразового білду.


Команди для роботи з образом

Збірка

# Перший раз — повний білд (5–8 хв)
docker build -f Dockerfile.demo -t eswf-demo .

# Повторний — BuildKit перевикористає кеш до зміненого кроку (1–2 хв)
docker build -f Dockerfile.demo -t eswf-demo .

# Без кешу (форсовано все з нуля)
docker build --no-cache -f Dockerfile.demo -t eswf-demo .

# З додатковими ENV (наприклад, інший секретний ключ)
docker build --build-arg DJANGO_SECRET_KEY=mysecret -f Dockerfile.demo -t eswf-demo .

Запуск

# Foreground — Ctrl+C зупиняє і видаляє
docker run --rm -p 8080:80 eswf-demo

# Background, з ім'ям
docker run -d --name eswf -p 8080:80 eswf-demo

# Інший порт хоста
docker run --rm -p 9000:80 eswf-demo

# З персистентним томом (зберігає зміни в БД між запусками)
docker run -d --name eswf -p 8080:80 -v eswf-data:/app/backend eswf-demo

Увага про том: при першому запуску з томом том перекриває /app/backend з образу — БД буде ПУСТА. Треба зайти всередину і запустити seed: docker exec -it eswf python manage.py seed_demo --reset.

Зупинка / видалення

docker stop eswf                           # зупинити
docker rm eswf                             # видалити контейнер
docker rmi eswf-demo                       # видалити образ
docker system prune -a                     # видалити ВСІ невикористані образи + кеш (звільнити vhdx)

Де фізично лежать образи

Docker Desktop на Windows з WSL2-бекендом тримає всі образи всередині одного віртуального диска:

C:\Users\<USER>\AppData\Local\Docker\wsl\disk\docker_data.vhdx

Цей файл росте з кожним новим образом. Звільнити місце — docker system prune -a. Сам vhdx Windows не зменшує автоматично; для ручного стискання:

wsl --shutdown
diskpart
> select vdisk file="C:\Users\<USER>\AppData\Local\Docker\wsl\disk\docker_data.vhdx"
> attach vdisk readonly
> compact vdisk
> detach vdisk
> exit

WSL distro Docker Desktop:

C:\Users\<USER>\AppData\Local\Docker\wsl\main\ext4.vhdx

wsl -l -v показує docker-desktop distro (на новіших Docker Desktop — один distro замість двох).


Порти і конфлікти з dev-середовищем

Сервіс Порт Звідки слухає
Демо-контейнер 8080 → 80 (container) nginx у контейнері
Backend dev 8000 Daphne (host)
ERP dev 5173 Vite (host)
Portal dev 3000 Next.js (host)
News dev 5174 Vite (host)
Shop dev 5175 Next.js (host)

Контейнер слухає тільки мапнутий порт хоста (за замовчуванням 8080). Решта dev-портів вільні. Можна паралельно тримати docker run і launcher-dev.js без конфліктів.

Якщо потрібен інший порт хоста: docker run -p 9000:80 eswf-demo → http://localhost:9000/.


Налаштування процесів усередині контейнера

Менеджер процесів — supervisord (docker/supervisord.conf):

[program:daphne]
command=/opt/venv/bin/daphne -b 127.0.0.1 -p 8000 eswf.asgi:application
directory=/app/backend
environment=DJANGO_SETTINGS_MODULE="eswf.settings.demo",PYTHONUNBUFFERED="1"
priority=10

[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
priority=20
  • Daphne слухає 127.0.0.1:8000 (тільки всередині контейнера, не експортується назовні).
  • nginx на 80 — приймає публічний трафік, проксує /api/, /ws/, /admin/ на Daphne, віддає /static/ (Django collectstatic), /media/ (uploads) і ERP-SPA (try_files ... /index.html).
  • WebSocket — у nginx.conf є upgrade-map і окрема location /ws/.

Логи обох процесів пишуться в /dev/stdout і /dev/stderr, тож docker logs <container> показує все злито.

Зайти в контейнер живий:

docker exec -it eswf bash
supervisorctl status         # daphne RUNNING, nginx RUNNING
supervisorctl restart daphne # рестарт без перезапуску контейнера
tail -f /var/log/nginx/error.log

Додавання плагінів

Плагіни ESWF — це окремі Django apps, які додаються в INSTALLED_APPS динамічно через ESWF_PLUGINS env (див. plugin-instruction.md).

Плагіни що вже в backend/

  • essentials_quality — самостійний, працює зі стандартним демо.
  • containerhubзалежить від logistic (хардовий from logistic.models import ContainerType у containerhub/views.py). У демо без logistic — крашить URL resolution, тому виключений.

Плагіни в plugins/

  • plugins/eswf-logistic/logistic/ — Django app logistic, не на Python path. Має pyproject.toml, можна встановити через pip install ./plugins/eswf-logistic.

Як підключити logistic у образі

Додати в Dockerfile.demo після pip install -r requirements.txt:

COPY plugins/eswf-logistic /tmp/logistic-plugin
RUN pip install /tmp/logistic-plugin && rm -rf /tmp/logistic-plugin

І змінити:

ENV ESWF_PLUGINS="essentials_quality,containerhub,logistic"

Перевірити: docker exec eswf python -c "import logistic; print(logistic.__file__)".


Розширена поставка (full image)

Поточний Dockerfile.demo ставить тільки ERP. Щоб додати усі фронтенди + logistic, треба окремий Dockerfile.full з такими додатковими стадіями:

1. Frontend збірки (3 нові stage)

FROM node:20-alpine AS portal-build
WORKDIR /build/portal
COPY frontend/portal/package*.json ./
RUN npm ci
COPY frontend/portal/ ./
RUN npm run build           #  out/ (Next.js static export)

FROM node:20-alpine AS news-build
WORKDIR /build/news
COPY frontend/news/package*.json ./
RUN npm ci
COPY frontend/news/ ./
RUN npx vite build          #  dist/

FROM node:20-alpine AS shop-build
WORKDIR /build/shop
COPY frontend/shop/package*.json ./
RUN npm ci
COPY frontend/shop/ ./
RUN npm run build           #  .next/ (Next.js SSR)

2. Runtime — додати Node для shop SSR

FROM python:3.13-slim AS runtime
RUN apt-get install -y nodejs npm    # для запуску `next start` або `node`
COPY --from=portal-build /build/portal/out/   /var/www/portal/
COPY --from=news-build   /build/news/dist/    /var/www/news/
COPY --from=shop-build   /build/shop/         /app/shop/
COPY plugins/eswf-logistic /tmp/logistic-plugin
RUN pip install /tmp/logistic-plugin
ENV ESWF_PLUGINS="essentials_quality,containerhub,logistic"

3. nginx — multi-server для віртуальних хостів

server { server_name eswf.dev;       root /var/www/portal/; index index.html; ... }
server { server_name news.eswf.dev;  root /var/www/news/;   index index.html; ... }
server { server_name erp.eswf.dev;   root /var/www/erp/;    index index.html; try_files $uri /index.html; }
server { server_name shop.eswf.dev;  location / { proxy_pass http://127.0.0.1:3001; } }
server { server_name api.eswf.dev;   location / { proxy_pass http://127.0.0.1:8000; } }

4. supervisord — додати shop process

[program:shop]
command=/usr/bin/node /app/shop/node_modules/next/dist/bin/next start -p 3001
directory=/app/shop
priority=15

5. Стратегія URL — як колега відкриває різні фронтенди

Тут є три підходи, від найпростішого до найскладнішого:

A. Path-based (рекомендовано для демо)

Усе на одному порту, різні фронтенди — за різними префіксами шляху:

http://localhost:8080/         → Portal
http://localhost:8080/news/    → News
http://localhost:8080/shop/    → Shop
http://localhost:8080/erp/     → ERP
http://localhost:8080/api/     → Backend

nginx роздає за префіксом (location /erp/ { root /var/www; } тощо). Колезі — один URL, нуль налаштувань. Ціна: треба перезібрати фронтенди з відповідним base path (Vite base: '/erp/', Next.js basePath: '/shop'), і поправити будь-які абсолютні /api/ посилання у фронтендах на <base>/api/.

B. Port-based (нульова доробка фронтендів)

Кожен фронтенд на окремому порту хоста:

docker run -p 8080:80 -p 8081:81 -p 8082:82 -p 8083:83 eswf-full

http://localhost:8080/   → Portal
http://localhost:8081/   → Shop
http://localhost:8082/   → News
http://localhost:8083/   → ERP

nginx має кілька server { listen N; }. Швидко зробити, але колега бачить кілька різних URL — не дуже зручно.

C. Host-based (production-like, тільки за потреби)

http://eswf.dev:8080/        → Portal
http://shop.eswf.dev:8080/   → Shop
...

Колега додає в C:\Windows\System32\drivers\etc\hosts (як адмін):

127.0.0.1 eswf.dev shop.eswf.dev news.eswf.dev erp.eswf.dev api.eswf.dev

nginx розрізняє за server_name. Сенс тільки якщо у фронтенді хардкодом зашиті ці домени (fetch("https://api.eswf.dev/...")) і ти хочеш повністю production-like демо. Для оцінки продукту — overkill: колезі некомфортно правити hosts.

Рекомендація — варіант C з nip.io

Архітектурно правильний — host-based (C). У production воно вже саме так працює (eswf.dev, shop.eswf.dev, api.eswf.dev — окремі домени). Path-based (A) ламає Service Worker scope, Next.js Image, природний cookie/CSRF flow і генерує "другу версію" фронтендів, що розходиться з production. Port-based (B) виглядає некрасиво для колеги.

Бар'єр з hosts-файлом обходимо через публічні wildcard-DNS сервіси типу nip.io або sslip.io. Вони резолвлять *.127.0.0.1.nip.io127.0.0.1 без жодного налаштування на стороні колеги:

http://eswf.127.0.0.1.nip.io:8080/        → Portal
http://shop.127.0.0.1.nip.io:8080/        → Shop
http://news.127.0.0.1.nip.io:8080/        → News
http://erp.127.0.0.1.nip.io:8080/         → ERP
http://api.127.0.0.1.nip.io:8080/         → API

nginx розрізняє за server_name eswf.127.0.0.1.nip.io; тощо. Колега: - відкриває одну стартову адресу (наприклад, eswf.127.0.0.1.nip.io:8080); - по посиланнях у Portal переходить на ERP/Shop/News — все працює як у production; - нічого не править у hosts; - працює offline (DNS-резолюція nip.io відбулась один раз і кешується ОС).

Поточний репозиторій не містить Dockerfile.full — реалізація відкладена до Wave 2. Архітектурне рішення зафіксовано: варіант C з nip.io (host-based з публічним wildcard-DNS), щоб не ламати production-конфігурацію фронтендів.


Перенесення образу на іншу машину

Через .tar файл

# На збиральній машині
docker save eswf-demo:latest -o eswf-demo.tar      # ~600 MB

# На цільовій
docker load -i eswf-demo.tar
docker run -p 8080:80 eswf-demo

Через registry (Docker Hub / GitHub Container Registry)

docker tag eswf-demo:latest ghcr.io/eswf/demo:wave1
docker push ghcr.io/eswf/demo:wave1

# На цільовій
docker pull ghcr.io/eswf/demo:wave1
docker run -p 8080:80 ghcr.io/eswf/demo:wave1

Дебаг

Контейнер не стартує

docker logs <container-id>

Перші ~30 рядків — Daphne, далі — nginx. Якщо Daphne крашиться при старті (зазвичай через відсутній модуль або міграцію) — лог покаже traceback.

502 Bad Gateway від nginx

Daphne впав. Зайди всередину:

docker exec -it <container-id> bash
supervisorctl status      # очікувано: daphne RUNNING; якщо FATAL — див. логи
supervisorctl tail daphne stderr

Перевірити стан БД

docker exec -it <container-id> bash
sqlite3 /app/backend/db.sqlite3 ".tables"
sqlite3 /app/backend/db.sqlite3 "SELECT COUNT(*) FROM essentials_organization;"

Reset бази без перезбірки образу

docker exec -it <container-id> python manage.py seed_demo --reset

Подивитися що зашите в образі

docker run --rm -it eswf-demo bash
ls /app/backend/
ls /var/www/erp/
ls /etc/nginx/

Поширені проблеми

For security reasons C:\ProgramData\DockerDesktop must be owned by elevated account

Інсталер Docker Desktop знайшов залишок від попередньої спроби установки. Видали теку як адмін:

Remove-Item -Recurse -Force "C:\ProgramData\DockerDesktop"

Запусти інсталер Run as administrator.

ModuleNotFoundError: No module named 'logistic'

ESWF_PLUGINS=* намагається підключити плагін logistic, але його нема в backend/. Або встанови його з plugins/eswf-logistic/ (див. "Додавання плагінів"), або виключи з ESWF_PLUGINS.

containerhub/views.py теж хардово імпортує logistic.models → у full image сидять разом, у demo image — обидва виключені.

IntegrityError: NOT NULL constraint failed: essentials_taxrate.tenant_id

Міграція 0024 на свіжій БД хоче tenant=None, але в схемі цієї міграції поле NOT NULL (стане nullable у 0025). Рішення вшито в Dockerfile.demo як "bootstrap-tenant dance" (див. вище). Поза контейнером — створи Tenant(code='_bootstrap_') через manage.py shell перед migrate.

vhdx росте і не зменшується

Docker Desktop не зменшує docker_data.vhdx після видалення образів. Періодично:

docker system prune -a       # видалити невикористані образи/шари/кеш
wsl --shutdown
# далі diskpart compact vdisk (див. "Де фізично лежать образи")

Посилання