Всем привет, меня зовут Аббакумов Валерий.

Я Python разработчик, в основном занимаюсь бэкэндом веб приложений и каждый раз когда дело доходит до разворачивания нового проекта по моей щеке начинает течь слеза.

Думаю, никто не будет спорить с тем, что локальное разворачивание нового проекта может вызвать неограниченное количество проблем. В данной статье я хочу представить выстраданную годами структуру проекта и организацию его окружения, которые помогают избежать большей части проблем, связанных с локальным разворачиванием проекта.

Пример будет представлен для Django проекта и PDM в качестве менеджера зависимостей, но концептуально должен подходить для любого проекта на любом языке и с любым набором сервисов. Так же у меня есть наработки для докерфайла с использованием Poetry, если это кому-то интересно, могу добавить информацию и для этого менеджера зависимостей.

Что будет в этой статье?

  1. Структура проекта. Как разложить все по полкам

  2. Управление переменными окружения. Как, где и почему лежат переменные

  3. Создание локальных файлов. Основной скрипт подготовки окружения для работы

  4. Docker. Описание docker-compose и Dockerfile файлов

  5. Запуск проекта. Основные команды

  6. Прочие файлы. Примеры некоторых файлов проекта, которые зачастую проблемно составить быстро и сразу

  7. Заключение. Краткий вывод и пожелания

Структура проекта

Очень часто можно наблюдать как корень проекта представляется своего рода свалкой без какой либо структуры, что в свою очередь значительно усложняет понимание того, как проект организован и как его запускать. Очень хорошо если присутствует хотя бы детально описанный README.md и еще лучше, если он постоянно актуализируется, но зачастую приходится спрашивать у своих коллег что вообще происходит в из репозиториях.

Очень важно группировать компоненты по их назначению, это автоматически формирует интуитивно понятную структуру. За 5 лет разработки я пришел к следующей минимальной структуре приложения, которая не перегружает корень проекта, является интуитивно понятной и достаточно гибкой для использования и расширения.

Важно отметить, что подобного рода структура наиболее эффективна именно для разработки на локальной машине, конечно же, для реального крупного приложения, работающего в production среде нужно куда больше. Масштабировать проще и лучше с k8s, про хранение секретов должна быть как минимум отдельная статья, а про CI-CD целый сборник статей. Но в любом случае, все описанное ниже никак не исключает хороший системный подход к разворачиванию и доставке кода на ваши сервера, но в огромной степени упрощает жизнь разработчиков, которые работают на своих тачках и вынуждены поднимать всю инфраструктуру локально.

├── .local_files # Локальные файлы проекта под gitignore
│   ├── asgi # Локальные файлы основного приложения
│   │   ├── log # Логи
│   │   │   └── ...
│   │   ├── media # Медиа файлы
│   │   │   └── ...
│   │   ├── static # Статика 
│   │   │   └── ...
│   │   ├── tmp # Временные файлы контейнера приложения
│   │   │   └── ...
│   │   └── ...
│   ├── celery # Локальные файлы celery
│   │   ├── log # Логи
│   │   │   └── ...
│   │   └── ...
│   ├── nginx # Локальные файлы nginx
│   │   ├── certs # Сертификаты
│   │   │   └── ...
│   │   ├── conf # Конфигурация nginx
│   │   │   └── ...
│   │   ├── log # Логи 
│   │   │   └── ...
│   │   └── ...
│   ├── postgres # Локальные файлы postgres
│   │   ├── backup # Директория с бэкапами
│   │   │   └── ...
│   │   ├── data # Данные бд (при удалении и перезапуске контейнера инициализируется новая бд)
│   │   │   └── ...
│   │   ├── log # Логи
│   │   │   └── ...
│   │   └── ...
│   ├── redis # Локальные файлы redis
│   │   ├── data # Данные redis
│   │   │   └── ...
│   │   ├── log # Логи
│   │   │   └── ...
│   │   └── ...
│   └── ...
├── docker # Директория для инструментов, и конфигурации docker
│   ├── composes # Опциональные расширения docker-compose файла
│   │   ├── docker-compose.dev.yml # DEV сборка приложения 
│   │   ├── docker-compose.prod.yml # PROD сборка приложения
│   │   ├── docker-compose.docs.yml # Собирать документацию
│   │   ├── docker-compose.build-asgi-locally.yml # Локальная сборка приложения 
│   │   ├── docker-compose.build-docs-locally.yml # Локальная сборка документации
│   │   ├── docker-compose.postgres.yml # Запускать  postgres в docker 
│   │   ├── docker-compose.redis.yml # Запускать  redis в docker 
│   │   ├── docker-compose.override.yml # Переопределение под gitignore
│   │   └── ...
│   ├── confs # Конфиги, которые прокидываются в volumes или dockerfiles
│   │   ├── nginx.dev.conf
│   │   ├── nginx.prod.conf
│   │   └── ...
│   ├── dockerfiles # Специфические докерфайлы
│   │   ├── asgi.dockerfile # Докерфайл приложения
│   │   ├── postgres.dockerfile # Докерфайл postgress
│   │   └── ...
│   ├── inits # Скрипты, выполняемые при первом запуске контейнера или сборке
│   │   ├── postgresql.init.sql
│   │   └── ...
│   └── ...
├── docs # Директория для документации, я использую mkdocs (Очень рекомендую, помогает делать красивые end to end странички для онбординга сотрудников в проект)
│   ├── index.md
│   └── ...
├── pipelines # пайплайны GitLab  
│   └── ...
├── scripts # Скрипты для быстрого доступа к частоиспользуемым командам
│   ├── bash.sh
│   ├── dbshell.sh
│   ├── init.sh
│   ├── manage.sh
│   ├── shell.sh
│   └── ...
├── src # Код самого приложения
│   ├── apps # Приложения с бизнес-логикой
│   │   ├── __init__.py
│   │   └── ...
│   ├── config # Конфигурация django приложения
│   │   ├── __init__.py
│   │   ├── asgi.py
│   │   ├── celery.py
│   │   ├── urls.py
│   │   └── ...
│   ├── contrib # Модули не привязанные к бизнес-логике
│   │   ├── __init__.py
│   │   └── ...
│   ├── plugins # Опциональные приложения с бизнес-логикой
│   │   ├── __init__.py
│   │   └── ...
│   ├── settings # Директория с настройками приложения
│   │   ├── __init__.py
│   │   ├── apps_settings.py # Настройки бизнес-логики
│   │   ├── base_settings.py # Настройки путей, режима работы, url
│   │   ├── deps_settings.py # Креды сторонних приложений
│   │   ├── django_settings.py # Специфические для django настройки
│   │   ├── local_settings.py # Локальные настройки под gitignore в большинстве случаев здесь импортятся все остальные настройки кроме 
│   │   ├── local_settings.sample.py # Пример локальных настроек
│   │   ├── test_settings.py # Настройки для тестов
│   │   └── ...
│   ├── __init__.py # Тут я указываю версию проекта __version__ = "0.0.1"
│   ├── manage.py # Не забудте указать os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.local_settings")
│   └── ...
├── .dockerignore # Файлы, которые игнорирует docker
├── .env # Автоматически генерируемый файл переменных окружения (под gitignore)
├── .env.base # Базовые переменные окружения
├── .env.local # Локальные переменные окружения (под gitignore)
├── .env.local.sample # Пример локальных переменных окружения
├── .gitignore # Файлы, которые игнорирует git
├── .gitlab-ci.yml # Конфигурация пайплайнов gitlab
├── .pre-commit-config.yaml # Конфигурация прекоммита
├── .prepare.sh # Скрипт подготовки окружения (выполняетс в compose)
├── compose # Утилита для вызова команд docker compose в нашем окружении
├── docker-compose.yml # Основной docker-compose файл
├── mkdocs.yml # Конфигурация докментации
├── pyproject.toml # Файл метаданных проекта
├── pdm.lock # Лок PDM (Возможно вы используете poetry или что-то еще)
├── CHANGELOG.md # Файл истории изменения версий
├── README.md # Файл с описанием проекта и взаимодействия с ним
└── ...
  • local_files # Локальные файлы проекта

    Локальные файлы проекта очень полезно хранить в отдельной директории на хосте под gitignore. Всегда можно получить доступ к старым логам, бэкапам, временным файлам, сертификатам, конфигурациям приложений и так далее, можно добавлять и удалять файлы сразу в контейнере оставаясь на хосте и не вызывая docker exec команды.
    Кстати, если вы очень хотите видеть содержание директорий типа data для postgres, redis и прочих сервисов, которые вы добавите сами и смонтируете, не забудьте пошарить права для хоста, иначе без sudo доступ к каталогам вы не получите, а следовательно ваша IDE будет отображать пустые каталоги.

  • docker # Директория для инструментов, и конфигурации docker

    Все что касается организации docker следует также выносить в отдельную директорию, более того файлы в этой директории можно группировать по назначению, а именно composes, dockerfiles, initsи confs , конечно это опционально и некоторые группы можно как убрать так и добавить, но не стоит перегружать директории разнородными файлами это усложняет понимание общей структуры

  • docs # Директория для документации, я использую mkdocs

    При установке mkdocstrings можно настроить автодокументирование вашей кодовой базы, ниже будет приведен пример настройки документации

  • pipelines # Пайплайны GitLab.

    Позволю себе не описывать настройку и всю боль, которую я получил от опыта настройки пайплайнов в GitLab, думаю ,это выходит за рамки данной статьи

  • scripts # Скрипты для быстрого доступа к часто используемым командам.

    Сюда лучше всего складывать все команды, которые так или иначе будут вызываться в командной строке в корне проекта, например вызов shell_plus, dbshell, manage.py, дамп бд и так далее. Для того чтобы не перегружать корень проекта в репозитории скрипты сгруппированы в отдельной директории, а локально для удобства на каждый скрипт в директории создается символическая ссылка в корне проекта

  • src # Код самого приложения

    Код приложения вынесен в отдельную директорию для удобства создания образа без всего лишнего. А также для логического разграничения окружения проекта и самой кодовой базы

  • .env* # Файлы с переменными окружения

    Вы можете заметить, что файлы с данного типа не выделены в отдельную группу каталогом, а лежат в каталоге. На это есть 2 причины: во-первых, вынося переменные окружения в подкаталог теряется интуитивная ясность того, что скорее всего нужно что-то заполнить, во-вторых .env файл лежит там же, где и основной docker-compose.yml файл, что позволяет не устраивать танцы с бубнами

Управление переменными окружения

В большинстве проектов в репозитории либо вообще нет никаких переменных окружения либо есть какой-то пример, который нужно значительно редактировать либо пример в котором не ясно, что нужно редактировать.

Зачастую мы имеем определенный набор переменных окружения, которые с большой долей вероятности не нужно будет изменять и которые в свою очередь не являются кредами. Данные переменные имеет смысл выносить в репозиторий отдельным файлом .env.base

# Основные настройки
# Префикс docker контейнеров
COMPOSE_PROJECT_NAME=<you_project>
# Файл настроек django
DJANGO_SETTINGS_MODULE=settings.local_settings
# Использовать debug режим
DEBUG=False
# Запущено в prod среде
PRODUCTION=False
# Количество запускаемых рабочих
WORKERS_COUNT=4

# Идентификация юзера
USER_NAME="$(echo $USER)"
USER_ID="$(id -u)"
GROUP_ID="$(id -g)"

# Образ приложения
# Путь до docker registry
DOCKER_REGISTRY=<you_project_docker_registry>
# Название образа
DOCKER_IMAGE=<you_project_docker_image>
# Тег образа
TAG=<you_project_docker_tag>

# Директории
# Корень проекта
PROJECT_DIR=$(cd $(dirname $(readlink -e $0)) && pwd)
# Директория с кодом приложения
SOURCE_DIR=${PROJECT_DIR}/src
# Директория docker
DOCKER_DIR=${PROJECT_DIR}/docker
# Директория с docker-compose файлами
DOCKER_COMPOSES_DIR=${DOCKER_DIR}/composes
# Директория с конфигурациями для различных серфисов
DOCKER_CONFS_DIR=${DOCKER_DIR}/confs
# Директория с Dockerfile
DOCKER_DOCKERFILES_DIR=${DOCKER_DIR}/dockerfiles
# Директория со скриптами инициализации
DOCKER_INITS_DIR=${DOCKER_DIR}/inits
# Директория с локальными файлами
LOCAL_FILES_DIR=${PROJECT_DIR}/.local_files

# Очень важно разделить ворты в сети докер и порты в сети хоста 
# чтобы была возможность менять порты с любой стороны по мере необходимости
# Порты на хосте
ASGI_PORT_EXTERNAL=8000
POSTGRES_PORT_EXTERNAL=5432
REDIS_PORT_EXTERNAL=6379
NGINX_PORT_EXTERNAL=80
FLOWER_PORT_EXTERNAL=5555
DOCS_EXTERNAL_PORT=8001

# Порты в сети докера
ASGI_PORT_INTERNAL=8000
POSTGRES_PORT_INTERNAL=5432
REDIS_PORT_INTERNAL=6379
NGINX_PORT_INTERNAL=80
FLOWER_PORT_INTERNAL=5555
DOCS_PORT_INTERNAL=8001

# Настройки docker-compose
# Основной файл docker-compose
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.yml"
# Запускать redis в докере
RUN_WITH_REDIS=True
# Запускать postgres в докере
RUN_WITH_POSTGRES=True
# Добавить docker-compose.override.yml
RUN_WITH_OVERRIDE_DOCKER_COMPOSE=True
# Собирать образ локаьно
RUN_WITH_BUILD_LOCALLY=False
# Запускать сервер с документацией
RUN_WITH_DOCS=False

# Настройки почты
MAIL_PORT=465
MAIL_SERVER=smtp.gmail.com
# Использовать S3
USE_S3_STORAGE=False
# Ключ для генерации токенов
SECRET_KEY=example_key

Помимо этого всегда есть необходимость определить или переопределить какие-то переменный локально. Для этого используем .env.local, который генерируется на основании .env.local.sample

# Основные настройки
DEBUG=True

# Настройки админ юзера, обычно я их тяну в manage.py команде при инициализации 
# проекта для автоматического создания юзера
ADMIN_USERNAME=admin
ADMIN_PASSWORD=password_example

# Собирать образ приложения локально (альтернатива стягиванию из регистри)
RUN_WITH_BUILD_LOCALLY=True
# Запустить сервер с документацией
RUN_WITH_DOCS=True

# Настройки postgres
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=password_example
POSTGRES_DB=postgres
POSTGRES_DEBUG=False

# Настройки redis
REDIS_HOST=redis
REDIS_PORT=6379

# Настройки почты
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM=${MAIL_USERNAME}
MAIL_PORT=465
MAIL_SERVER=smtp.gmail.com

# Настройки sentry
SENTRY_DSN=

# Настройки S3
USE_S3_STORAGE=False
AWS_STORAGE_BUCKET_NAME=
AWS_S3_ACCESS_KEY_ID=
AWS_S3_SECRET_ACCESS_KEY=
AWS_S3_ENDPOINT_URL=

Далее переменные, объявленные в этих 2 файлах будут собраны в итоговый .env файл для удобства использования

Создание локальных файлов

Приведенный ниже скрипт:

  • создает всю структуру локальных файлов проекта

  • создает локальные настройки, которые также находятся под gitignore:

    • для переменных окружения .env.local

    • для запуска контейнеров docker-compose.override.yml

    • для приложения local_settings.py

  • создает символические ссылки на часто используемые скрипты

  • создает общий .env файл, который используется для запуска приложения

  • записывает в переменные окружения конфигурацию запуска docker compose

#!/bin/bash

# Функция для вывода переменных, определенных во время исполнения скрипта 
print_script_variables() {
    set | sort | comm -13 .env.tmp -
}

# Записываем переменные окружения и функции оболочки во временный файл
set | sort > .env.tmp

# Импортируем базовые переменные окружения
source ".env.base"

# Создаем локальные файлы их их шаблонов, если они еще не созданы
cp -n ${PROJECT_DIR}/.env.local.sample ${PROJECT_DIR}/.env.local
cp -n ${SOURCE_DIR}/settings/local_settings.sample.py ${SOURCE_DIR}/settings/local_settings.py

# Импортируем локальные переменные окружения
if [[ -f .env.local ]]; then
  source ".env.local"
fi

# Создаем директорию с локальными фойлами
mkdir -p "${LOCAL_FILES_DIR}"

# Создаем символические ссылки на скрипты (сюда можно добавить создание и загрузку дампа бд)
ln -sf "${PROJECT_DIR}/scripts/bash.sh" "${PROJECT_DIR}/bash"
ln -sf "${PROJECT_DIR}/scripts/dbshell.sh" "${PROJECT_DIR}/dbshell"
ln -sf "${PROJECT_DIR}/scripts/manage.sh" "${PROJECT_DIR}/manage"
ln -sf "${PROJECT_DIR}/scripts/shell.sh" "${PROJECT_DIR}/shell"
ln -sf "${PROJECT_DIR}/scripts/init.sh" "${PROJECT_DIR}/init"

# Создание локальных файлов для postgres:
mkdir -p "${LOCAL_FILES_DIR}/postgres"
mkdir -p "${LOCAL_FILES_DIR}/postgres/log"
mkdir -p "${LOCAL_FILES_DIR}/postgres/data"
mkdir -p "${LOCAL_FILES_DIR}/postgres/backup"
# Создание локальных файлов для redis:
mkdir -p "${LOCAL_FILES_DIR}/redis"
mkdir -p "${LOCAL_FILES_DIR}/redis/log"
mkdir -p "${LOCAL_FILES_DIR}/redis/data"
# Создание локальных файлов для asgi:
mkdir -p "${LOCAL_FILES_DIR}/asgi"
mkdir -p "${LOCAL_FILES_DIR}/asgi/log"
mkdir -p "${LOCAL_FILES_DIR}/asgi/tmp"
mkdir -p "${LOCAL_FILES_DIR}/asgi/media"
# Создание локальных файлов для centry:
mkdir -p "${LOCAL_FILES_DIR}/celery"
mkdir -p "${LOCAL_FILES_DIR}/celery/log"
# Создание локальных файлов для nginx:
mkdir -p "${LOCAL_FILES_DIR}/nginx"
mkdir -p "${LOCAL_FILES_DIR}/nginx/conf"
mkdir -p "${LOCAL_FILES_DIR}/nginx/log"
mkdir -p "${LOCAL_FILES_DIR}/nginx/certs"

# Создания docker-compose.override.yml (под gitignore)
touch "${DOCKER_COMPOSES_DIR}/docker-compose.override.yml"

# Создание конфигов nginx из их шаблонов
export NGINX_PORT_INTERNAL=${NGINX_PORT_INTERNAL}
export ASGI_PORT_INTERNAL=${ASGI_PORT_INTERNAL}
export DOCS_PORT_INTERNAL=${DOCS_PORT_INTERNAL}
export HOST_NAME=${HOST_NAME}
# Следующие команды заполняют шаблон значениями переменных окружения
envsubst '${NGINX_PORT_INTERNAL},${ASGI_PORT_INTERNAL},${DOCS_PORT_INTERNAL},${HOST_NAME}' \
         < "${DOCKER_CONFS_DIR}/nginx.dev.conf" > \
         "${LOCAL_FILES_DIR}/nginx/conf/nginx.dev.conf"
envsubst '${NGINX_PORT_INTERNAL},${ASGI_PORT_INTERNAL},${DOCS_PORT_INTERNAL},${HOST_NAME}' \
         < "${DOCKER_CONFS_DIR}/nginx.prod.conf" > \
         "${LOCAL_FILES_DIR}/nginx/conf/nginx.prod.conf"

# Дополнение docker-compose.yaml в зависимости от значений переменных окружения
if [[ "${RUN_WITH_REDIS}" = "True" ]]; then
    COMPOSE_FILE="${COMPOSE_FILE}:${DOCKER_COMPOSES_DIR}/docker-compose.redis.yml"
fi
if [[ "${RUN_WITH_POSTGRES}" = "True" ]]; then
    COMPOSE_FILE="${COMPOSE_FILE}:${DOCKER_COMPOSES_DIR}/docker-compose.postgres.yml"
fi
if [[ "${RUN_WITH_OVERRIDE_DOCKER_COMPOSE}" = "True" ]]; then
    COMPOSE_FILE="${COMPOSE_FILE}:${DOCKER_COMPOSES_DIR}/docker-compose.override.yml"
fi
if [[ "${RUN_WITH_DOCS}" = "True" ]]; then
    COMPOSE_FILE="${COMPOSE_FILE}:${DOCKER_COMPOSES_DIR}/docker-compose.docs.yml"
fi
if [[ "${RUN_WITH_BUILD_LOCALLY}" = "True" ]]; then
    COMPOSE_FILE="${COMPOSE_FILE}:${DOCKER_COMPOSES_DIR}/docker-compose.build-asgi-locally.yml"
    if [[ "${RUN_WITH_DOCS}" = "True" ]]; then
      COMPOSE_FILE="${COMPOSE_FILE}:${DOCKER_COMPOSES_DIR}/docker-compose.build-docs-locally.yml"
    fi
fi
if [[ "${PRODUCTION}" = "False" ]]; then
    COMPOSE_FILE="${COMPOSE_FILE}:${DOCKER_COMPOSES_DIR}/docker-compose.dev.yml"
else
    COMPOSE_FILE="${COMPOSE_FILE}:${DOCKER_COMPOSES_DIR}/docker-compose.prod.yml"
fi

# Записываем в файл переменные окружения, объявленные в процессе выполнения текущего скрипта
EXCLUDE_RULES="^\(SHLVL=\|LINES=\|COLUMNS=\|BASH_SOURCE=\|BASH_LINENO=\|BASH_ARGV=\|BASH_ARGC=\|EXCLUDE_RULES=\|_=\|FUNCNAME=\|PIPESTATUS=\)"
print_script_variables | grep -v $EXCLUDE_RULES > ${PROJECT_DIR}/.env

# Удаляем временный файл
rm -f .env.tmp

Данный скрипт вызывается при каждом запуске compose с помощью которого мы в взаимодействуем с приложением, подробнее будет описано ниже

Примечание:
Начало и конец скрипта, связанные с вычленением объявленных переменных конечно же является ужаснейшим костылем, но никакого более элегантного решения я самостоятельно найти не смог. Буду очень рад, если кто-то предложит красивую альтернативу, не требующую установки сторонних приложений, не присутствующих в стандартных оболочках

Docker

Мы будем использовать набор из нескольких docker-compose*.yaml файлов а также много стадийный Dockerfile

Основные рекомендации по docker-compose файлам:

  • Выделяйте общие части в переменные

  • Используйте абсолютные пути в volumes во избежание конфликтов с другими проектами

  • Старайтесь по-максимуму использовать переменные окружения и по-минимуму хардкод

  • Не забывайте указывать зависимости и помните, что иногда вам придется добавлять кастомный скрипт для проверки доступности приложения в контейнере

  • Помните, что вы можете тонко настроить выделение ресурсов, сети, маски подсетей и так далее. А еще существует docker swarm если вы ну очень не хотите использовать k8s, в любом случае, читайте документацию

Основной файлdocker-compose.yaml
Здесь мы описываем сервисы, которые не являются опциональными либо те, что по логическим соображениям должны находиться вместе, тут все на ваше усмотрение, так как всегда есть возможность добавить любой сервис в отдельном файле

# Выделяем основную часть настроек приложения в переменную, чтобы не повторяться
x-asgi-base: &asgi-base
  # Тянем образ из нашего docker-registry
  image: ${DOCKER_REGISTRY}/${DOCKER_IMAGE}:${TAG}
  restart: on-failure
  user: ${USER_ID}:${GROUP_ID}
  # Прокидываем переменные окружения
  env_file:
    - .env
  # Все наши сервисы для удобства будут в одной сети
  networks:
    - app-network

services:
  # Сервис основного приложения
  asgi:
    # Вставляем часть документа, описанную выше
    <<: *asgi-base
    hostname: asgi
    container_name: ${COMPOSE_PROJECT_NAME}-asgi
    # Прокидываем порты. Делаем разделение настройки внешних и внутренних портов для того,
    # чтобы можно было запускать несколько подобных приложений без необходимости изменять порты внутри докера
    ports:
        - "${ASGI_PORT_EXTERNAL}:${ASGI_PORT_INTERNAL}"
    # Важно маунтить volumes по абсолютным путям так как с относительными могут возникнуть конфликты в нейминге
    volumes:
      - ${LOCAL_FILES_DIR}/asgi/log/:/var/log/asgi/
      - ${LOCAL_FILES_DIR}/asgi/tmp/:/tmp/
      - ${LOCAL_FILES_DIR}/asgi/static:/mnt/resource/static
      - ${LOCAL_FILES_DIR}/asgi/media:/mnt/resource/media

  # Сервис периодических задач
  celery-beat:
    # Аналогично asgi
    <<: *asgi-base
    hostname: celery-beat
    container_name: ${COMPOSE_PROJECT_NAME}-celery-beat
    command: bash -c '
      celery -A config beat -l info --logfile=/var/log/celery/celery_beat.log -s celerybeat-schedule'
    volumes:
      - ${LOCAL_FILES_DIR}/celery/log/:/var/log/celery/
      - ${LOCAL_FILES_DIR}/celery/log/:/var/log/asgi/
      - ${LOCAL_FILES_DIR}/asgi/tmp/:/tmp/
      - ${LOCAL_FILES_DIR}/asgi/static:/mnt/resource/static
      - ${LOCAL_FILES_DIR}/asgi/media:/mnt/resource/media

  # Сервис дефолтной очереди задач (конечно же можно /нужно увеличить количество очередей)
  celery-default:
    # Аналогично asgi
    <<: *asgi-base
    hostname: celery-default
    container_name: ${COMPOSE_PROJECT_NAME}-celery-default
    command: bash -c '
      celery -A config worker -l info --concurrency=5 --logfile=/var/log/celery/celery_default.log -Q default'
    volumes:
      - ${LOCAL_FILES_DIR}/celery/log/:/var/log/celery/
      - ${LOCAL_FILES_DIR}/celery/log/:/var/log/asgi/
      - ${LOCAL_FILES_DIR}/asgi/tmp/:/tmp/
      - ${LOCAL_FILES_DIR}/asgi/static:/mnt/resource/static
      - ${LOCAL_FILES_DIR}/asgi/media:/mnt/resource/media

  # Сервис мониторинга celery задач
  flower:
    # Аналогично asgi
    <<: *asgi-base
    hostname: flower
    container_name: ${COMPOSE_PROJECT_NAME}-flower
    command: bash -c 'celery -A config flower --address=0.0.0.0 --port=${FLOWER_PORT_INTERNAL}'
    ports:
      - "${FLOWER_PORT_EXTERNAL}:${FLOWER_PORT_INTERNAL}"
    volumes:
      - ${LOCAL_FILES_DIR}/celery/log/:/var/log/celery/
      - ${LOCAL_FILES_DIR}/celery/log/:/var/log/asgi/
      - ${LOCAL_FILES_DIR}/asgi/tmp/:/tmp/
      - ${LOCAL_FILES_DIR}/asgi/static:/mnt/resource/static
      - ${LOCAL_FILES_DIR}/asgi/media:/mnt/resource/media

  # HTTP-сервис
  nginx:
    image: nginx:1.25.3
    hostname: nginx
    container_name: ${COMPOSE_PROJECT_NAME}-nginx
    restart: on-failure
    ports:
      - "${NGINX_PORT_EXTERNAL}:${NGINX_PORT_INTERNAL}"
    volumes:
      - ${LOCAL_FILES_DIR}/nginx/log/:/var/log/nginx/
      - ${LOCAL_FILES_DIR}/asgi/tmp/:/tmp/
      # Важно не забыть прокинуть статику и медиафайлы основного приложения
      - ${LOCAL_FILES_DIR}/asgi/static:/mnt/resource/static
      - ${LOCAL_FILES_DIR}/asgi/media:/mnt/resource/media
    depends_on:
      - asgi
    networks:
      - app-network

networks:
  app-network:
    name: ${COMPOSE_PROJECT_NAME}

Файл для разработки docker/composes/docker-compose.dev.yaml
Тут мы просто расширяем наш основной docker-compose.yaml необходимыми для среды разработки элементами, а именно маунтим volumes для возможности установки библиотек через менеджер зависимостей, а также маунтим код проекта для поддержки работы в режиме hot-reload

services:
  # Прописываем команду для запуска dev сервера
  asgi:
    command: bash -c 'python manage.py runserver 0.0.0.0:${ASGI_PORT_INTERNAL}'
    # Прокидываем код, pdm и pyproject.toml для возможности установки библиотек
    volumes:
      - ${SOURCE_DIR}/:/project/src/
      - ${PROJECT_DIR}/pdm.lock:/project/pdm.lock
      - ${PROJECT_DIR}/pyproject.toml:/project/pyproject.toml

  celery-beat:
    volumes:
      - ${SOURCE_DIR}/:/project/src/
  celery-default:
    volumes:
      - ${SOURCE_DIR}/:/project/src/
  flower:
    volumes:
      - ${SOURCE_DIR}/:/project/src/

  # Прокидыываем dev конфигурацию nginx
  nginx:
    volumes:
      - ${LOCAL_FILES_DIR}/nginx/conf/nginx.dev.conf:/etc/nginx/conf.d/default.conf

Файл для прод среды docker/composes/docker-compose.prod.yaml
Понимаю, что "Для прод среды" - сильно громко сказано, но суть в том, что мы уже не хотим синхронизировать код на хосте и в контейнере и хотим иметь более чем одного запущенного воркера. В реальном проекте, конечно такой конфиг может быть в prod окружении, но я бы все-таки порекомендовал обратиться к DevOps инженерам.

services:
  # Прописываем команду для запуска gunicorn на uvicorn воркерах
  asgi:
    command: bash -c 'python -m gunicorn config.asgi:application --bind 0.0.0.0:8000 --worker-class uvicorn.workers.UvicornWorker --timeout 180 --workers $WORKERS_COUNT --log-level debug --access-logfile - --error-logfile - --pid /tmp/gunicorn.pid'

  # Прокидыываем prod конфигурацию nginx
  nginx:
    volumes:
      - ${LOCAL_FILES_DIR}/nginx/conf/nginx.prod.conf:/etc/nginx/conf.d/default.conf

Файл для запуска сервера документации docker/composes/docker-compose.docs.yml
По-хорошему этот сервис должен запускаться только во время локальной разработки. Mkdocs имеет возможность собрать dist проекта, что я рекомендую делать в пайплайнах и уже раздавать статику на ваших серверах

services:
  # Сервис документации
  docs:
    image: ${DOCKER_REGISTRY}/${DOCKER_IMAGE}:${TAG}
    container_name: ${COMPOSE_PROJECT_NAME}-docs
    env_file:
      - ${PROJECT_DIR}/.env
    environment:
      - LOCAL_PYTEST=1
      - ENVIRONMENT_TYPE=docker
    volumes:
      - ${PROJECT_DIR}:/src
      - ${LOCAL_FILES_DIR}/asgi/static:/mnt/resource/static
      - ${LOCAL_FILES_DIR}/asgi/media:/mnt/resource/media
      - ${LOCAL_FILES_DIR}/asgi/tmp:/tmp
    # Запускаем веб сервер документации
    command: mkdocs serve --dev-addr 0.0.0.0:${DOCS_PORT_INTERNAL}
    ports:
      - "${DOCS_EXTERNAL_PORT}:${DOCS_PORT_INTERNAL}"
    networks:
      - app-network

Файл для redis docker/composes/docker-compose.redis.yml
Наверное самая простая часть во всем нашем окружении, честно говоря, множество сервисов можно описать примерно так же и этого будет достаточно для большей части задач, но надеюсь, что в данной статье описано достаточно кейсов для того, чтобы вы не ограничивались минимальными возможностями настройки вашего окружения

services:
  redis:
    image: redis:7.4.0
    container_name: ${COMPOSE_PROJECT_NAME}-redis
    networks:
      - app-network
    ports:
      - "${REDIS_PORT_EXTERNAL}:${REDIS_PORT_INTERNAL}"

  # Добавляем redis в качестве зависимости
  asgi:
    depends_on:
      - redis
  celery-default:
    depends_on:
      - redis
  celery-beat:
    depends_on:
      - redis
  flower:
    depends_on:
      - redis

Файл для postgres docker/composes/docker-compose.postgres.yml
Для postgres использую кастомный Dockerfile так как по непонятным мне причинам в дефолтном образе отсутствует утилита для создания дампа

services:
  postgres:
    # Собираем образ
    build:
      context: ${PROJECT_DIR}
      dockerfile: ${DOCKER_DOCKERFILES_DIR}/postgres.dockerfile
    container_name: ${COMPOSE_PROJECT_NAME}-postgres
    command: ["postgres", "-c", "log_statement=all"]
    shm_size: 1g
    # Прокидываем скрипт инициализации и локлаьные файлы
    volumes:
      - ${DOCKER_INITS_DIR}/postgresql.init.sql:/docker-entrypoint-initdb.d/init.sql
      - ${LOCAL_FILES_DIR}/postgres/data/:/var/lib/postgresql/data/
      - ${LOCAL_FILES_DIR}/postgres/log/:/var/log/postgresql/
      - ${LOCAL_FILES_DIR}/postgres/backup/:/backup/
    # Прокидываем переменные для конфигурации базы
    environment:
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_DB=${POSTGRES_DB}
    ports:
      - "${POSTGRES_PORT_EXTERNAL}:${POSTGRES_PORT_INTERNAL}"
    networks:
      - app-network

  # Добавляем postgres в качестве зависимости
  asgi:
    depends_on:
      - postgres
    links:
      - postgres:postgres
  celery-default:
    depends_on:
      - postgres
    links:
      - postgres:postgres
  celery-beat:
    depends_on:
      - postgres
    links:
      - postgres:postgres
  flower:
    depends_on:
      - postgres
    links:
      - postgres:postgres

Файл для локальной сборки приложения docker/composes/docker-compose.build-asgi-locally.yml
Данный файл необходим, когда вы не можете / не хотите / вынуждены собрать образ приложения локально, например вы добавляете библиотеку

x-asgi-local-build: &asgi-local-build
  image: app:latest
  # Собираем образ из нужного слоя докерфайла
  build:
    context: ${PROJECT_DIR}
    dockerfile: ${DOCKER_DOCKERFILES_DIR}/asgi.dockerfile
    target: development
    args:
      - USER_NAME=${USER_NAME}
      - USER_ID=${USER_ID}
      - GROUP_ID=${GROUP_ID}

# Обновляем сборку образа в сервисах
services:
  asgi:
    <<: *asgi-local-build
  celery-beat:
    <<: *asgi-local-build
  celery-default:
    <<: *asgi-local-build
  flower:
    <<: *asgi-local-build

Файл для локальной сборки документации docker/composes/docker-compose.build-docs-locally.yml
Этот файл является своего рода аппендиксом, так как я не придумал, как иначе разрешить кейс, когда, мне нужно локально собрать образ приложения, но не нужно запускать сервис документации

services:
  docs:
    image: app:latest
    build:
      context: ${PROJECT_DIR}
      dockerfile: ${DOCKER_DOCKERFILES_DIR}/asgi.dockerfile
      target: development
      args:
        - USER_NAME=${USER_NAME}
        - USER_ID=${USER_ID}
        - GROUP_ID=${GROUP_ID}

Файл сборки postgresdocker/dockerfiles/postgres.dockerfile

FROM postgres:16.4-bullseye
# Устанавливаем утилиту для возможности создания дампа
RUN apt-get -y update
RUN apt-get -y install postgresql-16-repack

Файл сборки приложенияdocker/dockerfiles/postgres.dockerfile
На самом деле ниже описана квинтэссенция моих познаний и обрывков организации Dockerfile которые я успел увидеть за всю мою практику программирования. Еще 2 редакции этого файла назад я думал, что в нем уже нечего исправлять, но как вы, наверное, догадались, с тех пор файл претерпел значительные изменения, потому, буду безмерно рад любым советам по улучшению данного файла

# ---STAGE_1---
# Описываем стадию базового  python  образа
# Указываем платформу сборки через переменную окружения
FROM --platform=$BUILDPLATFORM python:3.12.6-slim AS python-base

# Определяем аргументы, которые можно будет передать как аргументы сборки
ARG USER_ID=1000
ARG GROUP_ID=1000
ARG USER_NAME="user"
ARG GROUP_NAME="user"

# Описываем базовое окружение для python
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=off \
    PIP_DISABLE_PIP_VERSION_CHECK=on \
    PIP_DEFAULT_TIMEOUT=10 \
    PDM_REQUEST_TIMEOUT=10 \
    PDM_VERSION=2.19.1 \
    PDM_CACHE_DIR="/opt/.cache/pdm" \
    PDM_LOG_DIR="/opt/.local/pdm" \
    PDM_CHECK_UPDATE=false \
    PDM_HOME="/opt/pdm" \
    PROJECT_PATH="/project" \
    APPLICATION_PATH="/project/src" \
    VENV_PATH="/project/.venv" \
    TZ="Europe/Moscow" \
    LANG="en_US.UTF-8" \
    LC_ALL="en_US.UTF-8"

# Добавляем PDM_HOME в PATH
ENV PATH="$PDM_HOME/bin:$VENV_PATH/bin:$PATH"

# ---STAGE-2---
# Описываем стадию сборки pdm
FROM python-base AS base-pdm-builder

# Устанавливаем все, необходимые зависимости для сборки
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    DEBCONF_NONINTERACTIVE_SEEN=true DEBIAN_FRONTEND=noninteractive apt-get update  \
    &&  apt-get install -y -qq --no-install-recommends  \
        build-essential \
        ca-certificates \
        gcc \
        git \
        curl \
        pkg-config \
        libcairo2-dev \
        libjpeg-dev \
        libgif-dev \
        python3-venv \
    && curl -sSL https://pdm-project.org/install-pdm.py | python3 - \
    # отчищаем кэш для уменьшения размера образа
    && apt-get clean \
    && rm -rf /tmp/* /var/lib/apt/lists/*

# Указвыаем рабочую директорию
WORKDIR $PROJECT_PATH

# Прокидываем файлы, необходимые для корректной работы pdm
COPY --link ./pdm.lock ./pyproject.toml ./README.md ./
COPY --link ./src/__init__.py $APPLICATION_PATH/__init__.py

# ---STAGE-3---
# Стадия сборки зависимостей приложения для dev среды
FROM base-pdm-builder AS development-pdm-builder

RUN pdm install --check --dev

# ---STAGE-4---
# Стадия сборки зависимостей приложения для test среды
FROM base-pdm-builder AS test-pdm-builder

RUN pdm install --check -G test --no-self

# ---STAGE-5---
# Стадия сборки зависимостей приложения для prod среды
FROM base-pdm-builder AS production-pdm-builder

RUN pdm install --check --production --no-self

# ---STAGE-6---
# Базовая стадия сборки зависимостей для python образа
FROM python-base AS base-project-builder

ARG USER_ID
ARG GROUP_ID

WORKDIR $APPLICATION_PATH

# Устанавливаем все, необходимые зависимости для работы приложения
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    DEBCONF_NONINTERACTIVE_SEEN=true DEBIAN_FRONTEND=noninteractive apt-get update  \
    &&  apt-get install -y -qq --no-install-recommends  \
        locales \
        libmagic1 \
        pkg-config \
        libcairo2-dev \
        libjpeg-dev \
        libgif-dev \
    # Настраиваем язык
    && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \
    && dpkg-reconfigure --frontend=noninteractive locales \
    && update-locale LANG=en_US.UTF-8 \
    # Настраиваем пользователя и группу
    && addgroup --gid $GROUP_ID $GROUP_NAME \
    && adduser -q --gecos '' --disabled-password --ingroup $GROUP_NAME --shell /bin/bash --uid $USER_ID $USER_NAME \
    # Отчищаем кэш
    && apt-get clean \
    && rm -rf /tmp/* /var/lib/apt/lists/* \
    # Создаем необходимые директории и файлы
    && mkdir -p /mnt/resource/static \
    && mkdir -p /mnt/resource/media \
    && mkdir -p $APPLICATION_PATH \
    && touch $APPLICATION_PATH/celerybeat-schedule \
    # Настраиваем права к директориям
    && chown -R $USER_ID:$GROUP_ID /tmp/ \
    && chown -R $USER_ID:$GROUP_ID /mnt/ \
    && chown -R $USER_ID:$GROUP_ID /opt/ \
    && chown -R $USER_ID:$GROUP_ID /project/ \
    && chown -R $USER_ID:$GROUP_ID /var/log/

# ---STAGE-7---
# Стадия сборки приложения для dev среды
FROM base-project-builder AS development

# Копируем необходимые данные из предыдущих стадий
COPY --link --chown=$USER_ID:$GROUP_ID --from=development-pdm-builder $PDM_HOME $PDM_HOME
COPY --link --chown=$USER_ID:$GROUP_ID --from=development-pdm-builder $PROJECT_PATH $PROJECT_PATH
# Копируем код приложения с хоста
COPY --link ./src $APPLICATION_PATH
# Копируем конфигурацию и наполнение документации
COPY --link ./docs $APPLICATION_PATH/docs
COPY --link ./mkdocs.yml $APPLICATION_PATH/mkdocs.yml

# Создвем символическую ссылку на pyproject.toml для корректной работы PDM  и удаляем не нужные в образе файлы
RUN ln -s $PROJECT_PATH/pyproject.toml $APPLICATION_PATH/pyproject.toml \
    && find . -name "*.pyc" -delete

# Меняем пользователя
USER $USER_NAME

# ---STAGE-8---
# Стадия сборки приложения для test среды
FROM base-project-builder AS test

# Копируем необходимые данные из предыдущих стадий
COPY --link --from=test-pdm-builder $PDM_HOME $PDM_HOME
COPY --link --from=test-pdm-builder $PROJECT_PATH $PROJECT_PATH
# Копируем код приложения с хоста
COPY --link ./src $APPLICATION_PATH


RUN chown $USER_ID:$GROUP_ID $PROJECT_PATH/pyproject.toml \
    && ln -s $PROJECT_PATH/pyproject.toml $APPLICATION_PATH/pyproject.toml \
    && find . -name "*.pyc" -delete

# Меняем пользователя
USER $USER_NAME

# ---STAGE-9---
# Стадия сборки приложения для prod среды
FROM base-project-builder AS production

# Копируем необходимые данные из предыдущих стадий
COPY --link --from=production-pdm-builder $PDM_HOME $PDM_HOME
COPY --link --from=production-pdm-builder $PROJECT_PATH $PROJECT_PATH
# Копируем код приложения с хоста
COPY --link ./src $APPLICATION_PATH

# Меняем пользователя
USER $USER_NAME

Запуск проекта

Зависимости:

  • python version - 3.12.6

  • docker - 27.2.1

Точка входа

Запуск проекта осуществляется с помощью скрипта compose
По факту он просто подготавливает окружение, подтягивает переменные среды и прокидывает передаваемые аргументы в docker compose.

Если вы используете более раннюю версию docker вам нужно использовать команду docker-compose вместо docker compose

#!/bin/bash

bash .prepare.sh
source ".env"

ARGS_STRING=""
while [ $1 ]
    do
        ARGS_STRING+="$1 "
        shift
    done

docker compose ${ARGS_STRING}

Далее все как обычно

./compose up -d --build

./compose restart

./compose down

./compose exec -it ...

...

Управление зависимостями

Для управления зависимостями используется pdm v2.19.1

Чтобы добавить зависимость используйте команду

./compose exec -it asgi pdm add <dependency>

Чтобы добавить в группу зависимость используйте команду

./compose exec -it asgi pdm add -dG <group> <dependency>

Чтобы удалить зависимость используйте команду

./compose exec -it asgi pdm remove <dependency>

Прочие файлы

В данном разделе представлены файлы из моего каркаса приложения, которые также могут быть интересны в формате текущей статьи но не несут за собой фундаментальной значимости для освещаемой темы

Пример конфигурации pyproject.toml
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"

[project]
name = "YOUR_PROJECT"
dynamic = ["version"]
description = "Основное приложение для YOUR_PROJECT"
readme = "README.md"
requires-python = ">=3.12"
authors = [
    { name = "Your Name", email = "your@email.ru"},
]
maintainers = [
    { name = "Your Name", email = "your@email.ru"},
]

classifiers = [
    "Development Status :: 3 - Alfa",
    "Operating System :: OS Independent",
    "Programming Language :: Python",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3 :: Only",
    "Programming Language :: Python :: 3.12",
    "Framework :: Django",
    "Framework :: Pydantic",
]

# Набор библиотек, которые вам скорее всего понадобятся, если вы что-то делаете на django
dependencies = [
    # Чисто django
    "django>=5.1.1",
    "djangorestframework>=3.15.2",
    "django-extensions>=3.2.3",
    # Для CORS в джанго
    "django-cors-headers>=4.4.0",
    "whitenoise>=6.7.0",
    # Для работы с celery
    "celery>=5.4.0",
    "django-celery-beat>=2.7.0",
    "django-celery-results>=2.5.1",
    "flower>=2.0.1",
    # Для работы с redis (в новых версиях с ним теперь можно работать и синхронно и асинхронно)
    "redis>=5.1.0",
    "django-redis>=5.4.0",
    # Я использую Pydantic для сериализации и валидации
    "pydantic[email]>=2.9.2",
    "pydantic-extra-types>=2.9.0",
    # Супер удобная либа для работы с хранением файлов
    "django-storages[s3]>=1.14.4",
    # Для веб сервера
    "uvicorn>=0.31.0",
    "gunicorn>=23.0.0",
    "uvicorn-worker>=0.2.0",
    # Супер удобная либа для работы с переменными окружения
    "envparse>=0.2.0",
    # Прочее
    "toml>=0.10.2",
    "markdown>=3.7",
    "psycopg2-binary>=2.9.9",
    "sentry-sdk[celery]>=2.15.0",
    "mock>=5.1.0",
    "IPython>=8.29.0",
    "requests>=2.32.3",
    "pyjwt>=2.10.1",
    "Pillow>=11.1.0",
]

[tool.pdm]
version = { source = "file", path = "src/__init__.py" }
distribution = true

# Стандартные группы и либы, которые я использую для разных сборок в большинстве своих проектов
[tool.pdm.dev-dependencies]
# Тесты
test = [
    "pytest>=8.3.3",
    "coverage>=7.6.1",
    "pytest-cov>=6.0.0",
    "pytest-django>=4.9.0",
    "pytest-celery>=1.1.3",
]
# Документация
doc = [
    "mkdocs>=1.6.1",
    "mkdocstrings[python]>=0.26.1",
    "mkdocs-material>=9.5.39",
]
# Линтеры
lint = [
    "black>=24.8.0",
    "ruff>=0.6.8",
]
# Прекоммит
dev = [
    "pre-commit>=3.8.0",
]

[project.urls]
Homepage = "YOUR_HOME_PAGE"
Repository = "YOUR_REPOSITORY"

# Очень удобная либа для бампа версий bump-my-version
[tool.bumpversion]
current_version = "0.0.2"
tag = true
tag_name = "v{new_version}"
tag_message = "Bump version: {current_version} → {new_version}"
commit = true
message = "Bump version: {current_version} → {new_version}"

    [[tool.bumpversion.files]]
    filename = "src/__init__.py"

    [[tool.bumpversion.files]]
    filename = "README.md"
    search = "Версия - v{current_version}"
    replace = "Версия - v{new_version}"

[tool.ruff]
target-version = "py312"
line-length = 120
extend-exclude = [
    ".git",
    ".git-rewrite",
    ".ruff_cache",
    ".mypy_cache",
    ".venv",
    ".local_files",
    "__pypackages__",
    "fixtures",
    "migrations/versions",
]

[tool.ruff.lint]
# Стандартные настройки линтера тут часто бывает вкусовщина
select = [
    "E",  # pycodestyle errors
    "W",  # pycodestyle warnings
    "F",  # pyflakes
    "I",  # isort
    "C",  # flake8-comprehensions
    "B",  # flake8-bugbear
    "S",  # flake8-bandit
    "Q",  # flake8-quotes
    "PL", # Pylint
    "RUF100", # Unused noqa directive
]
ignore = [
    "B008",  # do not perform function calls in argument defaults
    "B904",  # use 'raise ... from err'
    "B905",  # use explicit 'strict=' parameter with 'zip()'
    "C901",  # too complex
    "C417",  # Unnecessary `map` usage (rewrite using a `list` comprehension)
    "F403",  # used; unable to detect undefined names
    "F405",  # may be undefined, or defined from star imports
    "E501",  # Line too long handled by black
    "N818",  # Exception name should be named with an Error suffix
    "S308",  # mark_safe
    "S603",  # mark_safe
    "S607",  # mark_safe
    "PLR0911", # Too many return statements
    "PLR0912", # Too many branches
    "PLR0913", # Too many arguments to function call
    "PLR0915", # Too many statements
    "PLW2901", # Loop variable overwritten by assignment target
]

[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]

# Настройки запуска тестов
[tool.pytest.ini_options]
# С данными аргументами тесты:
#   - не будут запускать миграции 
#   - будут запускать сначала упавшие тесты 
#   - остановятся после первого провала
#   - создадут тестовую бд
#   - запустятся с выбранной настройкой django
addopts = "--no-migrations --failed-first --verbose --maxfail=1 -p no:warnings --create-db --ds=settings.test_settings"
norecursedirs = ["static", "migrations", "templates"]
python_files = "test_*.py"
django_find_project = false

[tool.coverage.run]
# Не отображать в отчете покрытия тестами
omit = ['*/tests/*', '*/migrations/*', 'settings/*']

Конфигурация mkdocs

site_name: YOUR_SITE
nav:
  - Главная: index.md

theme:
  name: material
  highlightjs: true
  hljs_languages:
    - yaml
    - python
  language: ru
  features:
    - navigation.instant
    - navigation.instant.progress
    - navigation.tracking
    - navigation.tabs
    - navigation.tabs.sticky
    - navigation.sections
    - navigation.expand
    - navigation.path
    - navigation.indexes
    - navigation.top
    - toc.follow

markdown_extensions:
  - admonition
  - codehilite
  - pymdownx.betterem
  - pymdownx.caret
  - pymdownx.mark
  - pymdownx.tilde
  - pymdownx.details
  - pymdownx.highlight:
      anchor_linenums: true
  - pymdownx.superfences
  - pymdownx.inlinehilite

plugins:
- autorefs
- search
- tags
- mkdocstrings:
    default_handler: python
    handlers:
      python:
        import:
        # latest instead of stable
        - http://python-eve.org/objects.inv
        options:
          extensions:
          - griffe_inherited_docstrings
          find_stubs_package: true
          summary: true
          relative_crossrefs: true
          merge_init_into_class: true
          inherited_members: true
          allow_inspection: true
          annotations_path: source
          line_length: 60
          separate_signature: true
          signature_crossrefs: false
          modernize_annotations: true
          docstring_style: google
          docstring_section_style: spacy
          docstring_options:
            trim_doctest_flags: true
            warn_unknown_params: true
            ignore_init_summary: true
          show_docstring_attributes: false
          show_docstring_functions: true
          show_docstring_classes: true
          show_docstring_modules: true
          show_docstring_description: true
          show_docstring_examples: true
          show_docstring_other_parameters: true
          show_docstring_parameters: true
          show_docstring_raises: true
          show_docstring_receives: true
          show_docstring_returns: true
          show_docstring_warns: true
          show_docstring_yields: true
          show_root_heading: true
          show_root_toc_entry: true
          show_root_full_path: false
          show_root_members_full_path: false
          show_symbol_type_heading: true
          show_symbol_type_toc: true
          show_source: true
          show_object_full_path: false
          show_signature: true
          show_signature_annotations: true
          show_category_heading: true
          show_if_no_docstring: true
          show_labels: true
          show_bases: true
          show_inheritance_diagram: true
          show_submodules: false
          heading_level: 2
          parameter_headings: true
          members_order: source
          group_by_category: true
          paths:
            - /srs
          preload_modules:
            - __future__
            - pydantic
            - datetime
            - abc
          filters:
            - "!^_"
            - "__"
            - "!^__slots__"
            - "!^__all__"

Пример .pre-commit-config.yaml
default_language_version:
    python: python3.12

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: check-added-large-files
      - id: check-toml
      - id: check-yaml
        args:
          - --unsafe
      - id: trailing-whitespace
      - id: end-of-file-fixer
  - repo: https://github.com/asottile/pyupgrade
    rev: v3.19.0
    hooks:
    -   id: pyupgrade
        args: [--py312-plus]
  - repo: https://github.com/psf/black
    rev: 24.8.0
    hooks:
    -   id: black
        args: [src, --config, ./pyproject.toml, --skip-magic-trailing-comma]
  - repo: https://github.com/charliermarsh/ruff-pre-commit
    rev: v0.7.3
    hooks:
      - id: ruff
        args:
          - --fix
      - id: ruff-format
  - repo: https://github.com/asottile/reorder-python-imports
    rev: v3.14.0
    hooks:
      - id: reorder-python-imports
        args: [--py312-plus]

Пример .gitignore
/.env.local
/.pdm-python
/.venv/
/.ruff_cache/
/.cache/
/.deploy/
/.local_files/
/src/settings/local_settings.py
/src/migrations/versions/
/src/celerybeat-schedule
/src/celerybeat-schedule.db
/src/settings/local.py
/shell
/restore_postgres
/manage
/dbshell
/bash
/.env
/gl-code-quality-report.json
/docker/composes/docker-compose.override.yml
/init_clean_project
venv/
.idea/
/src/static/
/src/report.xml
/src/coverage.xml
/src/.coverage
/ca.crt
/src/report.html

Пример .dockerignore
# Distribution / packaging / PyCharm
.Python
.idea/
.venv/

# Project local
/.local_files/
/.ruff_cache/
.env
.env.local

# Project
/pipelines/
/scripts/
/src/celerybeat-schedule
/src/coverage.xml
/src/.coverage
/src/report.xml
/src/settings/local_settings.py

# Git
.git
.gitignore
.gitattributes

# CI
.gitlab-ci.yml

# Docker
.docker
.dockerignore
docker-compose.yml
**/dockerfiles
**/composes

# Byte-compiled / optimized / DLL files
**/__pycache__/
**/*.py[cod]

Пример базовых настроек base_settings.py
from __future__ import annotations

from pathlib import Path

import toml
from envparse import env

# Путь до корня src проекта
BASE_DIR = Path(__file__).resolve().parent.parent
# Метаданные из toml файла
with Path(BASE_DIR.parent, "pyproject.toml").open("r") as toml_file:
    TOML_METADATA = toml.load(toml_file)

# Версия проекта
VERSION = TOML_METADATA["tool"]["bumpversion"]["current_version"]
# При DEBUG = True будет работать hot reload для uvicorn
DEBUG = env.bool("DEBUG", default=False)
# Продакшн режим
PRODUCTION = env.bool("PRODUCTION", default=False)
# Тестовый режим
TEST_MODE = False
# Ключ секретов для генерации токенов
SECRET_KEY = env.str("SECRET_KEY", default="")

# Адрес сайта
SITE_PROTOCOL = env.str("SITE_PROTOCOL", default="http")
SITE_DOMAIN = env.str("SITE_DOMAIN", default="127.0.0.1")
SITE_PORT = env.str("SITE_PORT", default=None)
SITE_URL = f"{SITE_PROTOCOL}://{SITE_DOMAIN}"
SITE_API_PATH = env.str("SITE_API_PATH", default="api/v1")

if SITE_PORT:
    SITE_URL = f"{SITE_URL}:{SITE_PORT}"

SITE_API_URL = f"{SITE_URL}/{SITE_API_PATH}"

Пример конфигурации Nginx

Прошу обратить внимания на то, что данная конфигурация достаточно скудная, сам я ее считаю более чем позорной, но тем не менее она является достаточной для локальной разработки

# nginx.conf

server {

    listen 0.0.0.0:${NGINX_PORT_INTERNAL};
    server_name 0.0.0.0 ${HOST_NAME};

    set $backend asgi:${ASGI_PORT_INTERNAL};
    set $docs docs:${DOCS_PORT_INTERNAL};
    resolver 127.0.0.11 valid=10s;

    client_max_body_size 100M;

    location /media/ {
        alias /media/;
        autoindex off;
    }

    location /docs/ {
        proxy_pass http://$docs;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header HOST $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass_request_headers on;
        proxy_connect_timeout 1800;
        proxy_send_timeout    1800;
        proxy_read_timeout    1800;
        send_timeout          1800;
    }

    location / {
        proxy_pass http://$backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header HOST $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass_request_headers on;
        proxy_connect_timeout 1800;
        proxy_send_timeout    1800;
        proxy_read_timeout    1800;
        send_timeout          1800;
    }
}

Заключение

В данной статье мы осветили основные концепции и идеи того, как можно делать удобные и понятные окружения для любых проектов. Очень надеюсь, что ничего фундаментально важного не забыл и вы смогли найти в этой статье что-то новое для себя. Буду рад любой критике или предложениям по улучшению как статьи так и самого подхода к организации окружения и проекта в целом. Сильно вдаваться в детали не стал, много о чем еще можно поговорить на данную тему, буду рад побеседовать с вами в комментариях к статье.

Комментарии (20)


  1. ahmpro
    12.02.2025 15:30

    # ---STAGE-3---
    # Стадия сборки зависимостей приложения для dev среды
    FROM base-pdm-builder AS development-pdm-builder
    
    RUN pdm install --check --dev
    
    # ---STAGE-4---
    # Стадия сборки зависимостей приложения для test среды
    FROM base-pdm-builder AS test-pdm-builder
    
    RUN pdm install --check -G test --no-self
    
    # ---STAGE-5---
    # Стадия сборки зависимостей приложения для prod среды
    FROM base-pdm-builder AS production-pdm-builder
    
    RUN pdm install --check --production --no-self
    
    # ---STAGE-6---
    # Базовая стадия сборки зависимостей для python образа
    FROM python-base AS base-project-builder


    Это противоречит всем современным практикам: один образ - много окружений, но никак не собирать разные образы под разные окружения


    1. ahmpro
      12.02.2025 15:30

      Я вижу что это про локальную разработку, но ведь когда-то это попадет на прод, и крайне желательно иметь единообразие между разными окружениями, меняется лишь инфраструктура вокруг


      1. Extralait Автор
        12.02.2025 15:30

        Мне казалось, что нежелательно тянуть dev, test и doc зависимости в финальный образ. Или такой подход разделения на группы релевантен исключительно для работы с библиотеками?


        1. ahmpro
          12.02.2025 15:30

          dev зависимостей и правда не должно быть на проде, но всё остальное вполне себе часть проекта


    1. Extralait Автор
      12.02.2025 15:30

      А как поступать, если вам необходимо доставлять образ в продакшн среду клиента предварительно обфусцировав код?


      1. ahmpro
        12.02.2025 15:30

        обфуцировав прежде чем вы из этого сделаете образ и начнете гонять дальше образ по workflow, было б очень странно когда оттестировали один вариант, а в проде запускается совершенно другое, это аналогично тому что вы запускали бы сервис в прод без какой либо валидации


        1. Extralait Автор
          12.02.2025 15:30

          Принял, большое спасибо, за разъяснения, пересмотрю тогда пайплайны


  1. pvzh
    12.02.2025 15:30

    Не силён в Джанго, его значит можно деплоить как ASGI-приложение. Допустим, тогда вопрос, зачем gunicorn и uvicorn-worker? Ведь в Докер-окружении одного uvicorn достаточно.


    1. Extralait Автор
      12.02.2025 15:30

      Года 4 назад видел именно такой пример запуска, он прижился у меня в проектах, почитаю подробно свежую документацию, Спасибо за комментарий!


      1. pvzh
        12.02.2025 15:30

        У меня как-раз ощущение, что многие тащат gunicorn в ASGI-проекты по какой-то старой привычке. Сам uvicorn уже и несколько воркеров может, хотя при Докере это возможно лишнее.

        Пруф про не обязательный Gunicorn


        1. Extralait Автор
          12.02.2025 15:30

          Ой, супер, спасибо, переработаю эту часть у себя на проектах, реально пропустил когда-то апдейт


  1. MadridianFox
    12.02.2025 15:30

    Спасибо что поделились, всегда интересно смотреть как это устроено у других.
    Удобно конечно когда весь проект в одном репозитории, хотя и возникают вопросики - а что делать когда придётся использовать второй/третий репозитории? Проекты имеют обыкновение расти и распиливаться на микросервисы. Либо волевым решением постановить что будет монорепо, либо выносить конфигурацию запуска в отдельный репозиторий.

    Я выбрал второй вариант. Я работаю с проектами, где в каждом несколько десятков репозиториев с кодом - приложения и библиотеки. Ну и им необходим десяток сервисов - база, редис и т.д.

    Чтобы это не превратилось к гигантский монолитный docker-compose.yml файл, созданы отдельные файлы под каждый компонент. Ну а чтобы облегчить типовые операции над приложениями написана утилита elc. А все конфиги необходимые для запуска приложений и сервисов проекта вынесены в репозиторий


    1. Extralait Автор
      12.02.2025 15:30

      На прошлой работе тоже был проект с отдельным репозиторием для стягивание репозиториев остальных приложений и их запуска, но до отдельной утилиты так и не добрались. Большое спасибо за комментарий, попробую тоже накидать пример с удобным разделением и запуском микросервисов


  1. Extralait Автор
    12.02.2025 15:30

    Мискликнул и отклонил один комментарий. Кто бы ты ни был, прости...


  1. Tony-Sol
    12.02.2025 15:30

    pipelines # пайплайны GitLab

    Не надо так, ведь есть стандартная директория .gitlab

    Да, к сожалению переместить туда .gitlab-ci.yml нельзя, но можно сделать его буквально из одной строки

    include: ".gitlab/ci.yaml"


    1. Extralait Автор
      12.02.2025 15:30

      Тоже не знал, спасибо, поправлю у себя в проектах


    1. ahmpro
      12.02.2025 15:30

      к сожалению переместить туда .gitlab-ci.yml нельзя, но можно сделать его буквально из одной строки

      в настройках конкретного проекта CI/CD -> General pipelines можно переопределить путь, удобно когда репозиторий кочует между гитлабами


      1. Tony-Sol
        12.02.2025 15:30

        Это да, в целом то можно

        и даже

        для self-hosted gitlab'а в admin area можно для всех реп задать (Settings -> CI/CD -> Continuous Integration and Deployment -> Default CI/CD configuration file)

        но для этого должны быть права не ниже maintainer (а то как бы и не owner, тут не помню)

        репозиторий кочует между гитлабами

        Ну не знаю, ИМХО наоборот не удобно отходить от пути по умолчанию - это ж при каждом переезде надо помнить про эту кастомизацию


  1. Tony-Sol
    12.02.2025 15:30

    Рекомендую рассмотреть использование утилиты mise - сильно упрощает работу с окружением per-project, например позволит схлопнуть все .env и .python-version (раз статья в разрезе python разработки) файлы и кастомные скрипты автоматизации (как например создание локальных файлов из статьи) в единый формат


    1. Extralait Автор
      12.02.2025 15:30

      Большое спасибо, почитаю! Я как раз работу с переменными хотел переработать, но не знал, как