Сквозной пример: деплой веб-приложения

Собираем всё, что вы прочли раньше, в одну работающую систему: группа ресурсов, база данных, веб-приложение — и рабочий URL в конце.

Сквозной деплой — последовательность команд, которая из пустой подписки делает работающее приложение с базой, где после каждого шага есть проверка «получилось или нет».

До этого урока каждый сервис жил отдельно: тут VM, там Blob Storage, где-то App Service. Так учат, но так не работают. В реальной жизни вас просят другое: «выкати приложение с базой в облако, чтобы к пятнице открывалось по ссылке». И вот тут выясняется, что знать сервисы по отдельности мало — нужно уметь связать их в цепочку и на каждом шаге понимать, что именно сломалось, если не открылось.

Мы развернём типовой стек, который встречается в 8 проектах из 10: веб-приложение на Python (Flask) в App Service + управляемая база PostgreSQL. Всё через az CLI — потому что команды можно скопировать, повторить, положить в скрипт и отдать коллеге, а клики в портале не повторишь.

Что мы строим и сколько это стоит

Архитектура минимальная, но честная — приложение не ходит в интернет за базой напрямую, база не имеет пароля в коде, а всё вместе живёт в одной группе ресурсов, которую можно снести одной командой.

Resource Group: rg-shop-demo (westeurope)
│
├── App Service Plan: plan-shop (B1, Linux)
│   └── Web App: shop-demo-1207  →  https://shop-demo-1207.azurewebsites.net
│                     │
│                     │  строка подключения в App Settings
│                     ▼
└── PostgreSQL Flexible Server: pg-shop-1207
    └── database: shopdb

Порядок цен на момент написания (регион West Europe, оплата по факту): план B1 — примерно 13 $/мес, PostgreSQL Flexible на Standard_B1ms с 32 ГБ диска — примерно 15–17 $/мес. Итого около 30 $/мес, если оставить всё включённым на месяц. Учебный стенд обычно живёт часы, а не месяцы: разверните, потрогайте, удалите — заплатите центы. Точные цены всегда сверяйте в калькуляторе Azure, они зависят от региона и меняются.

Если у вас новая подписка с бесплатным периодом, часть этого попадёт под free tier: 12 месяцев бесплатного B1ms PostgreSQL и бесплатный тариф F1 для App Service. Но у F1 есть подвох — 60 минут процессорного времени в сутки, после чего приложение начинает отвечать ошибкой 429. Для урока это терпимо, для демо заказчику — нет.

Шаг 0. Переменные и вход

Первое, что делает опытный человек, — заводит переменные. Имена ресурсов будут повторяться в десятке команд, и опечатка в одной из них стоит получаса поисков. Обратите внимание на суффикс: имя веб-приложения и имя сервера БД должны быть глобально уникальными — они превращаются в публичные DNS-имена, и shop-demo кто-то уже занял в 2019 году.

az login
az account show --output table          # в той ли подписке мы вообще?

SUFFIX=$RANDOM                          # добавка для уникальности имён
RG=rg-shop-demo
LOC=westeurope
PLAN=plan-shop
APP=shop-demo-$SUFFIX
PG=pg-shop-$SUFFIX
PGUSER=shopadmin
PGPASS='S3cure-Pa55-'$SUFFIX            # в проде так не делают, см. Key Vault
echo "app=$APP  pg=$PG"

Результат:

app=shop-demo-24817  pg=pg-shop-24817

Запишите эти два имени — они понадобятся до конца урока.

Шаг 1. Группа ресурсов

Группа ресурсов — контейнер жизненного цикла. Всё, что мы создадим дальше, живёт в ней и умрёт вместе с ней. Именно поэтому учебные стенды никогда не кладут в группу с продовыми ресурсами.

az group create --name $RG --location $LOC

# проверка: группа существует и в нужном регионе
az group show --name $RG --query "{name:name, location:location, state:properties.provisioningState}" -o table

Результат:

Name          Location     State
------------  -----------  ---------
rg-shop-demo  westeurope   Succeeded

Правило, которое экономит нервы: все ресурсы — в одном регионе. Приложение в West Europe и база в East US будут работать, но каждый SQL-запрос поедет через океан (плюс 100+ мс на запрос), а трафик между регионами тарифицируется отдельно.

Шаг 2. База данных

Создаём управляемый PostgreSQL. Флаг --public-access здесь означает «разреши подключаться из указанных адресов»; значение None — не открывать никому, правила добавим отдельно и осознанно.

az postgres flexible-server create   --resource-group $RG   --name $PG   --location $LOC   --admin-user $PGUSER   --admin-password "$PGPASS"   --sku-name Standard_B1ms   --tier Burstable   --storage-size 32   --version 16   --public-access None   --yes

# база внутри сервера (сервер ≠ база!)
az postgres flexible-server db create --resource-group $RG --server-name $PG --database-name shopdb

# разрешаем подключаться сервисам Azure (это и есть наш App Service)
az postgres flexible-server firewall-rule create   --resource-group $RG --name $PG   --rule-name allow-azure-services   --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0

Строчка с 0.0.0.0 – 0.0.0.0 выглядит пугающе, но это не «пустить весь интернет». В Azure это специальное правило-маркер: «разрешить подключения из сервисов Azure». Открыть базу всему миру — это 0.0.0.0 – 255.255.255.255, и вот так делать нельзя никогда. Взрослый вариант вообще без публичного доступа — сервер внутри VNet и Private Endpoint, но для первого деплоя это лишний слой.

# проверка: сервер поднялся, база создана
az postgres flexible-server show -g $RG -n $PG --query "{state:state, host:fullyQualifiedDomainName, version:version}" -o table
az postgres flexible-server db list -g $RG -s $PG --query "[].name" -o tsv

Результат:

State  Host                                  Version
-----  ------------------------------------  -------
Ready  pg-shop-24817.postgres.database.azure.com  16

azure_maintenance
azure_sys
postgres
shopdb

Создание сервера — самая долгая операция урока, 3–5 минут. Это нормально: под капотом поднимается VM, диск, репликация бэкапов. Если команда «зависла» — она не зависла, она ждёт.

Шаг 3. План и веб-приложение

App Service Plan — это арендованные мощности (сколько ядер и памяти), а Web App — приложение, которое на них крутится. На одном плане может жить несколько приложений: платите за план, а не за каждое приложение.

az appservice plan create --resource-group $RG --name $PLAN --location $LOC --is-linux --sku B1

az webapp create   --resource-group $RG   --plan $PLAN   --name $APP   --runtime "PYTHON:3.12"

# проверка: пустое приложение уже отвечает
curl -s -o /dev/null -w "%{http_code}
" https://$APP.azurewebsites.net

Результат:

200

Если вернулось 200 — платформа жива и показывает заглушку «Your web app is running». Если Name is already taken при создании — имя занято кем-то в мире, меняйте суффикс.

Шаг 4. Строка подключения (и никаких паролей в коде)

Приложение должно узнать адрес базы. Единственный правильный канал — настройки приложения (App Settings): App Service подставит их в контейнер как переменные окружения. Ни в git, ни в .env в репозитории пароля быть не должно.

PGHOST=$(az postgres flexible-server show -g $RG -n $PG --query fullyQualifiedDomainName -o tsv)

az webapp config appsettings set -g $RG -n $APP --settings   "DATABASE_URL=postgresql://$PGUSER:$PGPASS@$PGHOST:5432/shopdb?sslmode=require"   "SCM_DO_BUILD_DURING_DEPLOYMENT=true"

# проверка: ключи на месте (значения не печатаем!)
az webapp config appsettings list -g $RG -n $APP --query "[].name" -o tsv

Результат:

DATABASE_URL
SCM_DO_BUILD_DURING_DEPLOYMENT

Два важных момента. Первый: sslmode=require — PostgreSQL во Flexible Server принимает только шифрованные соединения, без этого параметра драйвер словит отказ. Второй: SCM_DO_BUILD_DURING_DEPLOYMENT=true — команда платформе «после загрузки архива установи зависимости из requirements.txt». Без неё приложение приедет без библиотек и упадёт с ModuleNotFoundError — это ошибка номер один у новичков.

Промышленный вариант — не хранить пароль даже в App Settings, а сослаться на Key Vault. Синтаксис ссылки такой (значение подставится платформой при старте):

@Microsoft.KeyVault(SecretUri=https://kv-shop.vault.azure.net/secrets/db-url/)

Шаг 5. Деплой кода

Минимальное приложение, которое ходит в базу и показывает, что связь есть. Библиотеки внешние, локально в браузере его не запустить — это код для вашего проекта, не для кнопки.

import os
from flask import Flask
import psycopg2

app = Flask(__name__)

@app.get("/")
def index():
    with psycopg2.connect(os.environ["DATABASE_URL"]) as conn:
        with conn.cursor() as cur:
            cur.execute("SELECT version()")
            version = cur.fetchone()[0]
    return f"<h1>Магазин жив</h1><p>{version}</p>"

@app.get("/healthz")
def healthz():
    return {"status": "ok"}, 200

Рядом два файла: requirements.txt со строками flask, psycopg2-binary, gunicorn — и всё. Упаковываем и отправляем:

zip -r app.zip app.py requirements.txt

az webapp deploy --resource-group $RG --name $APP --src-path app.zip --type zip

# чем стартовать: App Service должен знать команду запуска
az webapp config set -g $RG -n $APP --startup-file "gunicorn --bind=0.0.0.0 --timeout 600 app:app"

Команда запуска — вторая классическая грабля. По умолчанию платформа пытается угадать точку входа, и для Flask-файла с нестандартным именем угадывает неверно. Явно указать gunicorn ... app:app (файл app.py, объект app) надёжнее.

Шаг 6. Проверка, что всё живо

curl -s https://$APP.azurewebsites.net/healthz
echo
az webapp log tail -g $RG -n $APP        # живые логи, Ctrl+C чтобы выйти

Результат:

{"status":"ok"}

Если вместо этого пришла страница «Application Error» — не гадайте, идите в логи. az webapp log tail покажет вывод контейнера в реальном времени: там будет либо ModuleNotFoundError (забыли сборку), либо ошибка подключения к базе (забыли firewall или sslmode), либо падение gunicorn (неверная команда запуска). Три диагноза покрывают почти все случаи.

Как это работает

Каждая команда az — это HTTP-запрос к Azure Resource Manager (ARM), единой точке входа для всего облака. CLI не «делает» ресурсы, он отправляет ARM декларацию «пусть будет ресурс такого типа с такими свойствами», а ARM передаёт её нужному провайдеру ресурсов (Microsoft.Web, Microsoft.DBforPostgreSQL). Портал, Bicep, Terraform и SDK стучатся ровно туда же — поэтому неважно, чем вы создали ресурс, увидят его все инструменты.

Отсюда два практических следствия. Первое: операции идемпотентны — повторный az group create с теми же параметрами не создаст вторую группу и не сломает первую. Значит, скрипт деплоя можно смело перезапускать. Второе: создание ресурса асинхронно. ARM почти сразу отвечает «принято», а CLI дальше опрашивает статус, пока не увидит Succeeded. Поэтому долгие команды и выглядят «зависшими».

Внутри App Service ваш zip не «раскладывается на диске сервера». Его принимает служебная площадка Kudu (она же SCM, доступна по адресу имя.scm.azurewebsites.net), сборщик Oryx распознаёт Python, ставит зависимости в виртуальное окружение и упаковывает результат. Приложение затем крутится в Linux-контейнере, который слушает порт из переменной PORT — поэтому gunicorn и биндится на 0.0.0.0, а не на 127.0.0.1.

Частые ошибки

  • Не глобально уникальное имя. shop-demo занято, Name is already taken. Добавляйте суффикс — так делают все.
  • Забыли firewall-правило для базы. Приложение получает таймаут подключения, и это выглядит как «база не работает». Она работает — она вас не пускает.
  • Нет SCM_DO_BUILD_DURING_DEPLOYMENT=true. Зависимости не устанавливаются, в логах ModuleNotFoundError: No module named 'flask'.
  • Ресурсы в разных регионах. Работает, но медленно и с оплатой межрегионального трафика. Всегда фиксируйте --location переменной.
  • Пароль в коде или в git. Ключ в репозитории — это утечка, которую находят боты за минуты. Только App Settings, а лучше Key Vault.
  • Ошибка, стоящая денег: забыли удалить стенд. План B1 плюс PostgreSQL — это ~30 $ в месяц, которые капают, даже если вы больше не открывали приложение. Учебный стенд обязан быть удалён в тот же день.
# удалить всё одной командой, не дожидаясь завершения
az group delete --name $RG --yes --no-wait

# и убедиться, что группы больше нет
az group exists --name $RG

Результат:

false

Итоги

  • Сквозной деплой — это цепочка «группа ресурсов → база → план → приложение → настройки → код», и после каждого звена нужна проверка, иначе диагностика в конце превращается в гадание.
  • Имена веб-приложения и сервера БД глобально уникальны: закладывайте суффикс сразу.
  • Секреты живут в App Settings (в идеале — ссылкой на Key Vault), никогда в коде.
  • Три главные причины «Application Error»: нет сборки зависимостей, не пускает firewall базы, неверная команда запуска. Все три видны в az webapp log tail.
  • Ресурсы одного стенда — в одной группе и одном регионе. Тогда уборка — это одна команда az group delete, а не археология по подписке.
Проверьте себя
1. Приложение задеплоилось, но отдаёт «Application Error», а в логах — ModuleNotFoundError: No module named 'flask'. Что забыли?
AСоздать firewall-правило для базы данных
BВключить SCM_DO_BUILD_DURING_DEPLOYMENT=true, чтобы платформа поставила зависимости из requirements.txt
CУказать регион при создании App Service Plan
DДобавить sslmode=require в строку подключения
2. Почему имя веб-приложения в команде az webapp create должно быть уникальным на весь мир?
AТак требует биллинг: по имени считается стоимость
BИмя становится частью публичного DNS-адреса вида имя.azurewebsites.net
CВнутри одной группы ресурсов имена и так не повторяются, это просто рекомендация
DУникальность нужна только для серверов баз данных, для веб-приложений — нет