CI/CD, инфраструктура и облака Microsoft Azure: сервисы, интеграция с .NET-миром, AKS, сравнение с AWS
0%

Microsoft Azure: сервисы, интеграция с .NET-миром, AKS, сравнение с AWS

Microsoft Azure: сервисы, интеграция с .NET-миром, AKS, сравнение с AWS

В https://courses.digitable.life/post/devops/11-aws/ мы разобрали AWS как модель: аккаунт — граница взрыва, регион — независимая плоскость управления, сеть — источник неожиданных счетов. Azure устроен по тем же физическим законам, но принимает другие решения почти на каждой развилке. И эти решения — не косметические переименования: иерархия ресурсов, модель доступа и экономика лицензий отличаются настолько, что перенос архитектурных привычек один в один даёт плохую систему.

Azure выбирают по трём причинам, и почти всегда только по ним. Первая: организация уже платит Microsoft — Windows Server, SQL Server, Active Directory, Microsoft 365, и лицензионная экономика в Azure радикально другая. Вторая: команда пишет на .NET, и интеграция инструментов действительно бесшовная — от dotnet publish до трассировок в Application Insights без строчки конфигурации. Третья: требования по резидентности данных, комплаенс, а иногда — прямой корпоративный контракт (EA/MCA) с процентной скидкой, которой нет у конкурента.

Если ни одна из трёх не про вас — это ещё не аргумент против Azure, но повод честно сравнить, а не идти за модой. Ниже — попытка дать материал для такого сравнения.

Оговорка про цены: все цифры — порядок величины для регионов вроде westeurope/eastus на момент написания. Microsoft меняет прайс, вводит новые SKU и периодически переносит сроки объявленных изменений. Важны не центы, а соотношения.

Каркас: тенант, management group, подписка, resource group

В AWS два уровня — аккаунт и регион. В Azure их четыре, и путаница в них — источник половины проблем с доступами.

Тенант (Entra ID) — каталог удостоверений. Это про то, кто вы: пользователи, группы, приложения, service principal. Один тенант может держать много подписок; подписку можно перенести в другой тенант. Тенант — не граница биллинга и не граница ресурсов, это граница идентичности.

Management group — узел дерева для политик и назначений ролей. Дерево до шести уровней, корень — Tenant Root Group. Аналог AWS Organizations OU, но с важным отличием: назначение роли на management group наследуется вниз на все подписки, и в Azure нет прямого аналога SCP как «потолка разрешений». Роль ограничения играет Azure Policy — см. ниже.

Подписка (subscription) — граница биллинга и квот. Ближайший родственник аккаунта AWS, но заметно менее жёсткая: подписки живут в одном тенанте, и администратор тенанта при желании дотягивается до всех. Квоты (vCPU по семействам, публичные IP, число сетевых интерфейсов) — именно на уровне подписки и региона.

Resource group (RG) — обязательный контейнер жизненного цикла. Каждый ресурс лежит ровно в одной RG. У RG есть регион — но только для хранения метаданных, ресурсы внутри могут быть где угодно. Главное свойство: удаление RG каскадно удаляет всё внутри. Это одновременно лучший инструмент уборки за собой и лучший способ уничтожить прод одной командой.

Практический вывод: изоляция в Azure строится подписками, а не resource group. RG — единица развёртывания и уборки, не единица безопасности. Прод и стейдж в одной подписке разделены только ролями, а роли ошибаются.

Именование стоит зафиксировать в первый день, потому что Azure не даёт переименовывать ресурсы. Стандартная схема Microsoft: <тип>-<нагрузка>-<среда>-<регион>-<номер>, например aks-payments-prod-weu-01, kv-payments-prod-weu (у Key Vault лимит 24 символа), stpaymentsprodweu (у storage account — 3–24 символа, только строчные буквы и цифры, глобально уникально).

Entra ID, RBAC и managed identity: где ломаются мозги после AWS

Здесь самое существенное расхождение с AWS, и оно концептуальное.

Две системы ролей, а не одна. Роли Entra ID (Global Administrator, User Administrator, Application Administrator) управляют каталогом: пользователями, приложениями, MFA. Роли Azure RBAC (Owner, Contributor, Reader и сотни встроенных) управляют ресурсами: виртуалками, кластерами, хранилищами. Это разные плоскости. Global Administrator по умолчанию не имеет доступа к ресурсам вообще — пока не нажмёт «Elevate access», получив User Access Administrator на корне дерева. Инженеры, пришедшие из AWS, ищут «главную роль» и не находят.

Модель вычисления разрешений проще, чем в IAM, и от этого опаснее. В Azure RBAC назначения аддитивны: объединение всех Actions минус NotActions. Явного Deny в обычном назначении роли нет — deny assignments существуют, но создаются только платформой (Managed Applications, Blueprints), вручную их не пишут. Значит, привычный приём AWS «повесим Deny поверх всего» здесь не работает.

Роль запрета играет Azure Policy — отдельная система governance с эффектами Deny, Audit, Modify, DeployIfNotExists, AuditIfNotExists. Она перехватывает запросы к Azure Resource Manager до создания ресурса.

{
  "properties": {
    "displayName": "Запретить публичный сетевой доступ к PaaS-сервисам",
    "mode": "Indexed",
    "policyRule": {
      "if": {
        "allOf": [
          { "field": "type", "in": [
              "Microsoft.Storage/storageAccounts",
              "Microsoft.KeyVault/vaults",
              "Microsoft.Sql/servers"
          ]},
          { "field": "Microsoft.Storage/storageAccounts/publicNetworkAccess",
            "notEquals": "Disabled" }
        ]
      },
      "then": { "effect": "Deny" }
    }
  }
}

Это ровно то место, где Azure строже AWS: политика может не только запретить, но и починить ресурс (Modify добавит тег, DeployIfNotExists создаст диагностическую настройку). SCP так не умеет.

Managed identity — лучшее, что есть в Azure. Идея: ресурс (VM, App Service, Container App, под в AKS) получает собственное удостоверение в Entra ID, и токен для доступа к другому сервису берётся из локального endpoint без единого секрета. Никаких ключей, никакой ротации, никакого «а где лежит строка подключения».

Два вида. System-assigned — создаётся вместе с ресурсом, умирает вместе с ним, один к одному. User-assigned — самостоятельный объект, назначается многим ресурсам сразу; переживает пересоздание ресурса. Практическое правило: для одиночных сервисов берите system-assigned (нечего чистить), для групп одинаковых нагрузок и для всего, что пересоздаётся terraform-ом, — user-assigned, иначе при каждом replace придётся заново раздавать роли.

Как это выглядит из кода на .NET — весь секрет-менеджмент сводится к одному классу:

// Program.cs — приложение ASP.NET Core, ноль секретов в конфигурации.
// DefaultAzureCredential пробует по очереди: переменные окружения,
// workload identity, managed identity, Azure CLI разработчика.
// Один и тот же код работает на ноутбуке и в проде.
using Azure.Identity;
using Microsoft.Data.SqlClient;

var builder = WebApplication.CreateBuilder(args);

var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
{
    // В проде явно указываем client id user-assigned identity:
    // без этого на машине с несколькими identity выбор неоднозначен.
    ManagedIdentityClientId = builder.Configuration["AZURE_CLIENT_ID"]
});

// Секреты из Key Vault подмешиваются прямо в IConfiguration:
// в коде остаётся обычный builder.Configuration["Payments:ApiKey"].
builder.Configuration.AddAzureKeyVault(
    new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
    credential);

// Подключение к Azure SQL вообще без пароля: токен Entra ID вместо строки с паролем.
builder.Services.AddScoped(_ =>
{
    var conn = new SqlConnection(builder.Configuration.GetConnectionString("Db"));
    // Connection string вида:
    // "Server=sql-payments-prod.database.windows.net;Database=payments;Encrypt=True;"
    conn.AccessToken = credential
        .GetToken(new Azure.Core.TokenRequestContext(
            new[] { "https://database.windows.net/.default" }))
        .Token;
    return conn;
});

// Телеметрия: одна строка — и метрики, зависимости, исключения и распределённые
// трассировки уходят в Application Insights без ручной инструментации.
builder.Services.AddApplicationInsightsTelemetry();

var app = builder.Build();
app.MapGet("/healthz", () => Results.Ok("ok"));
app.Run();

Со стороны инфраструктуры остаётся выдать роли. Обратите внимание: доступ к данным в Azure — тоже RBAC, а не отдельный ACL, как политика бакета в S3.

# Даём managed identity право читать секреты Key Vault.
# Data-plane роли ("Key Vault Secrets User") — новая модель;
# старые access policies живут параллельно и до сих пор путают людей.
$ az role assignment create \
    --assignee-object-id "$MI_PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \
    --role "Key Vault Secrets User" \
    --scope "/subscriptions/$SUB/resourceGroups/rg-payments-prod-weu/providers/Microsoft.KeyVault/vaults/kv-payments-prod-weu"

# И право на базу — уже средствами самой SQL, а не ARM:
$ sqlcmd -S sql-payments-prod.database.windows.net -d payments -G -Q "
    CREATE USER [mi-payments-prod] FROM EXTERNAL PROVIDER;
    ALTER ROLE db_datareader ADD MEMBER [mi-payments-prod];
    ALTER ROLE db_datawriter ADD MEMBER [mi-payments-prod];"

Проверка, что identity вообще работает, — прямой запрос к IMDS изнутри машины:

$ curl -s -H "Metadata: true" \
    "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fvault.azure.net" \
  | jq -r '.access_token' | cut -c1-40
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1

Если здесь пусто — identity не назначена ресурсу; если токен есть, а вызов сервиса даёт 403 — не хватает назначения роли. Это два разных класса ошибок, и различать их надо с первой минуты.

AKS с рабочей нагрузкой: workload identity вместо секретов

В Kubernetes managed identity не вешается на под напрямую — под не является ресурсом Azure. Раньше это решал aad-pod-identity с перехватом IMDS-трафика (снят с поддержки). Сейчас — Workload Identity: кластер публикует OIDC-issuer, ServiceAccount получает проецируемый токен, Entra ID меняет этот токен на свой по федеративному доверию.

Настройка целиком — три команды и две аннотации:

# 1. Включаем OIDC issuer и надстройку workload identity на существующем кластере
$ az aks update -g rg-payments-prod-weu -n aks-payments-prod \
    --enable-oidc-issuer --enable-workload-identity
$ ISSUER=$(az aks show -g rg-payments-prod-weu -n aks-payments-prod \
    --query oidcIssuerProfile.issuerUrl -o tsv)
$ echo "$ISSUER"
https://westeurope.oic.prod-aks.azure.com/8f3c.../4b21.../

# 2. Создаём user-assigned identity и федеративное доверие к конкретному ServiceAccount
$ az identity create -g rg-payments-prod-weu -n mi-payments-prod
$ az identity federated-credential create \
    --identity-name mi-payments-prod -g rg-payments-prod-weu \
    --name fc-payments \
    --issuer "$ISSUER" \
    --subject "system:serviceaccount:payments:payments-api" \
    --audience api://AzureADTokenExchange
# 3. Манифесты: annotation связывает SA с identity, label включает вебхук инжекции
apiVersion: v1
kind: ServiceAccount
metadata:
  name: payments-api
  namespace: payments
  annotations:
    azure.workload.identity/client-id: "8b1e0f2a-4c9d-4f1a-9e77-2b6c5d3a1f04"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments-api
  namespace: payments
spec:
  replicas: 3
  selector: { matchLabels: { app: payments-api } }
  template:
    metadata:
      labels:
        app: payments-api
        azure.workload.identity/use: "true"   # без этого лейбла токен не проецируется
    spec:
      serviceAccountName: payments-api
      securityContext:
        runAsNonRoot: true
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: api
          image: crpayments.azurecr.io/payments-api:1.42.0
          ports: [{ containerPort: 8080 }]
          env:
            - name: KeyVaultName
              value: kv-payments-prod-weu
          resources:
            requests: { cpu: "250m", memory: "256Mi" }
            limits:   { memory: "512Mi" }        # limit по CPU обычно вреден
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 5

Типичная ошибка: аннотация есть, лейбла нет — под стартует, токен не смонтирован, приложение молча падает в DefaultAzureCredential с таймаутом на IMDS. Проверка в одну строку:

$ kubectl exec -n payments deploy/payments-api -- ls /var/run/secrets/azure/tokens/
azure-identity-token

Подробнее про сам Kubernetes — https://courses.digitable.life/post/devops/06-kubernetes/, про доставку манифестов через GitOps — https://courses.digitable.life/post/devops/08-helm-and-gitops/.

Сеть: VNet и её счёт

VNet — прямой аналог VPC, но с несколькими отличиями, которые бьют по рукам.

Подсети не привязаны к зоне доступности. В AWS подсеть живёт в одной AZ, и мультизональность выражается через несколько подсетей. В Azure подсеть региональная, а зона выбирается у самого ресурса. Это упрощает планирование адресов и убирает целый класс ошибок «забыли подсеть в третьей AZ».

NSG вместо двух сущностей. Network Security Group — это одновременно и security group, и NACL: stateful-правила с приоритетами, применяются к подсети или к сетевому интерфейсу. Отдельного stateless-уровня нет. Плюс к NSG есть Application Security Group — способ группировать интерфейсы по роли и писать правила «web → app», не оперируя CIDR.

Исходящий трафик стал явным. Исторически виртуалка без публичного IP всё равно получала выход в интернет через неявный SNAT платформы. Этот default outbound access отключается для новых развёртываний, и теперь выход нужно задать явно: NAT Gateway, публичный IP или outbound-правило балансировщика. Не заметив этого, команды получают «внезапно перестал резолвиться nuget.org» на свежесозданной подсети.

Цена трафика. Внутри региона и одной VNet — включая обмен между зонами доступности — трафик сейчас не тарифицируется, и это заметное отличие от AWS с её 0,01 $/ГБ в каждую сторону за cross-AZ. Зато VNet peering платный в обе стороны, примерно по 0,01 $/ГБ, и hub-and-spoke архитектура с прогоном всего трафика через центральный firewall превращает эту строку в заметную. Egress в интернет: первые 100 ГБ в месяц бесплатно, дальше ступенчато от ~0,087 $/ГБ.

Сравнение сетевых примитивов и их цены — то место, где выбор облака действительно решается:

Задача AWS Azure Что стоит знать
Изолированная сеть VPC Virtual Network В Azure подсеть региональная, не зональная
Фильтрация Security Group + NACL NSG + ASG NSG stateful; NACL-аналога нет
Выход в интернет NAT Gateway ~0,045 $/ч + 0,045 $/ГБ NAT Gateway ~0,045 $/ч + 0,045 $/ГБ Паритет; в Azure ещё есть outbound-правила LB
Приватный доступ к PaaS VPC Endpoint / PrivateLink Private Endpoint ~0,01 $/ч + 0,01 $/ГБ Endpoint на каждый сервис; при 40 сервисах это заметно
Балансировка L7 ALB Application Gateway / Front Door AppGW дороже ALB; Front Door — глобальный, ближе к CloudFront+Global Accelerator
Cross-AZ трафик 0,01 $/ГБ в каждую сторону не тарифицируется внутри VNet Реальная экономия для чатливых кластеров
Соединение сетей VPC Peering, Transit Gateway VNet Peering, Virtual WAN Peering платный с обеих сторон
Egress бесплатно 100 ГБ/мес 100 ГБ/мес Дальше похожие ступени

Отдельный сюжет — планирование IP-адресов под AKS. Это самая частая непоправимая ошибка в Azure: модель сети выбирается при создании кластера и не меняется.

Планирование IP в AKS: классический Azure CNI против overlay

Практика: берите Azure CNI Overlay по умолчанию, а классический CNI — только когда к поду обязаны обращаться напрямую из VNet (некоторые legacy-интеграции, специфические appliance). Overlay снимает потолок масштабирования и позволяет переиспользовать один и тот же pod CIDR во всех кластерах.

Вычисления: что брать под свой масштаб

У Azure пять способов запустить код, и разница между ними — это разница в эксплуатационной нагрузке, а не в производительности.

Вариант Цена (порядок) Порог входа Эксплуатационная нагрузка Когда брать
App Service (B1 ~13 $/мес, P1v3 ~120 $/мес) средняя час почти ноль: платформа обновляет ОС и рантайм Классическое веб-приложение .NET, 1–10 сервисов, команда без выделенного DevOps
Azure Functions (Consumption: 1 млн вызовов бесплатно) около нуля при простое час ноль, но холодный старт и лимиты Обработчики событий, webhooks, cron, склейка сервисов
Container Apps (~0,000024 $/vCPU-с, есть бесплатный грант) низкая при неровной нагрузке день низкая: Kubernetes внутри, но скрыт Микросервисы, где нужен масштаб до нуля и KEDA, но не нужен API Kubernetes
AKS (control plane Free 0 $ / Standard ~0,10 $/ч, узлы отдельно) зависит от узлов недели высокая: обновления, CNI, автоскейлинг, дежурства Много команд, много сервисов, нужен собственный API-слой платформы
Virtual Machines / VMSS самая низкая за vCPU день максимальная: ОС, патчи, образы Легаси, лицензированный софт, специфические требования к ядру

Ключевое, что часто понимают поздно: App Service и Container Apps закрывают потребности подавляющего большинства команд, а AKS оправдан числом команд, а не числом подов. Три сервиса в AKS — это отрицательная ценность: вы платите инженерным временем за гибкость, которой не пользуетесь. Тот же вывод мы делали про EKS в https://courses.digitable.life/post/devops/11-aws/, и он не зависит от облака.

Формула, по которой стоит принимать решение: сравните стоимость платформенной команды (в Европе — от 150 000 € в год на человека, и меньше двух человек в дежурстве по Kubernetes не бывает) со стоимостью надбавки управляемого сервиса. Container Apps дороже голых VM примерно вдвое за тот же vCPU-час; при счёте в 3000 $/мес это переплата 1500 $/мес, то есть 18 000 $ в год — заметно дешевле одного инженера.

AKS вглубь: то, что отличает его от EKS

Control plane бесплатен в Free-тарифе — редкая щедрость, у AWS EKS всегда 0,10 $/ч. Но Free-тариф не имеет финансово подкреплённого SLA и ограничен по числу узлов и по производительности плоскости управления. Для прода берите Standard (~0,10 $/ч, SLA 99,95 % с зонами) — это те же деньги, что EKS, но с честным обещанием. Premium (~0,60 $/ч) добавляет long-term support на версию Kubernetes до двух лет; берут те, кто физически не успевает обновляться раз в квартал.

Node pools вместо групп узлов. Ровно один системный пул (несёт CoreDNS, metrics-server, konnectivity) и сколько угодно пользовательских. Системный пул стоит держать отдельным и запрещать на нём прикладные поды:

$ az aks nodepool add -g rg-payments-prod-weu --cluster-name aks-payments-prod \
    --name userpool1 --node-count 3 \
    --node-vm-size Standard_D4s_v5 \
    --zones 1 2 3 \
    --enable-cluster-autoscaler --min-count 3 --max-count 20 \
    --max-pods 110 \
    --node-taints "workload=general:NoSchedule" \
    --os-sku AzureLinux

# Проверяем разделение ролей
$ kubectl get nodes -L agentpool,topology.kubernetes.io/zone
NAME                                STATUS   ROLES   AGE   VERSION   AGENTPOOL   ZONE
aks-systempool-2938471-vmss000000   Ready    <none>  31d   v1.31.3   systempool  westeurope-1
aks-systempool-2938471-vmss000001   Ready    <none>  31d   v1.31.3   systempool  westeurope-2
aks-userpool1-1847263-vmss000000    Ready    <none>  6d    v1.31.3   userpool1   westeurope-1
aks-userpool1-1847263-vmss000002    Ready    <none>  6d    v1.31.3   userpool1   westeurope-3

Обновление узлов — место, где ломаются кластеры. AKS обновляет пул по одному узлу, создавая surge-узлы. По умолчанию max-surge=1, что для пула из 20 узлов означает часы. Ставьте 33 % и обязательно настраивайте PodDisruptionBudget, иначе drain выселит все реплики сразу.

Автоскейлинг трёх уровней. Cluster Autoscaler на пуле (описан выше), HPA/KEDA на подах, и Node Autoprovisioning — реализация Karpenter под Azure, которая подбирает тип VM под ожидающие поды вместо масштабирования фиксированного пула. Плюс Virtual Nodes: поды уезжают в Azure Container Instances, когда узлов не хватает, — способ пережить всплеск без ожидания загрузки VM, но с ограничениями по сети и хранилищу.

Чего в AKS нет по сравнению с EKS: нет прямого аналога IRSA-простоты (workload identity делает то же, но настраивается многословнее), нет такого зрелого экосистемного слоя вокруг Spot, и Azure CNI исторически сложнее в отладке, чем VPC CNI. Чего нет в EKS: бесплатного control plane, встроенного контроллера GitOps (Flux ставится как надстройка кластера одной командой) и настолько же плотной интеграции с корпоративным каталогом — вход в кластер по учётке Entra ID с Conditional Access и MFA настраивается штатно.

# Вход в кластер через Entra ID: kubectl уходит за токеном в браузер,
# доступ управляется группами каталога, а не отдельным файлом aws-auth
$ az aks get-credentials -g rg-payments-prod-weu -n aks-payments-prod
$ kubectl get pods -n payments
To sign in, use a web browser to open https://microsoft.com/devicelogin
and enter the code F7K2QW9XZ to authenticate.

Инфраструктура как код: Bicep против Terraform

В Azure два законных пути, и выбор между ними — не вопрос вкуса.

Bicep — DSL Microsoft, транспилируется в ARM-шаблоны. Главное свойство: нет файла состояния. Состоянием является сам Azure Resource Manager, а what-if показывает разницу, опрашивая API. Это снимает целый класс проблем — блокировки стейта, дрейф, «терраформ хочет пересоздать всё». Взамен: Bicep умеет только Azure.

// main.bicep — AKS с workload identity и правами на ACR, без единого секрета
targetScope = 'resourceGroup'

param location string = resourceGroup().location
param clusterName string
param nodeCount int = 3
@allowed(['Free', 'Standard', 'Premium'])
param skuTier string = 'Standard'

resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
  name: 'cr${uniqueString(resourceGroup().id)}'
  location: location
  sku: { name: 'Premium' }          // Premium нужен для private endpoint и гео-репликации
  properties: { adminUserEnabled: false }
}

resource aks 'Microsoft.ContainerService/managedClusters@2024-05-01' = {
  name: clusterName
  location: location
  identity: { type: 'SystemAssigned' }
  sku: { name: 'Base', tier: skuTier }
  properties: {
    dnsPrefix: clusterName
    oidcIssuerProfile: { enabled: true }
    securityProfile: { workloadIdentity: { enabled: true } }
    networkProfile: {
      networkPlugin: 'azure'
      networkPluginMode: 'overlay'   // тот самый выбор, который потом не поменять
      networkPolicy: 'cilium'
      networkDataplane: 'cilium'
      podCidr: '10.244.0.0/16'
      serviceCidr: '10.0.0.0/16'
      dnsServiceIP: '10.0.0.10'
    }
    agentPoolProfiles: [
      {
        name: 'systempool'
        mode: 'System'
        count: nodeCount
        vmSize: 'Standard_D4s_v5'
        availabilityZones: ['1', '2', '3']
        osSKU: 'AzureLinux'
        maxPods: 110
      }
    ]
    autoUpgradeProfile: { upgradeChannel: 'patch' }  // патчи прилетают сами
  }
}

// Право тянуть образы — назначение роли, а не пароль реестра в secret
resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  scope: acr
  name: guid(acr.id, aks.id, 'AcrPull')
  properties: {
    principalId: aks.properties.identityProfile.kubeletidentity.objectId
    principalType: 'ServicePrincipal'
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      '7f951dda-4ed3-4680-a7ca-43fe172d538d')   // AcrPull
  }
}

output clusterFqdn string = aks.properties.fqdn
# Сухой прогон: ARM сам считает разницу с текущим состоянием
$ az deployment group what-if -g rg-payments-prod-weu \
    --template-file main.bicep --parameters clusterName=aks-payments-prod

Resource and property changes are indicated with these symbols:
  + Create
  ~ Modify
  = NoChange

The deployment will update the following scope:
Scope: /subscriptions/8f3c.../resourceGroups/rg-payments-prod-weu
  ~ Microsoft.ContainerService/managedClusters/aks-payments-prod
    ~ properties.agentPoolProfiles[0].count: 3 -> 5
  + Microsoft.ContainerRegistry/registries/crx7k2m9

Resource changes: 1 to create, 1 to modify, 12 no change.

Terraform с провайдером azurerm — то же самое, но с состоянием, модулями и, главное, с возможностью описать в одном плане Azure, Cloudflare, Datadog и GitHub. Подробнее про сам Terraform — https://courses.digitable.life/post/devops/09-terraform-and-iac/.

# backend.tf — состояние в Blob Storage с блокировкой через lease
terraform {
  required_version = ">= 1.9"
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
  }
  backend "azurerm" {
    resource_group_name  = "rg-tfstate-shared-weu"
    storage_account_name = "sttfstateshared"
    container_name       = "tfstate"
    key                  = "payments/prod.tfstate"
    use_azuread_auth     = true   # без ключей доступа к storage account
  }
}

provider "azurerm" {
  features {
    key_vault { purge_soft_delete_on_destroy = false }  # не даём terraform стирать секреты навсегда
  }
  subscription_id = var.subscription_id
}

resource "azurerm_kubernetes_cluster" "this" {
  name                      = "aks-payments-prod"
  location                  = var.location
  resource_group_name       = azurerm_resource_group.this.name
  dns_prefix                = "aks-payments-prod"
  sku_tier                  = "Standard"
  oidc_issuer_enabled       = true
  workload_identity_enabled = true
  automatic_upgrade_channel = "patch"
  local_account_disabled    = true   # только вход через Entra ID

  default_node_pool {
    name                 = "systempool"
    vm_size              = "Standard_D4s_v5"
    zones                = [1, 2, 3]
    auto_scaling_enabled = true
    min_count            = 3
    max_count            = 6
    os_sku               = "AzureLinux"
    only_critical_addons_enabled = true   # taint CriticalAddonsOnly
    upgrade_settings { max_surge = "33%" }
  }

  identity { type = "SystemAssigned" }

  network_profile {
    network_plugin      = "azure"
    network_plugin_mode = "overlay"
    network_policy      = "cilium"
    network_data_plane  = "cilium"
    pod_cidr            = "10.244.0.0/16"
    service_cidr        = "10.0.0.0/16"
    dns_service_ip      = "10.0.0.10"
  }

  lifecycle {
    ignore_changes = [
      # Автоскейлер меняет count вне terraform — не воюем с ним
      default_node_pool[0].node_count,
      # Патч-версию поднимает канал автообновления
      kubernetes_version,
    ]
  }
}
Критерий Bicep Terraform (azurerm)
Состояние нет, состояние — сам ARM файл state, нужен backend и блокировки
Мультиоблачность только Azure любой провайдер в одном плане
Скорость появления новых API день в день с релизом Azure отставание от недель до месяцев
Порог входа ниже, если знаком ARM ниже, если знаком Terraform
Дрейф не проблема: what-if спрашивает реальность требует refresh, конфликтует с ручными правками
Удаление ресурсов сложнее: complete mode рискован штатный destroy
Экосистема модулей Azure Verified Modules Registry, огромный
Кому чистый Azure, команды внутри Microsoft-стека смешанный ландшафт, уже есть Terraform

Практический выбор: если весь ландшафт — Azure и вы не хотите заводить процесс вокруг стейта, Bicep честно лучше. Во всех остальных случаях Terraform.

CI/CD: GitHub Actions с OIDC и что стало с Azure DevOps

Долгоживущий service principal с секретом в переменной пайплайна — устаревшая и опасная практика. Правильный путь — federated credential: GitHub выдаёт OIDC-токен, Entra ID меняет его на свой.

# Доверяем ровно ветке main ровно одного репозитория
$ az ad app federated-credential create --id "$APP_ID" --parameters '{
    "name": "gh-payments-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:contoso/payments:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'
# .github/workflows/deploy.yml
name: build-and-deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write      # без этого OIDC-токен не выдадут
  contents: read

env:
  ACR: crpayments.azurecr.io
  IMAGE: payments-api

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '9.0.x' }

      - name: Тесты
        run: dotnet test --configuration Release --logger trx

      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}      # это идентификаторы, не секреты
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      # Логин в ACR тоже без пароля — по токену той же федерации
      - run: az acr login --name crpayments

      - id: push
        run: |
          TAG="${GITHUB_SHA::12}"
          docker build -t "$ACR/$IMAGE:$TAG" .
          docker push "$ACR/$IMAGE:$TAG"
          echo "digest=$(docker inspect --format='{{index .RepoDigests 0}}' "$ACR/$IMAGE:$TAG")" >> "$GITHUB_OUTPUT"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production        # ручное подтверждение и защита ветки
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - uses: azure/aks-set-context@v4
        with:
          resource-group: rg-payments-prod-weu
          cluster-name: aks-payments-prod

      - name: Развёртывание по digest, а не по тегу
        run: |
          kubectl -n payments set image deploy/payments-api \
            api="${{ needs.build.outputs.digest }}"
          kubectl -n payments rollout status deploy/payments-api --timeout=5m

Развёртывание по digest, а не по тегу, — важная деталь: тег изменяем, digest нет, и только он даёт воспроизводимость. Разбор этой темы — в https://courses.digitable.life/post/devops/05-containers-and-registries/, а стратегии выкатки (canary, blue-green) — в https://courses.digitable.life/post/devops/04-cd-and-release-strategies/.

Про Azure DevOps: он жив, поддерживается и у многих корпоративных клиентов остаётся основным — особенно из-за Azure Boards и жёстких требований к аудиту. Но вектор развития Microsoft явно направлен в GitHub: новые возможности появляются там первыми. Для нового проекта в 2026 году брать Azure Pipelines стоит только при наличии конкретной причины — например, уже настроенных self-hosted агентов в закрытом контуре и лицензий. Сравнение платформ CI — https://courses.digitable.life/post/devops/03-modern-ci-platforms/.

Данные и наблюдаемость

Azure SQL Database — не «SQL Server в облаке», а отдельная PaaS-реализация: своя модель ресурсов (vCore или DTU), автоматические бэкапы с point-in-time restore, встроенная гео-репликация. Serverless-уровень умеет засыпать при простое и тарифицироваться посекундно — идеально для dev/test, опасно для прода из-за холодного старта в десятки секунд. Для переноса легаси, которому нужны SQL Agent, cross-database queries и CLR, существует Managed Instance — дороже, но совместимо почти полностью.

Ближайшая аналогия из мира AWS: Azure SQL DB ≈ Aurora по духу (переписанный движок под облако), Managed Instance ≈ RDS for SQL Server. Про моделирование данных и выбор СУБД — https://courses.digitable.life/post/databases/00-overview/.

Application Insights и Log Analytics — здесь Azure выигрывает у AWS в удобстве и проигрывает в цене. Автоматическая инструментация .NET даёт карту зависимостей, распределённые трассировки и профилирование без единой строки кода. Запросы пишутся на KQL, который по выразительности заметно превосходит CloudWatch Logs Insights:

// Топ медленных зависимостей за сутки с разбивкой по типу и перцентилями
dependencies
| where timestamp > ago(1d) and cloud_RoleName == "payments-api"
| summarize
    calls   = count(),
    p50     = percentile(duration, 50),
    p95     = percentile(duration, 95),
    p99     = percentile(duration, 99),
    fails   = countif(success == false)
  by type, target
| extend fail_rate = round(100.0 * fails / calls, 2)
| where calls > 100
| order by p95 desc
| take 20

Цена — примерно 2,30 $ за гигабайт приёма в Log Analytics. Приложение, пишущее debug-логи из тридцати подов, легко даёт 100 ГБ в сутки, то есть счёт за наблюдаемость больше счёта за вычисления. Меры: sampling в Application Insights (адаптивный включён по умолчанию, но его выключают «чтобы видеть всё»), Basic Logs-тариф для объёмных таблиц, retention и экспорт холодного в Storage. Подробно про метрики, SLO и дежурства — https://courses.digitable.life/post/devops/16-observability-and-oncall/.

Стоимость: реальный расчёт и главный рычаг Microsoft

Рычаги стоимости в Azure: Hybrid Benefit и резервирование

Azure Hybrid Benefit — единственный аргумент, который делает Azure объективно дешевле AWS, и он работает только для Windows Server и SQL Server с активной Software Assurance. Разница не косметическая: лицензия Windows добавляет примерно 0,046 $ на ядро в час, то есть на четырёхъядерной машине — около 134 $/мес поверх цены железа. Для парка из пятидесяти Windows-машин это ~80 000 $ в год, которые Hybrid Benefit просто убирает. Для SQL Server экономия ещё выше.

Если ваш ландшафт — Linux и .NET на кроссплатформенном рантайме, этот рычаг не работает вовсе, и ценовое сравнение с AWS сводится к торгу по контракту.

Остальные механизмы скидок:

Механизм Скидка Гибкость Обязательство Кому
Pay-as-you-go 0 % полная нет всплески, эксперименты
Spot VM до 90 % вытеснение с уведомлением ~30 с нет CI-агенты, батчи, stateless-пулы AKS
Savings plan for compute до ~65 % любые серии VM и регионы, App Service, Container Apps $/час на 1 или 3 года дефолт для смешанной нагрузки
Reserved VM Instances до ~72 % привязка к серии и региону, обмен возможен 1 или 3 года стабильный крупный парк одного типа
Azure Hybrid Benefit до 40 % на Windows, до 55 % на SQL полная, включается флагом наличие Software Assurance любой Windows/SQL-парк
Dev/Test подписка Windows и SQL без лицензионной платы только для непрода подписка Visual Studio стейджи, песочницы, CI

Складываются они не аддитивно, а мультипликативно: reservation действует на вычислительную часть, Hybrid Benefit убирает лицензионную. Отсюда маркетинговые «до 80 %» — арифметика честная, но требует и SA, и трёхлетнего обязательства.

Разбор счёта из CLI — первое, что стоит настроить:

# Что растёт: разбивка за месяц по сервисам
$ az costmanagement query --type ActualCost --timeframe MonthToDate \
    --scope "/subscriptions/$SUB" \
    --dataset-aggregation '{"total":{"name":"Cost","function":"Sum"}}' \
    --dataset-grouping name="ServiceName" type="Dimension" \
    --query "properties.rows" -o tsv | sort -k1 -rn | head -8
1849.77  Virtual Machines
 760.12  Azure SQL Database
 512.40  Log Analytics          <- почти как база данных, стоит проверить sampling
 411.05  Azure Kubernetes Service
 268.30  Bandwidth
 154.90  Private Link
  96.44  Storage
  61.20  Key Vault

# Кто владеет: разбивка по тегу, который вы обязаны навязать политикой
$ az costmanagement query --type ActualCost --timeframe MonthToDate \
    --scope "/subscriptions/$SUB" \
    --dataset-grouping name="team" type="TagKey" --query "properties.rows" -o tsv

Теги в Azure не наследуются автоматически от подписки или RG к ресурсам. Это ровно та же проблема, что в AWS: без Modify-политики, дописывающей теги, отчёт получается на треть «ничьим». Политика с эффектом Modify и inheritTagFromResourceGroup решает вопрос централизованно, не трогая шаблоны команд.

Про сравнение экономики облака и своего железа целиком — https://courses.digitable.life/post/devops/15-cloud-cost-and-tradeoffs/.

Честное сравнение с AWS

Сначала карта соответствий — она нужна, чтобы читать чужую документацию:

Функция AWS Azure
Изоляция биллинга Account Subscription
Группировка для политик Organizations OU + SCP Management Group + Azure Policy
Каталог пользователей IAM Identity Center Entra ID (сам тенант)
Роль без секретов IAM Role + IRSA Managed Identity + Workload Identity
Секреты Secrets Manager / Parameter Store Key Vault
Объектное хранилище S3 Blob Storage
Управляемый Kubernetes EKS (0,10 $/ч) AKS (Free 0 $ / Standard 0,10 $/ч)
Контейнеры без узлов Fargate, App Runner Container Apps, Container Instances
PaaS для веб-приложений Elastic Beanstalk, App Runner App Service
Функции Lambda Functions
Реляционные СУБД RDS, Aurora Azure SQL DB, MI, Flexible Server
NoSQL DynamoDB Cosmos DB
Реестр образов ECR ACR
CDN CloudFront Front Door / CDN
Метрики и логи CloudWatch Azure Monitor + Log Analytics
Аудит API CloudTrail Activity Log + Entra audit logs
IaC от вендора CloudFormation, CDK ARM, Bicep

Теперь по существу — где реальная разница, а не разные слова:

Где Azure сильнее. Экономика Windows и SQL Server через Hybrid Benefit — не догоняется ничем. Единый корпоративный каталог: одна учётка с Conditional Access, MFA и группами покрывает и Microsoft 365, и вход в AKS, и доступ к базе. Managed identity удобнее и повсеместнее, чем IRSA. Application Insights даёт трассировки .NET-приложения буквально бесплатно по усилиям. Cross-AZ трафик внутри VNet не тарифицируется. Azure Policy умеет чинить, а не только запрещать. Регионов больше, и в ряде стран Azure присутствует там, где AWS нет.

Где AWS сильнее. Глубина и предсказуемость IAM: явный Deny, permission boundaries, симулятор политик — в Azure аналогичный контроль собирается из RBAC и Policy и получается многословнее. Зрелость и стабильность API: провайдеры и SDK ломаются реже, документация точнее. Ассортимент инстансов и глубина спот-рынка. Экосистема сторонних инструментов почти всегда сначала поддерживает AWS. Наконец, у Azure заметно чаще встречаются региональные сбои плоскости управления и «превью, которое живёт три года».

Где паритет. Цена базового вычисления и хранения различается на единицы процентов и определяется вашим контрактом, а не прайсом. Оба облака одинаково плохо прощают отсутствие тегов, отсутствие бюджетных алертов и отсутствие учений по восстановлению.

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

Типичные ошибки

  1. Удаление resource group «для порядка». Каскад необратим. Ставьте CanNotDelete lock на прод-RG и ReadOnly — на сетевые.
  2. Путаница ролей Entra ID и Azure RBAC. Global Administrator без Owner не может ничего с ресурсами, Owner без ролей каталога не заведёт приложение. Раздавайте обе плоскости осознанно и через PIM с активацией по запросу.
  3. Free-тариф AKS в проде. Нет SLA на плоскость управления — при её деградации вы не сможете деплоить и масштабироваться, а обращение в поддержку не будет иметь опоры.
  4. Классический Azure CNI без расчёта адресов. Кластер упирается в подсеть, модель сети не меняется, лечится пересозданием. Считайте IP до создания.
  5. publicNetworkAccess: Enabled на PaaS. Storage account, Key Vault и SQL по умолчанию доступны из интернета с аутентификацией. Закрывайте политикой и Private Endpoint.
  6. Остановка VM из ОС вместо деаллокации. shutdown -h now внутри гостя оставляет машину в состоянии Stopped и продолжает тарифицировать вычисления. Только az vm deallocate останавливает счёт (диски всё равно оплачиваются).
  7. Диски и публичные IP от удалённых машин. Не удаляются автоматически; managed disk уровня Premium SSD на 1 ТБ — это ~135 $/мес за ничто.
  8. Игнорирование квот vCPU. Квоты выданы на семейство и регион; масштабирование в час пик упрётся в лимит, а не в деньги. Запрашивайте повышение заранее.
  9. Log Analytics без sampling и retention. Самая частая строка «почему счёт вырос» после виртуалок.
  10. Key Vault в горячем пути. Лимит порядка 2000 операций на 10 секунд на хранилище; приложение, читающее секрет на каждый запрос, получает 429. Кэшируйте и обновляйте по TTL.
  11. Секрет service principal в переменной пайплайна. Живёт год, утекает навсегда. Только federated credentials.
  12. Один тенант, одна подписка, всё в ней. Прод, стейдж и эксперименты, разделённые только именами RG, — то же самое, что один аккаунт AWS на всё.
  13. Ставка на превью-сервис. В Azure статус Preview держится долго, SLA не действует, а API меняется. Для прода — только GA.
  14. Отсутствие плана на региональный сбой. Paired regions — это про порядок восстановления Microsoft, а не про вашу репликацию. Гео-избыточность включается явно и стоит денег.

Практика продакшена: как это обычно устроено

Зрелая установка почти всегда сходится к схеме Cloud Adoption Framework: платформенные подписки отдельно, нагрузки отдельно, сеть — hub-and-spoke, доступы — через группы Entra ID с активацией по запросу.

Минимальный набор, который надо сделать в первую неделю и который потом окупается:

  1. Дерево management group и назначение Azure Policy на уровне Landing Zones: запрет публичного доступа к PaaS, обязательные теги через Modify, разрешённые регионы, обязательные диагностические настройки через DeployIfNotExists.
  2. Отдельные подписки под прод, непрод и песочницу; на песочнице — бюджет с автоматическим действием при превышении.
  3. Log Analytics workspace в платформенной подписке, все диагностические логи стекаются туда политикой, а не руками.
  4. Federated credentials для CI вместо секретов, отдельное приложение на каждый репозиторий и каждую среду.
  5. PIM (Privileged Identity Management): постоянных Owner нет, роль активируется на четыре часа с обоснованием и MFA.
  6. Бюджеты и алерты на прогноз — до того, как счёт удивит, а не после.
  7. Учения по восстановлению раз в квартал: развернуть Bicep/Terraform в чистой подписке, восстановить базу из point-in-time, проверить целостность.

Схема сети, к которой приходят почти все:

Две вещи, которые в этой схеме дороже, чем кажется: peering-трафик (он платный с обеих сторон, а весь egress прогоняется через hub — то есть тарифицируется дважды) и Azure Firewall (около 1,25 $/час за Standard плюс обработка данных — под 1000 $/мес до единого пакета полезной нагрузки). Для команды из десяти человек это часто избыточно: NSG плюс NAT Gateway плюс Private Endpoint закрывают ту же модель угроз за десятки долларов. Firewall берут, когда есть требование централизованной инспекции от службы безопасности, а не потому что «так на референсной архитектуре».

Про безопасность конвейера, подпись образов и сканирование — https://courses.digitable.life/post/devops/17-security-in-pipeline/. Про сам язык, ради которого чаще всего приходят в Azure, — https://courses.digitable.life/post/csharp/00-overview/.

Мини-итог

  • Иерархия Azure — четыре уровня: тенант (идентичность), management group (политики), подписка (биллинг и квоты), resource group (жизненный цикл). Изоляция строится подписками, RG — не граница безопасности.
  • Две независимые системы ролей: Entra ID управляет каталогом, Azure RBAC — ресурсами. Явного Deny в RBAC нет; роль запрета и починки играет Azure Policy.
  • Managed identity и workload identity — сильнейшая сторона платформы: приложение на .NET работает вообще без секретов, один и тот же код на ноутбуке и в проде.
  • Сеть: подсети региональные, cross-AZ внутри VNet бесплатен, зато peering платный в обе стороны, а исходящий доступ теперь надо задавать явно. Модель CNI для AKS выбирается один раз и навсегда — считайте IP заранее.
  • AKS даёт бесплатный control plane, но прод требует Standard. Выбирайте overlay, разделяйте системный и пользовательский пулы, настраивайте PDB до первого обновления.
  • Порог входа важнее моды: App Service и Container Apps закрывают большинство задач, AKS оправдан числом команд, а не числом подов.
  • Bicep выигрывает у Terraform в чистом Azure-ландшафте (нет стейта, API день в день), Terraform — везде, где облако не одно.
  • Единственный объективный ценовой аргумент в пользу Azure — Azure Hybrid Benefit для Windows и SQL Server. Без Windows-лицензий сравнение с AWS сводится к вашему контракту.

Источники

Что дальше

Azure и AWS — два крупнейших, но далеко не единственных варианта. У Google Cloud другая философия сети и лучший в отрасли управляемый Kubernetes, у Yandex Cloud и региональных провайдеров — своя ниша, определяемая законодательством и близостью поддержки. Об этом — следующая статья: Google Cloud, Yandex Cloud и другие облака: чем отличаются и когда их берут.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов