
Каждая минута простоя — это недополученные оплаты, падение конверсии, рост нагрузки на поддержку и ухудшение поведенческих метрик. Даже «короткий» релиз на 2–3 минуты может обнулить рекламные кампании на час вперёд. Zero‑downtime — это не про перфекционизм, а про предсказуемые релизы: меньше инцидентов, понятные риски, выше скорость изменений.
Практика: для большинства веб‑приложений достаточно Rolling + короткая канареечная фаза.
Сервер должен отвечать, готов ли он принимать трафик (readiness) и «жив» ли вообще (liveness). А при остановке — корректно завершать соединения.
# myproj/urls.py
from django.urls import path
from .views import healthz
urlpatterns = [
path("healthz/", healthz, name="healthz"),
]
# myproj/views.py
from django.http import JsonResponse
from django.db import connection
def healthz(request):
# Быстрая проверка соединения с БД без долгих запросов
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1;")
cursor.fetchone()
return JsonResponse({"status": "ok"}, status=200)
except Exception as e:
return JsonResponse({"status": "fail", "error": str(e)}, status=500)
Для gunicorn настройте таймауты и «мягкое» завершение:
gunicorn myproj.wsgi:application \
--workers 4 \
--graceful-timeout 30 \
--timeout 60 \
--preload \
--max-requests 1000 \
--max-requests-jitter 100
В Kubernetes используйте readinessProbe и корректный период завершения:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: web
image: registry.example.com/myproj:1.2.3
ports:
- containerPort: 8000
readinessProbe:
httpGet:
path: /healthz/
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz/
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
Главное правило: сначала делаем схему совместимой со старым и новым кодом (expand), раскатываем код, потом убираем «лишнее» (contract).
Чего избегать:
Шаг 1. Добавляем новую колонку допускающую NULL:
# migrations/0001_expand_add_field.py
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("app", "previous"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="timezone",
field=models.CharField(max_length=64, null=True),
),
]
Шаг 2. Постепенно заполняем значения батчами, чтобы не держать долгую транзакцию:
# migrations/0002_backfill_timezone.py
from django.db import migrations
def backfill(apps, schema_editor):
UserProfile = apps.get_model("app", "UserProfile")
qs = UserProfile.objects.filter(timezone__isnull=True)
batch_size = 2000
last_id = 0
while True:
batch = list(qs.filter(id__gt=last_id).order_by("id")[:batch_size])
if not batch:
break
for row in batch:
row.timezone = "UTC"
UserProfile.objects.bulk_update(batch, ["timezone"], batch_size=batch_size)
last_id = batch[-1].id
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("app", "0001_expand_add_field"),
]
operations = [
migrations.RunPython(backfill, reverse_code=noop),
]
Шаг 3. Делаем колонку NOT NULL в отдельной миграции (после деплоя кода, который умеет с ней работать):
# migrations/0003_contract_set_not_null.py
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("app", "0002_backfill_timezone"),
]
operations = [
migrations.AlterField(
model_name="userprofile",
name="timezone",
field=migrations.fields.CharField(max_length=64, null=False),
),
]
Примечание: в реальном коде для AlterField используйте правильный импорт поля, например models.CharField(...). Суть в разделении шагов.
-- безопасное создание индекса без долгой блокировки записи
CREATE INDEX CONCURRENTLY idx_userprofile_email ON app_userprofile (email);
-- если нужен уникальный индекс
CREATE UNIQUE INDEX CONCURRENTLY idx_userprofile_email_uniq ON app_userprofile (email);
Для MySQL используйте онлайн‑DDL (если доступно):
ALTER TABLE userprofile
ADD INDEX idx_userprofile_email (email)
ALGORITHM=INPLACE, LOCK=NONE;
Двойную запись можно сделать на уровне приложения (сигналы/репозитории) или триггером в БД. Для PostgreSQL:
CREATE OR REPLACE FUNCTION sync_old_to_new() RETURNS trigger AS $$
BEGIN
NEW.new_col := NEW.old_col;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_sync_old_to_new ON my_table;
CREATE TRIGGER trg_sync_old_to_new
BEFORE INSERT OR UPDATE ON my_table
FOR EACH ROW EXECUTE FUNCTION sync_old_to_new();
Пример идемпотентной задачи на Celery:
# tasks.py
import hashlib
from celery import shared_task
from django.core.cache import cache
@shared_task(bind=True, max_retries=3)
def charge(self, user_id: int, amount_cents: int, idempotency_key: str):
key = "charge:" + hashlib.sha256(idempotency_key.encode()).hexdigest()
if cache.get(key):
return {"status": "duplicate"}
# Тут — безопасный вызов платёжного провайдера
# ...
cache.set(key, True, timeout=24*3600)
return {"status": "ok"}
Включайте контент‑хеши в имени файлов. В Django — через ManifestStaticFilesStorage:
# settings.py
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
Это гарантирует, что новая версия HTML укажет на новые пути CSS/JS, а старая страница у пользователей продолжит работать со старыми файлами до обновления.
Откат — это такой же сценарий, как и выпуск. Его надо репетировать и автоматизировать.
Пример быстрого отката в Kubernetes:
# откат к предыдущему ReplicaSet
kubectl rollout undo deployment/web
kubectl rollout status deployment/web --timeout=120s
Идея: разделите изменения на «expand» и «contract» в разные пул‑реквесты. Пайплайн сначала накатывает «expand»-миграции и новый код, потом, спустя время наблюдения, — «contract».
Пример GitHub Actions (упрощённо):
name: Deploy
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: |
docker build -t registry.example.com/myproj:${{ github.sha }} .
echo $CR_PAT | docker login registry.example.com -u ci --password-stdin
docker push registry.example.com/myproj:${{ github.sha }}
- name: Apply DB migrations (expand)
env:
DJANGO_SETTINGS_MODULE: myproj.settings
run: |
python manage.py migrate --noinput
- name: Rolling update
run: |
kubectl set image deployment/web web=registry.example.com/myproj:${{ github.sha }}
kubectl rollout status deployment/web --timeout=180s
- name: Smoke tests
run: |
curl -f https://example.com/healthz/ | jq .
- name: Canary metrics check (pseudo)
run: |
# здесь обычно запрос к системам метрик/логов
echo "OK"
# Через сутки отдельным пайплайном: contract-миграции
Zero‑downtime — это не одна «волшебная кнопка», а набор привычек: медленные и безопасные миграции, проверенный процесс выключения, постепенная раскатка и наблюдаемость. В результате релизы перестают быть «стрессом по пятницам»: бизнес получает стабильность и скорость изменений, а разработчики — предсказуемость и меньше ночных инцидентов.