Сквозной пример: деплой веб-приложения
Собираем всё, что вы прочли раньше, в одну работающую систему: группа ресурсов, база данных, веб-приложение — и рабочий 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, а не археология по подписке.