Настройка статики и медиа, производительность, бэкапы, мониторинг и типичные ошибки в production.
Подготовка мультитенантного приложения к production: статика, медиа, производительность, бэкапы, мониторинг и типичные ошибки.
Статические файлы shared apps (админка, общие CSS/JS) работают как в обычном Django:
# settings.py
STATIC_URL = '/static/'
STATIC_ROOT = '/var/www/static/'
# Сбор статики
python manage.py collectstaticФайлы, загружаемые пользователями, должны быть изолированы по тенантам:
# settings.py
MEDIA_URL = '/media/'
MEDIA_ROOT = '/var/www/media/'
# Структура MEDIA_ROOT:
# media/
# tenants/
# acme/
# avatars/
# documents/
# globex/
# avatars/
# documents/Создайте storage, который автоматически добавляет префикс тенанта:
# tenants/storage.py
import os
from django.core.files.storage import FileSystemStorage
from django_tenants.utils import get_current_schema_name
class TenantStorage(FileSystemStorage):
"""
Storage для медиафайлов с изоляцией по тенантам.
"""
def __init__(self, location=None, base_url=None):
super().__init__(location, base_url)
self.location = location or settings.MEDIA_ROOT
def get_available_name(self, name, max_length=None):
# Добавить префикс схемы
schema = get_current_schema_name()
if schema != 'public':
name = os.path.join('tenants', schema, name)
return super().get_available_name(name, max_length)
def url(self, name):
schema = get_current_schema_name()
if schema != 'public':
name = os.path.join('tenants', schema, name)
return super().url(name)
# Использование в модели
from django.db import models
from .storage import TenantStorage
class Document(TenantModel):
title = models.CharField(max_length=200)
file = models.FileField(storage=TenantStorage())# settings.py
AWS_STORAGE_BUCKET_NAME = 'my-saas-bucket'
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
# Префикс для tenant файлов
AWS_LOCATION = 'tenants'
# В модели
class Document(TenantModel):
file = models.FileField(
storage=S3Boto3Storage(location=f'tenants/{schema_name}')
)Используйте select_related и prefetch_related как в обычном Django:
# Плохо — N+1 запрос
for project in Project.objects.all():
print(project.owner.email)
# Хорошо — 2 запроса
for project in Project.objects.select_related('owner').all():
print(project.owner.email)Добавляйте индексы на часто используемые поля:
class Project(TenantModel):
name = models.CharField(max_length=200, db_index=True)
status = models.CharField(max_length=20, db_index=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
app_label = 'projects'
indexes = [
models.Index(fields=['status', '-created_at']),
]from django.core.cache import cache
from django_tenants.utils import get_current_schema_name
def get_project_stats(project_id):
schema = get_current_schema_name()
cache_key = f'{schema}:project:{project_id}:stats'
stats = cache.get(cache_key)
if stats is None:
stats = calculate_stats(project_id)
cache.set(cache_key, stats, timeout=300)
return statsДля большого количества тенантов используйте connection pooling:
# settings.py
DATABASES = {
'default': {
'ENGINE': 'django_tenants.postgresql_backend',
'NAME': 'saas_db',
'USER': 'postgres',
'PASSWORD': 'password',
'HOST': 'localhost',
'PORT': '5432',
'CONN_MAX_AGE': 600, # Постоянные соединения
'OPTIONS': {
'connect_timeout': 10,
},
}
}Для production рассмотрите PgBouncer:
# pgbouncer.ini
[databases]
saas_db = host=localhost port=5432 dbname=saas_db
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
# Полный бэкап всех схем
pg_dump -U postgres saas_db > backup_$(date +%Y%m%d).sql
# Восстановление
psql -U postgres saas_db < backup_20260302.sql# Бэкап только схемы тенанта
pg_dump -U postgres -n acme saas_db > acme_backup.sql
# Восстановление
psql -U postgres saas_db < acme_backup.sql#!/bin/bash
# backup.sh
DB_NAME="saas_db"
BACKUP_DIR="/backups"
DATE=$(date +%Y%m%d_%H%M%S)
# Полный бэкап
pg_dump -U postgres $DB_NAME > "$BACKUP_DIR/full_$DATE.sql"
# Бэкап каждого тенанта отдельно
psql -U postgres -d $DB_NAME -t -c "SELECT schema_name FROM tenants_client" | \
while read schema; do
schema=$(echo $schema | xargs) # Trim whitespace
if [ -n "$schema" ]; then
pg_dump -U postgres -n $schema $DB_NAME > "$BACKUP_DIR/${schema}_$DATE.sql"
fi
done
# Удалить старые бэкапы (старше 30 дней)
find $BACKUP_DIR -name "*.sql" -mtime +30 -delete#!/bin/bash
# backup-s3.sh
BACKUP_FILE="backup_$(date +%Y%m%d).sql.gz"
pg_dump -U postgres saas_db | gzip > $BACKUP_FILE
aws s3 cp $BACKUP_FILE s3://my-saas-backups/$(date +%Y/%m/)/
rm $BACKUP_FILE# tenants/metrics.py
from django_tenants.utils import get_tenant_model
from django.db import connection
import psutil
def get_tenant_metrics():
Tenant = get_tenant_model()
return {
'total_tenants': Tenant.objects.count(),
'active_tenants': Tenant.objects.filter(active=True).count(),
'total_schemas': get_schema_count(),
'db_size': get_db_size(),
}
def get_schema_count():
with connection.cursor() as cursor:
cursor.execute("""
SELECT COUNT(*)
FROM information_schema.schemata
WHERE schema_name NOT LIKE 'pg_%'
""")
return cursor.fetchone()[0]
def get_db_size():
with connection.cursor() as cursor:
cursor.execute("""
SELECT pg_size_pretty(pg_database_size(%s))
""", ['saas_db'])
return cursor.fetchone()[0]# settings.py
LOGGING = {
'version': 1,
'formatters': {
'tenant': {
'format': '[%(asctime)s] [%(schema)s] %(levelname)s: %(message)s'
},
},
'handlers': {
'file': {
'class': 'logging.FileHandler',
'filename': '/var/log/django/saas.log',
'formatter': 'tenant',
},
},
'loggers': {
'django': {
'handlers': ['file'],
'level': 'INFO',
},
},
}
# Кастомный фильтр для добавления схемы в лог
class TenantFilter(logging.Filter):
def filter(self, record):
from django_tenants.utils import get_current_schema_name
record.schema = get_current_schema_name()
return True# settings.py
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from django_tenants.utils import get_current_schema_name
def before_send(event, hint):
event['tenant'] = get_current_schema_name()
return event
sentry_sdk.init(
dsn="your-sentry-dsn",
integrations=[DjangoIntegration()],
before_send=before_send,
traces_sample_rate=0.1,
)#!/bin/bash
# deploy.sh
set -e
echo "Creating backup..."
pg_dump -U postgres saas_db > backup_$(date +%Y%m%d).sql
echo "Applying shared migrations..."
python manage.py migrate_schemas --shared
echo "Applying tenant migrations..."
python manage.py migrate_schemas --tenant
echo "Collecting static..."
python manage.py collectstatic --noinput
echo "Restarting application..."
sudo systemctl restart myapp
echo "Deployment complete!"Для больших таблиц используйте подход с фоновыми миграциями:
# migrations/0002_add_field.py
# Шаг 1: Добавить поле с null=True
class Migration(migrations.Migration):
operations = [
migrations.AddField(
model_name='project',
name='new_field',
field=models.CharField(max_length=100, null=True, blank=True),
),
]
# Шаг 2: Фоновый скрипт для заполнения данных
# populate_new_field.py
from django_tenants.utils import get_tenant_model, schema_context
Tenant = get_tenant_model()
for tenant in Tenant.objects.all():
with schema_context(tenant.schema_name):
Project.objects.filter(new_field__isnull=True).update(
new_field='default_value'
)
# Шаг 3: Сделать поле обязательным
class Migration(migrations.Migration):
operations = [
migrations.AlterField(
model_name='project',
name='new_field',
field=models.CharField(max_length=100),
),
]Для дополнительной изоляции используйте PostgreSQL roles:
-- Создать роль для каждого тенанта
CREATE ROLE acme_user WITH LOGIN PASSWORD 'password';
GRANT ALL ON SCHEMA acme TO acme_user;
GRANT ALL ON ALL TABLES IN SCHEMA acme TO acme_user;
-- Запретить доступ к другим схемам
REVOKE ALL ON SCHEMA globex FROM acme_user;# settings.py
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True# ratelimit.py
from django.core.cache import cache
from django_tenants.utils import get_current_schema_name
def tenant_rate_limit(key, limit=100, timeout=60):
schema = get_current_schema_name()
cache_key = f'ratelimit:{schema}:{key}'
current = cache.get(cache_key, 0)
if current >= limit:
return False
cache.set(cache_key, current + 1, timeout)
return TrueПроблема: При потере данных невозможно восстановить одного клиента.
Решение: Автоматизируйте бэкап каждой схемы отдельно.
Проблема: Один тенант видит кэш другого.
Решение: Включайте schema_name в ключ кэша.
Проблема: База данных растёт незаметно и заканчивается место.
Решение: Отслеживайте размер БД и каждой схемы.
Проблема: Миграция большой tenant таблицы занимает часы.
Решение: Используйте фоновые миграции, разбивайте на этапы.
Проблема: Один тенант потребляет все ресурсы.
Решение: Внедрите квоты на проекты, пользователей, хранилище.
Вы завершили курс по django-tenants! Теперь вы готовы построить мультитенантное SaaS-приложение.
Рекомендуемые следующие шаги:
Вернуться к началу курса → Мультитенантность в Django
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.