Безопасные миграции в Django

Миграции в production требуют особой осторожности, чтобы не потерять данные пользователей. Неправильно выполненная миграция может привести к простою сервиса и потере критически важной информации.

Основные принципы безопасности

  • Всегда делай backup перед миграцией - это твоя страховка
  • Тестируй миграции на копии production базы - никогда не тестируй на живых данных
  • Используй поэтапные миграции для крупных изменений - разбивай сложные изменения на простые шаги
  • Планируй время выполнения - большие миграции могут занять часы
  • Имей план отката - знай как вернуться к предыдущему состоянию

Пошаговый процесс безопасной миграции

Шаг 1: Подготовка и планирование

 1# 1. Создай backup базы данных
 2pg_dump -h localhost -U username -d database_name > backup_$(date +%Y%m%d_%H%M%S).sql
 3
 4# 2. Проверь текущее состояние миграций
 5python manage.py showmigrations
 6
 7# 3. Создай копию production базы для тестирования
 8createdb test_database_name
 9psql -d test_database_name < backup_file.sql
10
11# 4. Обнови settings.py для тестовой базы
12DATABASES = {
13    'default': {
14        'ENGINE': 'django.db.backends.postgresql',
15        'NAME': 'test_database_name',
16        'USER': 'username',
17        'PASSWORD': 'password',
18        'HOST': 'localhost',
19        'PORT': '5432',
20    }
21}

Шаг 2: Создание и тестирование миграции

 1# 1. Создай миграцию
 2python manage.py makemigrations
 3
 4# 2. Проверь созданную миграцию
 5python manage.py sqlmigrate app_name 0002
 6
 7# 3. Протестируй миграцию на копии
 8python manage.py migrate
 9
10# 4. Проверь целостность данных
11python manage.py check
12python manage.py shell
13
14# В shell проверь данные
15from myapp.models import MyModel
16print(MyModel.objects.count())
17print(MyModel.objects.first())

Практические примеры безопасных миграций

Пример 1: Добавление нового поля с default значением

 1# models.py - добавляем новое поле
 2class User(models.Model):
 3    username = models.CharField(max_length=100)
 4    email = models.EmailField()
 5    # Новое поле
 6    is_verified = models.BooleanField(default=False, help_text='Подтвержден ли email')
 7    created_at = models.DateTimeField(auto_now_add=True)
 8
 9# Создаем миграцию
10python manage.py makemigrations
11
12# Получаем что-то вроде:
13# migrations/0002_user_is_verified.py
14from django.db import migrations, models
15
16class Migration(migrations.Migration):
17    dependencies = [
18        ('users', '0001_initial'),
19    ]
20
21    operations = [
22        migrations.AddField(
23            model_name='user',
24            name='is_verified',
25            field=models.BooleanField(default=False, help_text='Подтвержден ли email'),
26        ),
27    ]

Пример 2: Изменение типа поля с сохранением данных

 1# Исходная модель
 2class Product(models.Model):
 3    name = models.CharField(max_length=200)
 4    price = models.DecimalField(max_digits=10, decimal_places=2)  # Старый тип
 5
 6# Новая модель - хотим изменить на IntegerField (копейки)
 7class Product(models.Model):
 8    name = models.CharField(max_length=200)
 9    price = models.IntegerField(help_text='Цена в копейках')  # Новый тип
10
11# Создаем поэтапную миграцию
12# Шаг 1: Добавляем новое поле
13class Migration(migrations.Migration):
14    dependencies = [
15        ('products', '0001_initial'),
16    ]
17
18    operations = [
19        migrations.AddField(
20            model_name='product',
21            name='price_cents',
22            field=models.IntegerField(default=0, help_text='Цена в копейках'),
23        ),
24    ]
25
26# Шаг 2: Переносим данные
27def convert_price_to_cents(apps, schema_editor):
28    Product = apps.get_model('products', 'Product')
29    for product in Product.objects.all():
30        # Конвертируем Decimal в копейки
31        product.price_cents = int(product.price * 100)
32        product.save()
33
34def reverse_convert_price_to_cents(apps, schema_editor):
35    Product = apps.get_model('products', 'Product')
36    for product in Product.objects.all():
37        # Конвертируем обратно в Decimal
38        product.price = product.price_cents / 100
39        product.save()
40
41class Migration(migrations.Migration):
42    dependencies = [
43        ('products', '0002_product_price_cents'),
44    ]
45
46    operations = [
47        migrations.RunPython(convert_price_to_cents, reverse_convert_price_to_cents),
48    ]
49
50# Шаг 3: Удаляем старое поле
51class Migration(migrations.Migration):
52    dependencies = [
53        ('products', '0003_convert_price_to_cents'),
54    ]
55
56    operations = [
57        migrations.RemoveField(
58            model_name='product',
59            name='price',
60        ),
61        migrations.RenameField(
62            model_name='product',
63            old_name='price_cents',
64            new_name='price',
65        ),
66    ]

Пример 3: Безопасное переименование поля

 1# Исходная модель
 2class Article(models.Model):
 3    title = models.CharField(max_length=200)
 4    content = models.TextField()
 5    pub_date = models.DateTimeField()  # Старое название
 6
 7# Новая модель
 8class Article(models.Model):
 9    title = models.CharField(max_length=200)
10    content = models.TextField()
11    published_at = models.DateTimeField()  # Новое название
12
13# Создаем миграцию с переименованием
14class Migration(migrations.Migration):
15    dependencies = [
16        ('blog', '0001_initial'),
17    ]
18
19    operations = [
20        migrations.RenameField(
21            model_name='article',
22            old_name='pub_date',
23            new_name='published_at',
24        ),
25    ]

Пример 4: Добавление NOT NULL поля к существующей таблице

 1# Исходная модель
 2class Order(models.Model):
 3    customer_name = models.CharField(max_length=100)
 4    total_amount = models.DecimalField(max_digits=10, decimal_places=2)
 5
 6# Новая модель - добавляем обязательное поле
 7class Order(models.Model):
 8    customer_name = models.CharField(max_length=100)
 9    total_amount = models.DecimalField(max_digits=10, decimal_places=2)
10    status = models.CharField(max_length=20, choices=[
11        ('pending', 'В ожидании'),
12        ('confirmed', 'Подтвержден'),
13        ('shipped', 'Отправлен'),
14        ('delivered', 'Доставлен'),
15    ])
16
17# Поэтапная миграция
18# Шаг 1: Добавляем поле с default значением
19class Migration(migrations.Migration):
20    dependencies = [
21        ('orders', '0001_initial'),
22    ]
23
24    operations = [
25        migrations.AddField(
26            model_name='order',
27            name='status',
28            field=models.CharField(
29                max_length=20,
30                choices=[
31                    ('pending', 'В ожидании'),
32                    ('confirmed', 'Подтвержден'),
33                    ('shipped', 'Отправлен'),
34                    ('delivered', 'Доставлен'),
35                ],
36                default='pending',  # Временное default значение
37            ),
38        ),
39    ]
40
41# Шаг 2: Заполняем поле осмысленными данными
42def set_order_status(apps, schema_editor):
43    Order = apps.get_model('orders', 'Order')
44    for order in Order.objects.all():
45        # Логика определения статуса на основе существующих данных
46        if order.total_amount > 0:
47            order.status = 'confirmed'
48        else:
49            order.status = 'pending'
50        order.save()
51
52class Migration(migrations.Migration):
53    dependencies = [
54        ('orders', '0002_order_status'),
55    ]
56
57    operations = [
58        migrations.RunPython(set_order_status),
59    ]
60
61# Шаг 3: Убираем default и делаем поле обязательным
62class Migration(migrations.Migration):
63    dependencies = [
64        ('orders', '0003_set_order_status'),
65    ]
66
67    operations = [
68        migrations.AlterField(
69            model_name='order',
70            name='status',
71            field=models.CharField(
72                max_length=20,
73                choices=[
74                    ('pending', 'В ожидании'),
75                    ('confirmed', 'Подтвержден'),
76                    ('shipped', 'Отправлен'),
77                    ('delivered', 'Доставлен'),
78                ],
79                # Убираем default
80            ),
81        ),
82    ]

Проверка и валидация миграций

Команды для проверки

 1# Проверка состояния миграций
 2python manage.py showmigrations
 3
 4# Проверка SQL без выполнения
 5python manage.py sqlmigrate app_name 0002
 6
 7# Проверка целостности проекта
 8python manage.py check
 9
10# Проверка миграций на конфликты
11python manage.py makemigrations --dry-run
12
13# Проверка зависимостей миграций
14python manage.py migrate --plan

Тестирование миграций

 1# Создаем тестовую базу
 2python manage.py test --keepdb
 3
 4# Или создаем отдельную тестовую базу
 5DATABASES = {
 6    'default': {
 7        'ENGINE': 'django.db.backends.postgresql',
 8        'NAME': 'test_db',
 9    },
10    'test': {
11        'ENGINE': 'django.db.backends.postgresql',
12        'NAME': 'test_db_test',
13    }
14}
15
16# Запускаем тесты с новой базой
17python manage.py test --database=test

План отката (Rollback)

Откат к предыдущей миграции

 1# Откатываемся к конкретной миграции
 2python manage.py migrate app_name 0001
 3
 4# Откатываемся на одну миграцию назад
 5python manage.py migrate app_name 0002
 6
 7# Проверяем текущее состояние
 8python manage.py showmigrations app_name
 9
10# Восстанавливаем базу из backup если нужно
11psql -d database_name < backup_file.sql

Создание миграции для отката

 1# Создаем миграцию, которая отменяет изменения
 2class Migration(migrations.Migration):
 3    dependencies = [
 4        ('myapp', '0002_add_new_field'),
 5    ]
 6
 7    operations = [
 8        migrations.RemoveField(
 9            model_name='mymodel',
10            name='new_field',
11        ),
12    ]

Миграции в production

Подготовка к production миграции

 1# 1. Выбери время минимальной нагрузки
 2# 2. Уведоми команду о планируемом простое
 3# 3. Подготовь rollback план
 4# 4. Создай backup
 5
 6# Команда для создания backup
 7pg_dump -h production_host -U username -d production_db > production_backup_$(date +%Y%m%d_%H%M%S).sql
 8
 9# Проверь размер backup
10ls -lh production_backup_*.sql
11
12# Проверь время выполнения на тестовой базе
13time python manage.py migrate

Выполнение production миграции

 1# 1. Останови приложение (если нужно)
 2sudo systemctl stop gunicorn
 3sudo systemctl stop celery
 4
 5# 2. Выполни миграцию
 6python manage.py migrate
 7
 8# 3. Проверь результат
 9python manage.py check
10python manage.py showmigrations
11
12# 4. Запусти приложение
13sudo systemctl start gunicorn
14sudo systemctl start celery
15
16# 5. Проверь работоспособность
17curl -I https://yoursite.com/health/

Частые ошибки и их решения

Ошибка: "django.db.utils.IntegrityError: column cannot contain NULL values"

 1# Проблема: добавляешь NOT NULL поле к таблице с данными
 2# Решение: используй поэтапную миграцию
 3
 4# Шаг 1: Добавь поле с default значением
 5migrations.AddField(
 6    model_name='mymodel',
 7    name='new_field',
 8    field=models.CharField(max_length=100, default='default_value'),
 9)
10
11# Шаг 2: Заполни поле данными
12def populate_field(apps, schema_editor):
13    MyModel = apps.get_model('myapp', 'MyModel')
14    for obj in MyModel.objects.all():
15        obj.new_field = 'calculated_value'
16        obj.save()
17
18migrations.RunPython(populate_field)
19
20# Шаг 3: Сделай поле обязательным
21migrations.AlterField(
22    model_name='mymodel',
23    name='new_field',
24    field=models.CharField(max_length=100),  # Убираем default
25)

Ошибка: "django.db.utils.OperationalError: cannot ALTER TABLE"

 1# Проблема: PostgreSQL не может изменить таблицу
 2# Решение: используй RunSQL для сложных изменений
 3
 4class Migration(migrations.Migration):
 5    dependencies = [
 6        ('myapp', '0001_initial'),
 7    ]
 8
 9    operations = [
10        migrations.RunSQL(
11            # SQL для изменения таблицы
12            "ALTER TABLE myapp_mymodel ADD COLUMN new_field VARCHAR(100) DEFAULT 'default';",
13            # SQL для отката
14            "ALTER TABLE myapp_mymodel DROP COLUMN new_field;",
15        ),
16    ]

Лучшие практики

  • Тестируй на копии production данных - никогда не тестируй миграции на живых данных
  • Используй поэтапные миграции - разбивай сложные изменения на простые шаги
  • Создавай осмысленные названия миграций - это поможет в отладке
  • Документируй сложные миграции - добавляй комментарии в код миграции
  • Планируй время выполнения - большие миграции могут занять часы
  • Имей план отката - знай как вернуться к предыдущему состоянию
  • Мониторь производительность - следи за временем выполнения миграций

FAQ

Q: Что делать если миграция упала?
A: Проверь состояние через showmigrations, откатись к предыдущей версии, исправь ошибку и создай новую миграцию.

Q: Как откатить миграцию в production?
A: Используй python manage.py migrate app_name previous_migration_number или восстанови базу из backup.

Q: Можно ли изменять уже примененную миграцию?
A: Нет, никогда не изменяй уже примененные миграции. Создай новую миграцию для исправления.

Q: Как безопасно изменить тип поля?
A: Используй поэтапную миграцию: добавь новое поле, перенеси данные, удали старое поле.

Q: Что делать если миграция зависла?
A: Проверь логи, возможно есть блокировка таблицы. В крайнем случае перезапусти PostgreSQL.

Q: Как мигрировать большие таблицы без простоя?
A: Используй поэтапные миграции, выполняй в нерабочее время, используй инструменты типа pt-online-schema-change.