Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,24 @@ POSTGRES_PASSWORD=app
REDIS_URL=redis://localhost:6379/1
REDIS_HOST=localhost
REDIS_PORT=6379
# REDIS_MAX_CONNECTIONS=100

# Database connection pool (только PostgreSQL, выключить: DJANGO_DB_POOL=False)
# DJANGO_DB_POOL=True
# DJANGO_DB_POOL_MIN_SIZE=2
# DJANGO_DB_POOL_MAX_SIZE=10
# DJANGO_DB_POOL_TIMEOUT=10

# API throttling
# DJANGO_THROTTLE_ANON=100/min
# DJANGO_THROTTLE_USER=1000/min

# Production security (используются при ENVIRONMENT=prod)
# DJANGO_SECURE_SSL_REDIRECT=True
# DJANGO_SECURE_HSTS_SECONDS=2592000
# DJANGO_NUM_PROXIES=1

# Gunicorn (по умолчанию workers = CPU * 2 + 1)
# GUNICORN_WORKERS=4
# GUNICORN_THREADS=4
# GUNICORN_TIMEOUT=30
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
| 📖 **OpenAPI из коробки** | Swagger UI и ReDoc (drf-spectacular) |
| ⚙️ **12-factor конфигурация** | Всё через переменные окружения (django-environ) |
| 🔒 **Безопасный production** | HSTS, secure cookies, SSL redirect, whitenoise, gunicorn |
| ⚡ **Highload-тюнинг** | Пул соединений psycopg, кэш-сессии, Argon2, rate limiting |
| 👤 **Кастомный пользователь** | Email-логин + `external_id` (UUID) для интеграций |
| 💓 **Health-check** | `/health/` с проверкой БД и Redis — готов для k8s-проб |
| 🧹 **Качество кода** | ruff · bandit · ty · pre-commit · pytest · GitHub Actions |
Expand Down Expand Up @@ -176,9 +177,16 @@ make up / down # 🐳 docker compose up/down
| `DJANGO_DEBUG` | Режим отладки | `False` (prod), `True` (dev) |
| `DJANGO_ALLOWED_HOSTS` | Разрешённые хосты через запятую | — |
| `DATABASE_URL` | URL базы данных | SQLite |
| `DJANGO_DB_POOL` | Пул соединений psycopg (PostgreSQL) | `True` |
| `DJANGO_DB_POOL_MAX_SIZE` | Максимум соединений в пуле (на процесс) | `10` |
| `REDIS_URL` | URL Redis для кэша | `redis://redis:6379/1` |
| `REDIS_MAX_CONNECTIONS` | Размер пула Redis-клиента | `100` |
| `DJANGO_THROTTLE_ANON` | Лимит запросов для анонимов | `100/min` |
| `DJANGO_THROTTLE_USER` | Лимит запросов для пользователей | `1000/min` |
| `DJANGO_NUM_PROXIES` | Прокси перед приложением (prod) | `1` |
| `DJANGO_LOG_LEVEL` | Уровень логирования | `INFO` |
| `GUNICORN_WORKERS` | Количество воркеров gunicorn | `4` |
| `GUNICORN_WORKERS` | Воркеры gunicorn | `CPU × 2 + 1` |
| `GUNICORN_THREADS` | Потоки на воркер (gthread) | `4` |

</details>

Expand Down
53 changes: 50 additions & 3 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,35 @@
DATABASES = {
'default': env.db('DATABASE_URL', default=f'sqlite:///{BASE_DIR / "db.sqlite3"}'),
}
DATABASES['default']['CONN_MAX_AGE'] = env.int('DJANGO_DB_CONN_MAX_AGE', default=60)
DATABASES['default']['CONN_HEALTH_CHECKS'] = True

_db_is_postgres = DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'
if _db_is_postgres and env.bool('DJANGO_DB_POOL', default=True):
# Нативный пул psycopg: пул несовместим с CONN_MAX_AGE и health checks Django
DATABASES['default']['CONN_MAX_AGE'] = 0
DATABASES['default']['CONN_HEALTH_CHECKS'] = False
DATABASES['default'].setdefault('OPTIONS', {})['pool'] = {
'min_size': env.int('DJANGO_DB_POOL_MIN_SIZE', default=2),
'max_size': env.int('DJANGO_DB_POOL_MAX_SIZE', default=10),
'timeout': env.int('DJANGO_DB_POOL_TIMEOUT', default=10),
}
else:
DATABASES['default']['CONN_MAX_AGE'] = env.int('DJANGO_DB_CONN_MAX_AGE', default=60)
DATABASES['default']['CONN_HEALTH_CHECKS'] = True

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

AUTH_USER_MODEL = 'users.User'

# Argon2 — быстрее и устойчивее PBKDF2; остальные хэшеры нужны
# для прозрачной миграции уже существующих паролей
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
'django.contrib.auth.hashers.ScryptPasswordHasher',
]

AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
Expand Down Expand Up @@ -122,9 +144,13 @@
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'PAGE_SIZE': 20,
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_THROTTLE_CLASSES': ('rest_framework.throttling.AnonRateThrottle',),
'DEFAULT_THROTTLE_CLASSES': (
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
),
'DEFAULT_THROTTLE_RATES': {
'anon': env.str('DJANGO_THROTTLE_ANON', default='100/min'),
'user': env.str('DJANGO_THROTTLE_USER', default='1000/min'),
},
}

Expand All @@ -139,9 +165,24 @@
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': env.str('REDIS_URL', default='redis://redis:6379/1'),
'KEY_PREFIX': env.str('DJANGO_CACHE_KEY_PREFIX', default='app'),
'OPTIONS': {
# Параметры пула соединений redis-py
'max_connections': env.int('REDIS_MAX_CONNECTIONS', default=100),
'socket_connect_timeout': 5,
'socket_timeout': 5,
'retry_on_timeout': True,
'health_check_interval': 30,
},
},
}

# Сессии в кэше с write-through в БД: чтение не нагружает PostgreSQL,
# при сбросе Redis сессии не теряются
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'

EMAIL_TIMEOUT = env.int('DJANGO_EMAIL_TIMEOUT', default=10)

LOGGING = {
'version': 1,
'disable_existing_loggers': False,
Expand All @@ -167,6 +208,12 @@
'level': env.str('DJANGO_LOG_LEVEL', default='INFO'),
'propagate': False,
},
# Под нагрузкой 4xx-шум забивает логи: пишем только 5xx и медленные ответы
'django.request': {
'handlers': ['console'],
'level': env.str('DJANGO_REQUEST_LOG_LEVEL', default='ERROR'),
'propagate': False,
},
},
}

Expand Down
4 changes: 4 additions & 0 deletions config/settings/prod.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@

# В production браузерные формы DRF не нужны
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ('rest_framework.renderers.JSONRenderer',) # noqa: F821

# Количество прокси перед приложением — для корректного определения IP клиента
# в троттлинге (X-Forwarded-For)
REST_FRAMEWORK['NUM_PROXIES'] = env.int('DJANGO_NUM_PROXIES', default=1) # noqa: F821
14 changes: 11 additions & 3 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,21 @@ case "$1" in
;;
"prod")
echo "Запуск в продакшн режиме..."
# Формула по умолчанию: CPU * 2 + 1
WORKERS="${GUNICORN_WORKERS:-$(( $(nproc) * 2 + 1 ))}"
echo "Gunicorn workers: $WORKERS, threads: ${GUNICORN_THREADS:-4}"
exec gunicorn config.wsgi:application \
--bind 0.0.0.0:8000 \
--workers "${GUNICORN_WORKERS:-4}" \
--workers "$WORKERS" \
--worker-class gthread \
--threads "${GUNICORN_THREADS:-4}" \
--worker-tmp-dir /dev/shm \
--backlog 2048 \
--max-requests 1000 \
--max-requests-jitter 100 \
--timeout 30 \
--keep-alive 2 \
--timeout "${GUNICORN_TIMEOUT:-30}" \
--graceful-timeout 30 \
--keep-alive 5 \
--log-level info \
--access-logfile - \
--error-logfile -
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md"
requires-python = "==3.13.*"
dependencies = [
"argon2-cffi==25.1.0",
"django==6.0.6",
"django-environ==0.13.0",
"django-filter==25.2",
Expand Down
67 changes: 67 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading