diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f32a9ed --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# VCS +.git +.github +.gitignore + +# Python artifacts +__pycache__ +*.py[oc] +*.egg-info +build +dist + +# Virtual environments and caches +.venv +.cache +.pytest_cache +.ruff_cache +.uv-cache + +# Secrets and local data +.env +*.sqlite3 +staticfiles + +# Docker +Dockerfile +docker-compose*.yml +.dockerignore + +# Tooling and docs +.pre-commit-config.yaml +Makefile +README.md + +# IDE +.idea +.vscode diff --git a/.gitignore b/.gitignore index 37c188f..297bb87 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ wheels/ # Logs and databases *.log *.sqlite3 +staticfiles/ .cache/ # Virtual environments diff --git a/Dockerfile b/Dockerfile index a5db3bf..cea9739 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,64 +1,81 @@ -ARG UID=1000 +# syntax=docker/dockerfile:1 + ARG PYTHON_VERSION=3.13 +FROM ghcr.io/astral-sh/uv:0.11 AS uv + + +# ============================== builder ============================== FROM python:${PYTHON_VERSION}-slim-trixie AS builder -RUN apt-get update && apt-get install -y \ - build-essential \ - libpq-dev \ - curl \ - && rm -rf /var/lib/apt/lists/* +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/opt/venv -RUN useradd --create-home --shell /bin/bash builder -USER builder -WORKDIR /home/builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* -COPY --chown=builder:builder --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +COPY --from=uv /uv /uvx /usr/local/bin/ -RUN uv venv .venv -ENV PATH="/home/builder/.venv/bin:$PATH" +WORKDIR /app -COPY --chown=builder:builder pyproject.toml uv.lock* ./ +COPY pyproject.toml uv.lock ./ +# --locked гарантирует точное соответствие uv.lock (сборка падает при рассинхроне) ARG INSTALL_DEV=false -RUN if [ "$INSTALL_DEV" = "true" ] ; then \ - uv sync --group dev ; \ +RUN --mount=type=cache,target=/root/.cache/uv \ + if [ "$INSTALL_DEV" = "true" ]; then \ + uv sync --locked --group dev; \ else \ - uv sync --no-group dev ; \ + uv sync --locked --no-group dev; \ fi +# ============================== runtime ============================== FROM python:${PYTHON_VERSION}-slim-trixie AS runtime -RUN apt-get update && apt-get install -y \ - libpq5 \ - netcat-openbsd \ - && rm -rf /var/lib/apt/lists/* - -ARG UID -RUN useradd --create-home --shell /bin/bash --uid ${UID:-1000} django -USER django -WORKDIR /home/django/app +LABEL org.opencontainers.image.title="django-boilerplate" \ + org.opencontainers.image.description="Production-ready Django API boilerplate" \ + org.opencontainers.image.source="https://github.com/ak4code/django_boilerplate" -COPY --from=builder --chown=django:django /home/builder/.venv /home/django/.venv - -ENV VIRTUAL_ENV="/home/django/.venv" -ENV PATH="$VIRTUAL_ENV/bin:$PATH" +ARG UID=1000 +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --shell /usr/sbin/nologin --uid "${UID}" django \ + && install -d -o django -g django /app /app/staticfiles + +ENV VIRTUAL_ENV=/opt/venv \ + PATH=/opt/venv/bin:$PATH \ + PYTHONPATH=/app \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 -COPY --chown=django:django . . +# venv принадлежит root: приложение не может модифицировать свои зависимости +COPY --from=builder /opt/venv /opt/venv +COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh -RUN mkdir -p /home/django/logs /home/django/www +WORKDIR /app -ENV PYTHONPATH=/home/django/app \ - PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 +USER django EXPOSE 8000 -# Entrypoint -COPY --chown=django:django entrypoint.sh /home/django/entrypoint.sh -RUN chmod +x /home/django/entrypoint.sh +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/', timeout=4)"] -ENTRYPOINT ["/home/django/entrypoint.sh"] +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["dev"] + + +# ============================= production ============================ +FROM runtime AS production + +COPY --chown=django:django . . + +CMD ["prod"] diff --git a/Makefile b/Makefile index 2658280..3de9965 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: env install run migrate makemigrations superuser test lint format up down logs +.PHONY: env install run migrate makemigrations superuser test lint format up down logs up-prod down-prod logs-prod env: @if [ -f .env ]; then \ @@ -42,3 +42,12 @@ down: logs: docker compose logs -f django + +up-prod: + docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build + +down-prod: + docker compose -f docker-compose.yml -f docker-compose.prod.yml down + +logs-prod: + docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f django diff --git a/README.md b/README.md index eeb3354..8a28995 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ | 👤 **Кастомный пользователь** | Email-логин + `external_id` (UUID) для интеграций | | 💓 **Health-check** | `/health/` с проверкой БД и Redis — готов для k8s-проб | | 🧹 **Качество кода** | ruff · bandit · ty · pre-commit · pytest · GitHub Actions | -| 🐳 **Docker** | Multi-stage образ (non-root) + compose с PostgreSQL и Redis | +| 🐳 **Docker** | Multi-stage образ (non-root, HEALTHCHECK) + compose dev/prod | ## 🚀 Быстрый старт @@ -196,13 +196,43 @@ make up / down # 🐳 docker compose up/down
`ENVIRONMENT=prod` включает: `DEBUG=False`, SSL redirect, HSTS, secure cookies, -JSON-only рендеринг DRF, манифестную статику (whitenoise) и запуск через gunicorn -(`MODE=prod` в docker-compose). +JSON-only рендеринг DRF, манифестную статику (whitenoise) и запуск через gunicorn. ```bash -ENVIRONMENT=prod MODE=prod INSTALL_DEV=false docker compose up -d --build +make up-prod +# или явно: +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build ``` +В prod-режиме код запекается в образ (таргет `production`, без bind-mount), +dev-зависимости не устанавливаются, порты PostgreSQL/Redis не публикуются, +на сервисы наложены лимиты CPU/памяти. + + + +
+🐳 Устройство Docker-окружения + +
+ +**Dockerfile** (multi-stage): + +| Стадия | Назначение | +| --- | --- | +| `builder` | Сборка venv через uv (`--locked`, BuildKit-кэш, прекомпиляция байткода) | +| `runtime` | Минимальный рантайм для разработки: non-root, venv принадлежит root | +| `production` | `runtime` + код, запечённый в образ | + +Образ содержит `HEALTHCHECK`, который опрашивает `/health/` (БД + Redis). + +**Compose** (три файла): + +| Файл | Когда применяется | +| --- | --- | +| `docker-compose.yml` | Общая база: сервисы, healthchecks, лимиты логов | +| `docker-compose.override.yml` | Dev: подхватывается автоматически — bind-mount кода, порты БД на localhost | +| `docker-compose.prod.yml` | Prod: подключается явно — таргет `production`, лимиты ресурсов | +
## 🧹 Качество кода diff --git a/config/settings/prod.py b/config/settings/prod.py index 5224290..121c64f 100644 --- a/config/settings/prod.py +++ b/config/settings/prod.py @@ -1,6 +1,11 @@ # Production settings DEBUG = False +# Healthcheck Docker/k8s ходит изнутри контейнера на 127.0.0.1 по HTTP +if '127.0.0.1' not in ALLOWED_HOSTS: # noqa: F821 + ALLOWED_HOSTS = [*ALLOWED_HOSTS, '127.0.0.1'] # noqa: F821 +SECURE_REDIRECT_EXEMPT = [r'^health/$'] + # Приложение работает за обратным прокси (nginx/traefik), который терминирует TLS SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_SSL_REDIRECT = env.bool('DJANGO_SECURE_SSL_REDIRECT', default=True) # noqa: F821 diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..3d6311b --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,21 @@ +# Dev-оверрайд: подхватывается `docker compose up` автоматически. +# Исходники монтируются в контейнер, порты БД доступны только с localhost. +services: + postgres: + ports: + - "127.0.0.1:5432:5432" + + redis: + ports: + - "127.0.0.1:6379:6379" + + django: + build: + target: runtime + args: + INSTALL_DEV: "true" + environment: + ENVIRONMENT: dev + volumes: + - .:/app + command: dev diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..c2983f1 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,32 @@ +# Production-оверрайд: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build +# Код запекается в образ (без bind-mount), порты БД не публикуются. +services: + django: + build: + target: production + args: + INSTALL_DEV: "false" + environment: + ENVIRONMENT: prod + command: prod + deploy: + resources: + limits: + cpus: "${DJANGO_CPU_LIMIT:-2}" + memory: ${DJANGO_MEMORY_LIMIT:-1g} + reservations: + cpus: "0.5" + memory: 256m + + postgres: + deploy: + resources: + limits: + memory: ${POSTGRES_MEMORY_LIMIT:-1g} + + redis: + deploy: + resources: + limits: + memory: 384m diff --git a/docker-compose.yml b/docker-compose.yml index e86aae9..7c98cf0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,16 @@ +# Общая конфигурация. Для разработки автоматически подхватывается +# docker-compose.override.yml, для production: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + services: postgres: image: postgres:18-alpine hostname: postgres - ports: - - "5432:5432" environment: POSTGRES_DB: ${POSTGRES_DB:-app} POSTGRES_USER: ${POSTGRES_USER:-app} @@ -17,14 +24,13 @@ services: retries: 5 start_period: 10s restart: unless-stopped + logging: *default-logging networks: - backend redis: image: redis:8-alpine hostname: redis - ports: - - "6379:6379" volumes: - redis_data:/data command: > @@ -39,6 +45,7 @@ services: retries: 5 start_period: 10s restart: unless-stopped + logging: *default-logging networks: - backend @@ -46,24 +53,18 @@ services: build: context: . dockerfile: Dockerfile - target: runtime args: PYTHON_VERSION: ${PYTHON_VERSION:-3.13} UID: ${UID:-1000} - INSTALL_DEV: ${INSTALL_DEV:-true} hostname: django + init: true ports: - "8000:8000" - volumes: - - .:/home/django/app - - ./.cache/logs:/home/django/logs - - ./.cache/www:/home/django/www - - uv_cache:/home/django/.cache/uv environment: - ENVIRONMENT: ${ENVIRONMENT:-dev} DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-config.settings} DATABASE_URL: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-app}@postgres:5432/${POSTGRES_DB:-app} REDIS_URL: redis://redis:6379/1 + REDIS_HOST: redis POSTGRES_HOST: postgres env_file: - ./.env @@ -72,15 +73,14 @@ services: condition: service_healthy redis: condition: service_healthy - command: ${MODE:-dev} restart: unless-stopped + logging: *default-logging networks: - backend volumes: pg_data: redis_data: - uv_cache: networks: backend: