모델로 가는 하나의 문, 2부. Terraform인가, Bicep인가, ARM인가
Bicep은 state file이 없고 Entra ID를 만들지 못한다. 2부는 게이트웨이의 IaC 레이어를 고르고, 프로덕션을 스스로 업그레이드하는 모델 버전 기본값을 끈다.

이 플랫폼의 Terraform에서 가장 결과가 큰 한 줄은 아무도 쓰지 않는 줄이다. Azure 모델 배포의 version_upgrade_option은 기본값이 OnceNewDefaultVersionAvailable이어서, Azure가 새 기본 버전이 생겼다고 판단할 때마다 배포가 스스로 더 새로운 모델 버전으로 옮겨 간다. 모델 라이프사이클을 소유하는 것이 존재 이유의 전부인 게이트웨이가 그것을 기본값에 맡겨 둘 수는 없다. 이번 편은 1부의 아키텍처 아래에 인프라 레이어를 세우고, 실제로 하중을 받는 근거로 Terraform, Bicep, ARM 사이에서 선택하며, 라이프사이클을 조용히 도로 가져가는 두 개의 기본값을 끈다.
세 가지 언어, 그러나 진짜 질문은 하나
이 비교는 보통 문법을 두고 논쟁이 붙는데, 문법은 가장 흥미 없는 축이다. ARM JSON은 기저층이다. 모든 것은 결국 ARM 템플릿이 되고, Bicep이 내놓은 결과물을 디버깅할 때를 빼면 이제 아무도 그것을 손으로 작성하지 않는다. 그러면 후보는 둘이 남고, 그 사이를 가르는 질문은 하나다.
Bicep은 ARM으로 트랜스파일되는 도메인 특화 언어로, Azure CLI에 함께 실려 오고, state file이 필요 없으며, deployment stacks라는 라이프사이클 관리에 대한 실질적인 답을 갖고 있다. 스택은 자신이 관리하는 리소스를 추적하고, 템플릿에서 빠진 리소스의 운명은 actionOnUnmanage가 결정한다. detachAll은 리소스를 계속 실행되되 추적되지 않는 상태로 두고, deleteResources는 리소스를 삭제하며, deleteAll은 리소스와 리소스 그룹을 함께 삭제한다. 스택은 deny 설정도 지녀서, denySettingsMode를 denyDelete나 denyWriteAndDelete로 두고 특정 액션과 프린시펄을 제외한 채 만들 수 있다. 이 마지막 능력에는 Terraform에 직접적인 대응물이 없다. plan이 삭제를 만들어 내지 않는 것이 아니라, Azure 자체가 삭제를 거부하는 것이기 때문이다.
Terraform은 state file, 풀 리퀘스트에서 리뷰할 수 있는 plan, 그리고 이 플랫폼에는 결정적으로, 하나의 의존성 그래프 안에 여러 provider를 가져온다. 실제로 결론을 가르는 질문이 이것이다. 이 플랫폼이 Azure Resource Manager 컨트롤 플레인 바깥의 무언가를 관리하는가?
관리한다. 1부의 테넌트 온보딩은 팀에게 Entra ID 애플리케이션, 서비스 프린시펄, 클라이언트 자격 증명을 건네고, 거기서 나온 오브젝트 ID를 API Management 구독과 쿼터에 연결한다. Entra ID 오브젝트는 ARM 리소스가 아니다. Bicep은 그것을 만들 수 없다. Bicep을 우선하는 플랫폼은 아이덴티티용 두 번째 도구와 그 둘을 잇는 수동 단계를 갖게 되는데, 바로 그 이음새에서 해체된 팀이 유효한 자격 증명을 1년 더 쥐고 있게 된다. Terraform은 azurerm, azuread, azapi를 하나의 그래프에 담으므로, 애플리케이션 등록과 그것에 의존하는 API Management 구독이 함께 만들어지고, 함께 바뀌고, 함께 파괴된다.
그래서 이 플랫폼은 Terraform이다. 정직한 반대 추는 이것이다. 플랫폼이 순수하게 ARM 플레인 인프라뿐이고 조직이 Azure Policy와 deployment stacks를 이미 잘 운영하고 있다면, denyWriteAndDelete를 건 Bicep이, 누구든 targeted apply로 덮어쓸 수 있는 Terraform plan보다 더 강한 보장이다.
두 개의 플레인, 두 개의 state file
1부의 분리(데이터 플레인은 API Management, 컨트롤 플레인은 AKS 위의 Python 서비스)는 HCL을 한 줄이라도 쓰기 전에 명시해 둘 가치가 있는 인프라적 귀결을 갖는다. 이 두 레이어의 변경 빈도는 100배 규모로 다르다. API Management 인스턴스, AKS 클러스터, 네트워크는 1년에 몇 번 바뀐다. 모델 배포, 백엔드, 정책은 매주, 때로는 매일 바뀐다.
둘을 하나의 state file에 넣으면 일상적인 모델 배포 변경이 AKS 클러스터를 상대로 plan을 돌리게 되고, 중간에 실패한 apply는 두 레이어를 모두 반쯤 적용된 상태로 남긴다. 분리한다:
infra/
10-platform/ # resource group, network, APIM instance, AKS, Log Analytics
20-models/ # Foundry account, model deployments, APIM backends
30-tenants/ # Entra ID apps, APIM subscriptions, quotas
각 디렉터리는 자체 state를 가진 독립 루트 모듈이고, 뒤쪽 레이어는 앞쪽 레이어를 terraform_remote_state로, 또는 더 낫게는 리소스를 이름으로 찾아오는 데이터 소스로 읽는다. 데이터 소스는 더 느리고 더 장황하지만 그만한 값을 한다. 10-platform의 state가 리팩터링되어도 20-models가 깨지지 않는다는 뜻이기 때문이다.
티어 결정, 비용 결정이 아닌 이유
1부는 게이트웨이가 Anthropic Claude를 네이티브로 중개한다고 했다. 그 요구사항 하나가 API Management 티어를 정한다. Anthropic Messages API 스키마는 클래식 티어가 아니라 v2 티어에서 지원되기 때문이다. v2 티어는 클래식 티어의 악명 높은 긴 프로비저닝 대기 대신 몇 분 안에 배포되고, Basic v2와 Standard v2에서 10 유닛, Premium v2에서 30 유닛까지 확장되며, Standard v2 이상은 가상 네트워크 통합과 인바운드 프라이빗 엔드포인트를 지원한다.
포기하는 것도 작지 않으며, 인시던트 중에 아는 것보다 지금 알아 두는 편이 낫다. v2 티어는 현재 멀티 리전 배포, 인스턴스의 백업과 복원, Event Grid로의 이벤트 전송, Git 기반 서비스 구성, Management API 직접 접근, self-hosted gateway, Azure DDoS Protection을 지원하지 않는다. 또한 클래식 티어에서 v2 티어로 가는 업그레이드 경로가 없고, 리소스 이동도 없다. Developer나 Standard로 시작했다가 나중에 Anthropic 지원이 필요해진 게이트웨이는 스케일 작업이 아니라 마이그레이션이다.
따라서 플랫폼 모듈은 여기서 시작한다:
resource "azurerm_api_management" "gw" {
name = "apim-genai-${var.env}"
location = azurerm_resource_group.platform.location
resource_group_name = azurerm_resource_group.platform.name
publisher_name = "Platform Engineering"
publisher_email = "platform@example.net"
# StandardV2 is required for the Anthropic Messages API schema.
# Capacity is units, not tokens: 1 unit, scale to 10 without redeploying.
sku_name = "StandardV2_1"
identity {
type = "SystemAssigned"
}
}
두 가지 provider 제약은 포스트모템이 아니라 그 블록 옆의 주석에 있어야 한다. delegation 블록은 모든 V2 SKU와 Consumption에서 그대로 거부된다. 그리고 public_ip_address_id는 가상 네트워크에 배포된 Developer와 Premium 인스턴스에만 허용되므로, V2 인스턴스는 그 방법으로 아웃바운드 IP를 고정할 수 없다. 모델 백엔드가 IP 허용 목록 뒤에 있다면 이것이 문제가 된다.
모델 배포, 그리고 프로덕션을 움직이는 기본값
모델은 azurerm_cognitive_account 하나에 논리 모델당 azurerm_cognitive_deployment 하나다. 이 글의 리드가 사는 곳이 바로 여기다:
resource "azurerm_cognitive_deployment" "chat_default" {
name = "chat-default"
cognitive_account_id = azurerm_cognitive_account.foundry.id
model {
format = "OpenAI"
name = "gpt-4.1"
version = "2025-04-14" # pinned, never omitted
}
sku {
name = "GlobalStandard"
capacity = 300 # thousands of TPM: 300 = 300,000 TPM
}
# Default is OnceNewDefaultVersionAvailable, which upgrades this
# deployment when Azure changes the default version. The whole point
# of the catalog is that we decide when a model version changes.
version_upgrade_option = "NoAutoUpgrade"
}
이 블록에서 틀리기 쉬운 것이 세 가지다. capacity는 유닛 수도 아니고 날것의 TPM 수치도 아니다. 천 단위의 tokens-per-minute이고, 기본값은 1, 즉 1,000 TPM이다. capacity를 명시하지 않고 만든 배포는 테스트 스크립트 하나에도 스로틀링된다. version 필드는 선택 사항이고, 생략하면 생성 시점의 기본 버전이 무엇이든 그것이 할당되므로, 리소스의 동작이 apply를 실행한 날짜의 함수가 된다. 그리고 version_upgrade_option에는 OnceNewDefaultVersionAvailable, OnceCurrentVersionExpired, NoAutoUpgrade 세 값이 있는데, 결정을 우리 손에 남겨 두는 것은 마지막 하나뿐이다. OnceCurrentVersionExpired는 옹호할 수 있는 중간값이다. 고정한 버전을 Azure가 퇴역시킬 때까지 유지하다가, 깨지는 대신 옮겨 간다. 모델마다 하나를 의도적으로 고르고, 9부가 만들 카탈로그에 어느 것을 골랐는지 기록한다.
이 리소스의 또 한 가지 속성은 에러가 아니라 이유 없이 느린 apply로 나타난다. provider는 배포를 만드는 동안 계정 수준 잠금을 잡으므로, 하나의 Foundry 계정 위의 배포 11개는 -parallelism이 뭐라 하든 병렬로 생성되지 않는다. 모델 배포를 워크로드별로 여러 계정에 나누는 것은 쿼터 결정인 만큼이나 처리량 결정이다.
azurerm이 멈추고 azapi가 시작되는 곳
Azure의 AI 표면은 어떤 Terraform provider의 릴리스 주기보다 빠르게 움직이는데, 이는 불평이 아니라 구조적 사실이다. Microsoft의 공식 가이드도 두 provider를 함께 지목한다. 안정된 리소스에는 azurerm, 그리고 provider 업데이트를 기다리지 않고 최신 기능을 따라잡도록 Azure Resource Manager API를 직접 구동하는 azapi다.
이것이 난장판이 되지 않게 막는 실용 규칙은 이렇다. azapi는 리소스 단위로 쓰고, 서브시스템 전체에는 절대 쓰지 않는다. 프리뷰 기능 하나를 위한 azapi_resource 하나가 azurerm 리소스 스무 개 옆에 앉아 ID로 그것들을 참조하는 것은 괜찮고 되돌릴 수 있다. 리소스의 절반이 날것의 API 바디인 플랫폼은, Terraform을 쓰는 이유였던 스키마 검증과 읽을 수 있는 plan을 이미 포기한 것이다. 모든 azapi 블록에는 그것이 기다리는 azurerm 리소스의 이름을 적은 주석을 달아, 되돌아가는 마이그레이션이 고고학 프로젝트가 아니라 하나의 작업이 되게 한다.
state, 백엔드, 그리고 당신을 구하는 지루한 부분
원격 state는 Azure Storage 계정에, 환경당 컨테이너 하나로 두고, blob lease 잠금을 쓴다. azurerm 백엔드에 대해 Terraform이 네이티브로 처리한다. storage 계정 자체는 Terraform에 넣지 않는다. 문서화된 스크립트로 한 번만 만든다. 자신이 저장하는 state 안에 사는 state 백엔드는 나쁜 날을 기다리는 순환 의존성이기 때문이다.
terraform {
required_version = "~> 1.9"
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstategenai"
container_name = "prod"
key = "10-platform.tfstate"
use_azuread_auth = true # no storage keys in the pipeline
}
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.36" }
azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
azapi = { source = "Azure/azapi", version = "~> 2.0" }
}
}
use_azuread_auth가 논쟁할 가치가 있는 줄이다. 이것이 없으면 파이프라인은 storage 계정 키로 state 백엔드에 인증하는데, 그 키는 모든 환경의 state에 대한 전체 접근 권한을 가진 채 변수 그룹에 놓인 장수명 시크릿이다. 이것이 있으면 파이프라인의 워크로드 아이덴티티가 역할 할당으로 인가되고, state file은 다른 모든 것과 같은 아이덴티티 모델을 물려받는다.
정책 XML은 파일에 산다
1부는 정책 스프롤을 첫날의 실패 모드로 지목했다. 기계적인 방어는 정책 XML을 절대 포털에서 작성하지 않는 것이다. 정책은 저장소에 살고, 템플릿화되며, 다른 모든 것과 같은 파이프라인으로 적용된다:
resource "azurerm_api_management_api_policy" "chat" {
api_name = azurerm_api_management_api.chat.name
api_management_name = azurerm_api_management.gw.name
resource_group_name = azurerm_resource_group.platform.name
xml_content = templatefile("${path.module}/policies/chat.xml", {
tpm_default = var.tpm_default
metric_ns = "llm-metrics"
})
}
템플릿화된 파일은 1부의 토큰 한도와 메트릭 정책이 담긴 평범한 XML이고, 다른 코드와 똑같이 diff로 리뷰된다:
<inbound>
<base />
<llm-token-limit counter-key="@(context.Subscription.Id)"
tokens-per-minute="${tpm_default}"
estimate-prompt-tokens="true"
remaining-tokens-variable-name="remainingTokens" />
<llm-emit-token-metric namespace="${metric_ns}">
<dimension name="Tenant" value="@(context.Subscription.Name)" />
</llm-emit-token-metric>
</inbound>
estimate-prompt-tokens="true"에 주목한다. 한도를 넘는 요청이 백엔드 쿼터를 소모하고 나서야 실패하는 대신 게이트웨이에서 실패하게 만드는 설정이다. 추정의 정확도를 약간 내주는 대신, 정확히 보호하려던 그 토큰을 아낀다.
벽에 적어 둘 가치가 있는 함정 네 가지
- Foundry 리소스를 삭제해도 그 이름은 48시간 동안 풀리지 않는다. soft delete가 리소스를 복구 가능한 상태로 유지하므로,
terraform destroy후 같은 이름으로 apply하면 리소스를 명시적으로 purge하기 전까지 실패한다. 더 나쁜 것은, 프로비저닝된 배포의 과금이 purge 전까지 계속된다는 점이다. 파괴한 환경이 계속 청구될 수 있다. 계정을 지우기 전에 배포를 먼저 지우고, purge는 의도적으로 한다. - PTU는 Terraform에서 살 수 없다. 프로비저닝 SKU(
ProvisionedManaged,GlobalProvisionedManaged,DataZoneProvisionedManaged)는 배포된 PTU 수에 따라 시간 단위로 구매하고, Azure Reservations로 약정 할인을 받는데, 그 구매 단계는 provider가 완결할 수 있는 일이 아니다. 배포는 코드다. 약정은 그 옆에서 일어나는 조달 행위다. - 쿼터는 infrastructure as code가 아니다. 모델 배포의 capacity는 해당 모델과 리전에 대한 구독의 쿼터를 넘을 수 없고, 쿼터는 지원 요청을 통해 도착한다. 제약은 plan 출력이 아니라 쿼터 페이지다.
- 클래식에서 v2로의 업그레이드는 없다. 이 글의 실수 중 apply가 아니라 마이그레이션을 치르게 하는 유일한 실수라서 반복할 가치가 있다.
3부가 물려받는 것
이번 편이 끝난 시점에는 system-assigned 아이덴티티를 가진 Standard v2의 API Management 인스턴스, 누구 발밑에서도 움직이지 않을 고정된 모델 배포를 가진 Foundry 계정, 아직 아무것도 올라가지 않은 AKS 클러스터, 아직 아무것도 수신하지 않는 Log Analytics와 Application Insights, 그리고 독립적으로 apply할 수 있는 state file 세 개가 있다. 아직 API가 없고 어떤 테넌트도 자격 증명이 없으므로, 어떤 애플리케이션도 이것들을 호출할 수 없다. 그것이 다음 두 편이다.
다음으로 읽을 글
- 3부, Provider 추상화와 Streaming, 이 인프라가 API를 얻는 편이다. 모델 alias, priority 기반 라우팅, circuit breaker, 그리고 streaming이 토큰 계량기에 치르게 하는 비용을 다룬다.
- 1부, 중앙 LLM 게이트웨이를 세워야 하는 이유, 이 인프라가 구현하는 시나리오와 구축 대 구매 결정이다.
- How to Call Multiple Terraform Modules in a Single Terragrunt File (ercan.cloud), 환경 수가 늘어날 때 여기서 설명한 플레인 분리 위에 놓이는 레이어를 다룬다.
이런 플랫폼을 대규모로 운영할 때의 인프라와 플랫폼 쪽 이야기는 ercan.cloud에, 허브는 ercanermis.com에 있다.
참고 자료
Ercan의 다른 글
같은 저자, 다른 영역의 사이트 두 개.