1부에 나온 에이전트 4개 중 어느 것이든 로그를 들여다보거나 워크로드 비용을 산정하거나 수정안을 제안하려면, 이 플랫폼에는 먼저 계정 경계와 두 개의 IAM 역할이 있어야 한다. "기본값은 읽기 전용"이라는 원칙을 시스템 프롬프트의 약속이 아니라 AWS가 강제하는 속성으로 만드는 역할이다. 그 경계, 누군가 데모를 계획하기 며칠 전에는 이미 끝나 있어야 하는 모델 접근 신청, 그리고 온디맨드와 프로비저닝드 처리량과 크로스 리전 추론 프로필 사이의 선택. 이 세 가지가 이번 편에서 만들어지며, 에이전트 코드 한 줄이 존재하기 전에 전부 테라폼으로 구축된다.

1부는 배경을 설정했다. AWS 계정 30개와 매주 40건의 페이지를 가진 중견 SaaS 기업이, 기성 제품을 사는 대신 Bedrock AgentCore와 Strands 위에 다중 에이전트 운영 플랫폼을 직접 구축하기로 했고, IAM으로 강제되는 읽기 전용을 타협 불가능한 설계 제약으로 삼았다. 이번 편은 그 제약이 다이어그램에서 벗어나 HCL이 되는 지점이다. 동반 코드는 github.com/flightlesstux/agents-on-callterraform/00-foundation/에 있으며, 아래의 모든 스니펫은 글을 위해 단순화한 것이 아니라 그 디렉터리에서 그대로 발췌했다.

왜 전용 ops-tooling 계정인가

1부의 계정 구조는 전체 에이전트 플랫폼을 하나의 계정에 두고, 이 플랫폼이 다루는 약 30개의 워크로드 계정과는 분리한다.

계정역할
management조직 루트, SCP, 워크로드 없음
security조직 CloudTrail, GuardDuty, Security Hub 위임 관리자
ops-tooling전체 에이전트 플랫폼: Runtime, Gateway, Memory, 승인용 Step Functions 스테이트 머신
workload × 약 30스포크. ops-tooling에 정확히 두 개의 역할만 노출

각 워크로드 계정에 에이전트 인프라를 배포하는 대신 전용 계정 하나를 두면, 공유 계정으로는 얻을 수 없는 세 가지를 얻는다. 첫째, 블라스트 반경이다. ops-tooling에서 잘못 설정된 Lambda나 유출된 자격 증명이 있어도 스포크의 무언가를 직접 건드릴 수 없다. 스포크에 도달하려면 언제나 계정 경계를 넘는 명시적 sts:AssumeRole이 필요하고, 같은 계정 안의 암묵적 권한으로는 되지 않기 때문이다. 둘째, 감사할 곳이 한 군데다. security 계정의 CloudTrail은 30개의 트레일에 흩어진 에이전트 활동을 짜맞추는 대신, 단일 소스 계정에서 나오는 모든 크로스 계정 assume-role 호출을 한눈에 본다. 셋째, 플랫폼 자체의 블라스트 반경이 독립적이다. ops-tooling에 장애가 생기면 다른 모든 곳은 정확히 현재 상태로 저하될 뿐이며, 이는 1부가 요구사항으로 정한 추가적(additive)이되 핵심 경로는 아니라는 속성 그대로다.

모든 스포크가 노출하는 두 개의 역할

모든 워크로드 계정은 ops-tooling에 정확히 두 개의 역할만 노출하고, 그 외에는 아무것도 노출하지 않는다. 둘 다 재사용 가능한 테라폼 모듈 modules/spoke-roles/에 있으며, 스포크 계정마다 한 번씩 적용하도록 되어 있다. 스포크가 몇 개뿐이라면 계정별 프로바이더 별칭으로 충분하지만, 약 30개 계정 규모에서는 대신 계정별 파이프라인으로 돌린다. 예를 들면 계정마다 하나씩 두는 Terraform Cloud 워크스페이스나 Landing Zone Account Factory 커스터마이제이션이다. 이 저장소의 루트 모듈은 예제가 terraform validate로 실행 가능한 상태를 유지하도록 단일 프로바이더에 대해 한 번만 인스턴스화한다.

ops-readonly: 관리형 정책으로 구성하고, 명시적 거부로 상한을 둔다

읽기 도구(cloudwatch-read, logs-read, cost-read)는 모두 이 하나의 역할을 어썸한다. 신뢰 정책은 어썸을 허용할 Lambda 실행 역할 ARN을 정확히 지정하고, 컨퓨즈드 디퓨티 문제에 대한 이차 방어 수단으로 공유 외부 ID(external ID)를 함께 요구한다.

data "aws_iam_policy_document" "ops_readonly_trust" {
  statement {
    sid     = "AssumeFromOpsToolingReadTools"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "AWS"
      identifiers = var.ops_readonly_assumer_role_arns
    }

    condition {
      test     = "StringEquals"
      variable = "sts:ExternalId"
      values   = [var.external_id]
    }
  }
}

resource "aws_iam_role" "ops_readonly" {
  name                 = "ops-readonly"
  assume_role_policy   = data.aws_iam_policy_document.ops_readonly_trust.json
  max_session_duration = 3600
}

권한은 AWS 관리형 정책 두 개에서 나온다. CloudWatchReadOnlyAccess는 CloudWatch 메트릭과 CloudWatch Logs 읽기를 하나의 어태치먼트로 모두 커버하는데, 그래서 cloudwatch-readlogs-read가 두 개의 역할 대신 하나의 IAM 역할을 공유할 수 있다. AWSBillingReadOnlyAccesscost-read에 필요한 Cost Explorer 호출을 커버한다. 이 둘 위에, 첨부된 관리형 정책이 무엇을 허용하든 상관없이 변경 작업의 고정 목록(iam:*, ssm:StartAutomationExecution, ec2:TerminateInstances, s3:PutObject* 등 십여 개)을 차단하는 명시적 거부 문(statement)이 얹혀 있다. 이 마지막 부분은 장식이 아니다. AWS 관리형 정책은 서비스가 기능을 추가함에 따라 시간이 지나면서 새 액션이 늘어나는데, 명시적 거부는 어느 정책이, 혹은 그 정책의 어떤 미래 버전이 만들어낸 허용이든 IAM 평가가 절대 뒤집을 수 없는 유일한 수단이다.

ops-mutate: 호출자 하나, 허용 목록 하나, 관리형 정책은 전혀 없음

신뢰 정책은 한층 더 좁다. 프린시펄이 정확히 하나뿐인데, 사람이 Slack에서 승인을 클릭한 뒤에만 실행되는 승인 후 실행자(post-approval executor) Lambda다.

data "aws_iam_policy_document" "ops_mutate_trust" {
  statement {
    sid     = "AssumeFromPostApprovalExecutorOnly"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "AWS"
      identifiers = [var.ops_mutate_assumer_role_arn]
    }

    condition {
      test     = "StringEquals"
      variable = "sts:ExternalId"
      values   = [var.external_id]
    }
  }
}

더 흥미로운 쪽은 권한 정책이다. ssm:StartAutomationExecution은 특정 SSM Automation 문서 ARN으로 리소스 수준 제한을 지원하므로, 이 역할이 실행할 수 있는 런북의 허용 목록은 버그가 있을 수 있는 애플리케이션 로직이 아니라 IAM이 직접 강제한다.

data "aws_iam_policy_document" "ops_mutate_permissions" {
  statement {
    sid       = "StartOnlyAllowlistedRunbooks"
    effect    = "Allow"
    actions   = ["ssm:StartAutomationExecution"]
    resources = var.allowed_ssm_automation_document_arns
  }

  statement {
    sid    = "InspectAndStopOwnExecutions"
    effect = "Allow"
    actions = [
      "ssm:GetAutomationExecution",
      "ssm:DescribeAutomationExecutions",
      "ssm:StopAutomationExecution",
    ]
    resources = ["*"]
  }
}

두 번째 문이 Resource = "*"인 데는 분명한 이유가 있다. GetAutomationExecution과 다른 조회 호출들은 실행 ID를 대상으로 동작하는데, 그 ID는 StartAutomationExecution이 이미 하나를 반환하기 전까지는 존재하지 않으므로 미리 범위를 지정할 대상 자체가 없다. 이 역할에서 의미 있는 경계는 어떤 문서를 시작할 수 있느냐이지, 이미 실행 중인 자신의 실행을 조회할 수 있느냐가 아니다. 그리고 ops-readonly와 달리 이 역할에는 AWS 관리형 정책 어태치먼트가 전혀 없다. 이 역할이 가진 모든 권한은 그 하나의 정책 문서에 나열되어 있으므로, AWS 쪽에서 관리형 정책을 바꾼다고 해서 이 역할이 할 수 있는 일이 조용히 넓어질 일은 없다.

Bedrock 모델 접근은 콘솔에서 며칠이 걸리는 문제다

Bedrock 파운데이션 모델 접근을 부여하는 테라폼 리소스는 존재하지 않는다. 이 공백은 디버깅 세션을 하나 날리기 전에 미리 짚어둘 가치가 있다. 계정에서 모델을 켜는 것은 수작업 단계다. Bedrock 콘솔의 "Model access" 페이지든, 그에 해당하는 일회성 API 호출이든, AWS가 비동기로 처리하는 EULA 동의 워크플로를 트리거한다. 즉시 처리되지 않고, plan과 apply로 표현할 수 있는 멱등적 인프라도 아니므로 위의 IAM 역할들과 같은 저장소에 둘 수 없다.

실무적으로 뜻하는 바는 이렇다. 첫 terraform apply 이후가 아니라 그 전에, 이 플랫폼이 호출할 모든 모델에 대해, 크로스 리전 추론 프로필이 라우팅할 수 있는 모든 리전에서 접근을 신청해두라는 것이다. 다중 리전 프로필 중 한 리전에 권한 부여가 빠져 있으면 그 리전으로 라우팅되는 호출만 실패하는데, 그러면 마치 간헐적인 문제, 불안정한 에이전트, 일시적인 스로틀링처럼 보인다. 하지만 실제로는 여러 리전 중 하나가 아직 아무도 누르지 않은 콘솔 클릭을 기다리고 있을 뿐이다. 이것은 테스트 도중에 처리할 작업이 아니라 첫날 처리할 작업으로 예산을 잡아야 한다. 승인 워크플로 자체가 테라폼의 통제 밖이고, 이 프로젝트의 통제 밖이기 때문이다.

온디맨드, 프로비저닝드, 크로스 리전: 결정 규칙은 "측정 전까지는 온디맨드"

Bedrock 모델을 호출하는 방법은 세 가지가 있고, 측정할 트래픽이 하나도 없는 상태에서 그중 하나를 고르는 것은 아키텍처 결정으로 포장된 추측일 뿐이다.

방식과금 방식얻는 것
온디맨드입출력 토큰당, 공개된 표준 요율커밋 없음, 0까지 스케일 가능, 트래픽 기준선이 없는 상태에서 유일하게 합리적인 기본값
Provisioned Throughput시간당, Model Unit 기준. 요율은 모델, MU 수, 커밋 기간에 따라 다름(무커밋, 1개월, 6개월, 커밋이 길수록 시간당 비용은 낮아짐)보장된 전용 처리량. 사용 여부와 무관하게 용량에 대해 과금됨
크로스 리전 추론 프로필소스 리전과 동일한 토큰당 요율, 라우팅 추가 요금 없음버스트 여유와 안정성을 위해 해당 지리권의 리전들에 자동 라우팅, 온디맨드에서만 사용 가능

크로스 리전 추론 프로필은 쉬운 선택이다. 온디맨드 대비 추가 비용이 전혀 없으면서 버스트 용량과, 한 리전이 스로틀링될 때의 페일오버 경로까지 얹어주므로 이 플랫폼은 첫날부터 어디서나 이것을 쓴다. Provisioned Throughput은 더 어려운 선택이며, 이 프로젝트가 쓰는 규칙은 측정 전까지는 온디맨드다. Model Unit 하나는 분당 고정된 입출력 토큰 상한을 사는 것이고, 그 시간에 플랫폼이 요청을 단 하나도 보내지 않았더라도 시간당으로 과금된다. 그러니 에이전트 4개에 걸친 실제 요청률 분포를 알기도 전에 커밋하는 것은, 아직 아무도 근거를 댈 수 없는 숫자에 커밋하는 것과 같다. 7부에서 실제 토큰 계산과 provisioned throughput의 시간당 요율이 온디맨드의 토큰당 요율을 이기는 지점을 다룬다. 그 계산이 나오기 전까지 커밋 수준을 추측하는 것은 위험을 "모델이 느리다"에서 "청구서가 잘못됐다"로 옮기는 것일 뿐이다.

여기서 실제로 테라폼이 만드는 것은 크로스 리전 프로필 자체가 아니다. 그 부분은 AWS가 관리하며 리전이 지원하는 순간 이미 존재한다. 테라폼이 만드는 것은 시스템에 정의된 프로필을 감싸는, 에이전트별 애플리케이션 추론 프로필이다.

resource "aws_bedrock_inference_profile" "agent" {
  for_each = var.agents

  name        = "${var.platform_name}-${each.key}"
  description = "Application inference profile for the ${each.key} agent, tagged for per-agent cost allocation."

  model_source {
    copy_from = each.value.cross_region_profile_arn
  }

  tags = merge(var.tags, {
    Agent = each.key
  })
}

Agent 태그가 시스템 프로필을 직접 호출하지 않고 감싸는 이유의 전부다. 이 태그가 없으면 모든 에이전트의 Bedrock 지출이 구분되지 않는 한 줄로 뭉뚱그려지고, 1부의 아키텍처가 요구한 에이전트별 비용 할당 요구사항은 걸 데가 없어진다. 태그가 있으면, 다른 모든 것을 지켜보는 에이전트들을 지켜보기 위한 플랫폼 자체의 Budgets와 Cost Anomaly Detection 설정이 지출을 실제로 그것을 만들어낸 에이전트에 귀속시킬 수 있다.

첫 번째 terraform apply

terraform -chdir=terraform/00-foundation init을 실행한 뒤 plan을 돌리면 줄 수 기준으로는 작은 플랜이다. IAM 역할 두 개, 인라인 정책 두 개, 관리형 정책 어태치먼트 두 개, 그리고 에이전트 수만큼의 애플리케이션 추론 프로필. 하지만 이 시리즈 전체를 통틀어 이후의 모든 것이 신뢰받으려면 반드시 옳아야 하는 유일한 apply이기도 하다. 커밋된 루트 모듈은 spoke_roles를 기본 프로바이더에 대해 한 번만 인스턴스화하므로, 이 예제는 terraform validateplan으로 실행 가능한 상태를 유지하기 위해 단일 계정에 적용된다. 실제 배포에서는 대신 그 모듈에 스포크 범위의 프로바이더 별칭을 넘긴다. 그렇게 하지 않으면 두 역할이 한 계정 안에 나란히 놓이게 되고, 이번 편이 만들려는 크로스 계정 경계 자체가 존재하지 않게 된다. 이후의 모든 편은 ops-readonlyops-mutate가 정확히 이 형태로 이미 존재한다고 가정한다. 여기서 신뢰 정책의 프린시펄 목록을 잘못 적으면, 4부에서 도구 Lambda의 AssumeRole 호출이 실패하는데, 이는 3주 앞선 2부의 오타가 아니라 Gateway 설정 오류처럼 보이는 방식으로 나타난다.

처음 겪을 때 몇 분을 잡아먹은 구현 세부사항 하나. aws_iam_role.max_session_duration에는 3600초라는 확고한 하한선이 있다. ops-mutate는 이미 블라스트 반경이 좁게 잡혀 있으니 가능한 한 짧은 세션을 요구하고 싶어지지만, IAM은 역할 수준의 상한을 한 시간 미만으로 두는 것을 허용하지 않는다. 승인 후 실행자가 런타임에 자신의 STS 호출로 실제 요청하는 자격 증명 수명은 여전히 그 상한보다 훨씬 짧게, 그리고 그래야 마땅하게 설정할 수 있다. 역할 설정은 최댓값만 제한할 뿐 기본값을 정하지는 않는다.

State와 백엔드 위생

이번 편은 의도적으로 원격 백엔드를 건드리지 않는다. 이 절에서 말하려는 요점이 어떤 백엔드를 고르느냐보다 더 중요하기 때문이다. 두 번째 사람이든, 두 번째 CI 실행이든 같은 계정에 적용하기 전에 state는 잠금과 암호화가 있는 어딘가에 있어야 하고, 에이전트나 도구 Lambda가 닿을 수 있는 범위 밖에 있어야 한다. 버전 관리가 켜진 S3 버킷과 DynamoDB 잠금 테이블이든, Terraform Cloud든, 둘 다 방법이 된다. 중요한 것은 ops-readonlyops-mutate에 그 state를 읽거나 쓸 권한이 없다는 것, 그리고 이 저장소에 대해 terraform apply를 실행하는 사람이 이 저장소가 만드는 플랫폼과는 별개의, 감사되는 경로를 거친다는 것이다. 콘솔 드리프트는 이 규율의 나머지 절반이다. 콘솔을 클릭해서 만든 프로덕션 변경은 state 파일을 실제 상태와 어긋나게 만드는데, 그 어긋남은 즉각적인 오류가 아니라 나중에 헷갈리는 plan으로 드러난다. 이것이 바로 ercan.cloud의 IaC-First: Why We Never Touch the AWS Console in Production에서 더 자세히 다루는, IaC를 프로덕션으로 가는 유일한 경로로 취급해야 한다는 주장 그 자체다.

이번 편에서 특히 지켜봐야 할 실패 모드

두 편 뒤에 헷갈리는 증상으로 나타나기 전에 미리 알아둘 가치가 있는 것이 세 가지 있다. 크로스 리전 프로필 중 한 리전에서 모델 권한 부여가 빠져 있으면 그 리전으로 라우팅된 요청만 실패하므로, 누군가 코드 대신 콘솔의 접근 페이지를 확인해볼 생각을 할 때까지는 깔끔한 오류가 아니라 간헐적인 불안정 증상으로 나타난다. ops_readonly_assumer_role_arnsops_mutate_assumer_role_arn에 오타가 있으면 AssumeRole이 액세스 거부로 실패하는데, 신뢰 정책 쪽이 잘못됐는지 호출자 쪽이 잘못됐는지 아무런 단서도 주지 않는다. 그러니 그 Lambda 역할 ARN들은 만들어진 곳에서 테라폼 출력값으로 유지하고 참조하되, ARN을 손으로 다시 입력하는 일은 절대 하지 말아야 한다. 그리고 allowed_ssm_automation_document_arns 목록이 비어 있어도 테라폼 상으로는 유효하다. 역할은 깔끔하게 적용되고 깔끔하게 어썸도 되지만, 그 뒤로는 실제로 아무것도 시작할 수 없다. plan 시점의 검증 블록이 이 특정 사례를 잡아내긴 하지만, 그것도 누군가 추가할 생각을 했을 때만 그런 것이지 테라폼이 스스로 잡아내는 것은 아니다.

다음으로 읽을 글

위 스니펫에서 잘라낸 부분을 포함한 전체 terraform/00-foundation/ 모듈은 동반 저장소인 github.com/flightlesstux/agents-on-call에 있다. 이런 플랫폼을 대규모로 운영할 때의 데이터베이스와 인프라 쪽 이야기는 ercan.cloud에, 허브는 ercanermis.com에 있다.