#!/usr/bin/env bash # # Sprint 21: blue-green deploy для prod-vm. # # Алгоритм: # 1. Pull новых images из registry (если их там нет → fail) # 2. Запуск ВТОРОГО api-контейнера (food-market-api-next) на :8088 # 3. Выполнить миграции БД через временный one-shot контейнер # 4. Smoke-test на новом api: /health/ready + /api/me с тестовым токеном # 5. Если ок → переключить prod nginx upstream на :8088 → reload nginx # 6. Удалить старый api-контейнер; переименовать food-market-api-next → food-market-api # 7. То же самое для web (но без миграций) # # Если smoke не прошёл → откат: убиваем -next, оставляем старый запущенным. # # Usage: # deploy/prod-deploy.sh [--dry-run] [--skip-web] # # Подразумевает что nginx стоит на хосте (не в compose) и его upstream # конфигурируется через include /etc/nginx/upstream.conf, который этот # скрипт переписывает atomic'ом. Если nginx внутри web-контейнера — # blue-green становится через docker-swap, а не nginx-reload (подробнее # в docs/prod-deploy.md). set -uo pipefail API_TAG="${1:-}" WEB_TAG="${2:-}" DRY_RUN=0 SKIP_WEB=0 while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; --skip-web) SKIP_WEB=1; shift ;; -*) echo "Unknown: $1" >&2; exit 2 ;; *) shift ;; esac done if [[ -z "$API_TAG" || -z "$WEB_TAG" ]]; then cat < [--dry-run] [--skip-web] Example: $0 v20260607.3 v20260607.3 $0 v20260607.3 v20260607.3 --dry-run Required env (defaults in [brackets]): REGISTRY [127.0.0.1:5001] COMPOSE_PATH [/home/nns/food-market-prod/deploy/docker-compose.yml] NGINX_UPSTREAM_FILE [/etc/nginx/conf.d/food-market-upstream.conf] API_PORT_BLUE [8080] — текущий API_PORT_GREEN [8088] — временный для нового контейнера EOF exit 2 fi REGISTRY="${REGISTRY:-127.0.0.1:5001}" COMPOSE_PATH="${COMPOSE_PATH:-/home/nns/food-market-prod/deploy/docker-compose.yml}" NGINX_UPSTREAM_FILE="${NGINX_UPSTREAM_FILE:-/etc/nginx/conf.d/food-market-upstream.conf}" API_PORT_BLUE="${API_PORT_BLUE:-8080}" API_PORT_GREEN="${API_PORT_GREEN:-8088}" TEST_TOKEN_FILE="${TEST_TOKEN_FILE:-/home/nns/.fm-prod-test-token}" run() { if [[ $DRY_RUN -eq 1 ]]; then echo "[dry-run] $*" else echo "[exec] $*" "$@" fi } log() { echo "[$(date -Iseconds)] $*"; } fail() { log "FAIL: $*" exit 1 } # ── 1. Pull новых images ───────────────────────────────────────────── log "=== Step 1/7: pull images ===" API_IMG="$REGISTRY/food-market-api:$API_TAG" WEB_IMG="$REGISTRY/food-market-web:$WEB_TAG" run docker pull "$API_IMG" || fail "api image $API_IMG отсутствует в registry" [[ $SKIP_WEB -eq 0 ]] && (run docker pull "$WEB_IMG" || fail "web image $WEB_IMG отсутствует") # ── 2. Запуск green-api на :8088 ───────────────────────────────────── log "=== Step 2/7: start green api on :$API_PORT_GREEN ===" # Если -next уже есть (от прошлой неудачной попытки) — снести. run docker rm -f food-market-api-next 2>/dev/null || true # .env берём из compose dir чтобы получить те же переменные. ENV_FILE="$(dirname "$COMPOSE_PATH")/.env" [[ -f "$ENV_FILE" ]] || fail ".env не найден: $ENV_FILE" # Запускаем second api с теми же volume/env, но на host port 8088 # и подключённый к той же compose-сети (food-market-prod_default). NETWORK="food-market-prod_default" run docker run -d \ --name food-market-api-next \ --network "$NETWORK" \ --env-file "$ENV_FILE" \ -e ASPNETCORE_ENVIRONMENT=Production \ -p "127.0.0.1:$API_PORT_GREEN:8080" \ --restart no \ "$API_IMG" # ── 3. Миграции БД ────────────────────────────────────────────────── # .NET API мигрирует автоматически на старте через AppDbContext.Database.Migrate() # (см. Program.cs). Поэтому ждём готовности green-контейнера — если он # поднялся healthy = миграции прошли. log "=== Step 3/7: wait for green api ready (migrations) ===" if [[ $DRY_RUN -eq 0 ]]; then READY=0 for i in $(seq 1 60); do sleep 2 if curl -fsS "http://127.0.0.1:$API_PORT_GREEN/health/ready" 2>/dev/null | grep -q '"status":"Healthy"'; then READY=1 log "green api ready после $((i*2))s" break fi done if [[ $READY -eq 0 ]]; then log "FAIL: green api не стал Healthy за 120с — сносим" docker logs food-market-api-next --tail 50 || true docker rm -f food-market-api-next || true fail "green api не поднялся" fi else log "[dry-run] skip wait for ready" fi # ── 4. Smoke-test на green ────────────────────────────────────────── log "=== Step 4/7: smoke green api ===" if [[ $DRY_RUN -eq 0 ]]; then # /health/ready уже проверили; ещё /api/me с тестовым токеном. if [[ -f "$TEST_TOKEN_FILE" ]]; then TEST_TOKEN=$(cat "$TEST_TOKEN_FILE") ME=$(curl -fsS -H "Authorization: Bearer $TEST_TOKEN" \ "http://127.0.0.1:$API_PORT_GREEN/api/me" 2>/dev/null || echo "") if [[ -z "$ME" ]] || ! echo "$ME" | grep -q '"email"'; then log "FAIL: /api/me с тестовым токеном вернул: $ME" docker rm -f food-market-api-next || true fail "smoke-test провалился" fi log "smoke /api/me ✓" else log "(нет $TEST_TOKEN_FILE — пропускаем /api/me, только health-check)" fi else log "[dry-run] skip smoke" fi # ── 5. Switch nginx upstream ──────────────────────────────────────── log "=== Step 5/7: nginx upstream switch :$API_PORT_BLUE → :$API_PORT_GREEN ===" if [[ $DRY_RUN -eq 0 ]]; then if [[ ! -f "$NGINX_UPSTREAM_FILE" ]]; then log "WARN: $NGINX_UPSTREAM_FILE не существует — создаём впервые" sudo touch "$NGINX_UPSTREAM_FILE" fi # Atomic write: новый upstream указывает на green. TMP="$(mktemp)" cat > "$TMP" </dev/null || true run docker rename food-market-api-next food-market-api # Обновляем compose-yml tag → для будущих up-d # (используем docker compose с новой версией; перезапуск НЕ нужен, # контейнер уже работает). log "(compose-yml tag update — manual через .env API_TAG=$API_TAG)" # Переключить nginx обратно на blue-port чтобы соответствовать compose-mapping. # Контейнер уже на host port $API_PORT_BLUE? нет, мы запускали green на 8088. # Переключаем upstream обратно на 8080 чтобы dock-compose up в будущем работал. if [[ $DRY_RUN -eq 0 ]]; then # Переcоздаём green-контейнер с blue-портом (быстрый stop/start). docker stop food-market-api && docker rm food-market-api cd "$(dirname "$COMPOSE_PATH")" API_TAG="$API_TAG" docker compose up -d api TMP="$(mktemp)" cat > "$TMP" <