0. Contents


1. Overview

고객 AWS 계정서울 리전 스택CFN스택 (v2)[스택 생성] 한 번IAMKukieAccess07편의 출입증CRKukieNotice사용자 지정 리소스= 엽서 한 장AWS 관리 · 사용자 브라우저S3응답 주소CloudFormation 버킷회신이 와야 스택이 끝남WEBKukie 웹3초마다 연결 상태 질문→ 연결됨 · EKS 목록Kukie 계정SNS알림 주제누구나 발행 가능SQS보관함재시작에도 보관APIKukie 서버① 응답 주소 검사② ExternalId → 팀③ 역할을 맡아 봄④ 계정 저장⑤ 회신12345SUCCESS / FAILED 는 ④ 로 S3 에 PUT웹은 ⑤ 로 연결 상태를 묻는다고객이 하는 일버튼 한 번계정 번호를 적지 않는다
① 엽서 발송 ② 보관함에 쌓임 ③ 서버가 꺼내 확인 ④ CloudFormation 에 회신 ⑤ 웹이 연결 상태를 받아 다음 단계로

07편까지의 연결 방식

  • 07편에서 고객은 스택 하나로 KukieAccess-<ExternalId> 역할을 만들었다
  • Kukie 는 그 역할을 15분씩 빌려 EKS 에 들어갔다
  • 문제: 역할 ARN 을 만들려면 고객의 계정 번호가 필요했고, 그 번호는 고객이 직접 적었다
    • 스택을 만든 뒤 콘솔 오른쪽 위에서 12자리를 찾아 Kukie 화면에 옮겨 적는 단계였다

이번 편: 계정 번호 입력 없애기

  • 스택이 만들어질 때 CloudFormation 이 Kukie 에 “역할을 만들었고 주소는 이것” 이라는 알림을 보낸다
  • 이 글에서는 그 알림을 엽서라고 부른다
  • 엽서는 누구나 보낼 수 있다 → 서버는 엽서를 그대로 믿지 않는다
    • 적힌 역할을 실제로 맡아 본 뒤에만 계정을 저장한다

장별 내용

  • 2장: 바뀐 사용자 단계
  • 3장: 엽서의 구성
  • 4장: 엽서를 받는 SNS 와 SQS
  • 5장: 서버의 판단 순서
  • 6장: 재시도와 시간 제한
  • 7장: 우체통을 열어 둔 데 따른 위험
  • 8장: 실제 스택으로 돌린 시험

2. Before and After — 사람이 하던 한 단계

v1계정 번호를 사람이 옮겨 적음스택생성사람콘솔에서번호 찾기사람12자리입력사람[불러오기]사람EKS목록Kukiev2스택이 알려 줌스택생성사람엽서 →역할 확인Kukie계정저장KukieEKS목록Kukie
주황은 사람이, 초록은 Kukie 가 하는 일. v1 스택이나 10분 넘게 소식이 없을 때를 위해 직접 입력은 남겼다

사람이 하는 단계: 넷에서 하나로

  • v1: 사람이 하던 일은 넷이었다 — 스택 생성, 계정 번호 찾기, 입력, 불러오기
  • v2: 스택 생성 하나만 남는다
    • 나머지는 엽서가 도착하면 서버가 처리한다
    • 웹은 3초마다 연결 상태를 묻고, 계정이 생기면 바로 EKS 목록을 받는다

남겨 둔 직접 입력

  • 직접 입력은 지우지 않았다
    • 이미 v1 스택을 만든 고객이 있다
    • 엽서가 어떤 이유로 오지 않을 수도 있다
  • 웹은 10분 동안 기다린 뒤 묻기를 멈추고 “스택 상태를 확인하거나 계정 번호를 직접 넣어 달라” 고 안내한다
  • 다시 물어도 달라지지 않는 응답에는 바로 멈춘다 — 권한 오류(403), 연결 정보 없음(404)

3. Custom Resource — 스택이 보내는 엽서

SNS서울본문 · ResourcePropertiesExternalId"kukie-xxxxxxxx"팀 찾기 — 힌트RoleArn"…:1111:role/KukieAccess-…"계정 후보 — 맡아 보고 확인RequestType"Create" · "Update" · "Delete"처리 갈래주소란ResponseURL"https://cloudformation-…"CloudFormation S3 일 때만 회신StackId · RequestId"…:1111:stack/kukie-access-…"회신에 그대로LogicalResourceId"KukieNotice"회신에 그대로힌트역할을 맡아 봐야 인정주소검사 통과 시에만 PUT되돌림확인 없이 회신에 실음
누구나 이 모양의 엽서를 만들어 보낼 수 있다 — 그래서 본문은 증거가 아니라 확인할 단서다

사용자 지정 리소스(custom resource)

  • CloudFormation 스택이 만들어지거나 바뀌거나 지워질 때 지정한 곳에 요청을 보내고, 결과 회신을 기다리는 리소스다
  • 받는 곳(ServiceToken)은 Lambda 함수나 SNS 주제다
  • 템플릿 v2 는 v1 에 이 리소스 하나만 더했다
KukieNotice:
  Type: Custom::KukieConnection
  Properties:
    ServiceToken: "arn:aws:sns:ap-northeast-2:<Kukie 계정>:kukie-access-notice"
    ExternalId:
      Ref: ExternalId
    RoleArn:
      Fn::GetAtt: [KukieAccessRole, Arn]

생성·삭제 순서와 남는 자원

  • RoleArn 이 역할을 가리킨다 → CloudFormation 은 역할을 먼저 만들고 엽서를 보낸다
  • 지울 때는 반대로 엽서 쪽(삭제 알림)이 먼저다
  • 고객 계정에 새로 생기는 실물은 여전히 역할 하나뿐이다
    • 엽서는 알림일 뿐 고객 계정에 남는 자원이 없다

v2 를 만들며 지킨 두 가지

  • v1 은 고치지 않았다(07편 4장의 write-once)
  • v2 의 역할·권한·파라미터가 v1 과 같다는 것을 테스트로 고정했다
    • 엽서를 더하는 김에 권한이 따라 바뀌면, 고객이 검토하지 않은 권한이 새 버전에 섞이기 때문이다
assert {
  condition     = var.template_v2.Resources.KukieAccessRole == var.template.Resources.KukieAccessRole && var.template_v2.Parameters == var.template.Parameters
  error_message = "v2의 고객 역할·권한·파라미터는 v1과 같아야 한다."
}

제약: 스택과 같은 리전의 SNS 주제

  • ServiceToken 의 SNS 주제는 스택과 같은 리전에 있어야 한다
  • 주제를 서울에 두었다 → 스택도 서울에서 만들어야 한다
    • Kukie 가 주는 빠른 생성 링크가 서울 리전 콘솔을 연다
  • 연결할 EKS 의 리전과는 상관없다

4. Mailbox — SNS 와 SQS

엽서의 경로

  • 엽서는 Kukie 계정의 SNS 주제로 온다
  • 주제는 그것을 SQS 보관함에 넣는다
  • 서버는 보관함을 읽는다
구성설정이유
주제 kukie-access-notice발행(sns:Publish)만, 누구에게나 (Principal AWS "*")고객 계정을 미리 알 수 없다. 발행 외(구독·관리)는 열지 않는다
보관함 kukie-access-notice그 주제만 넣을 수 있음 (aws:SourceArn) · 원문 그대로 전달 · SSE-SQS다른 경로로 들어온 메시지가 없게
보관 시간1시간CloudFormation 이 회신을 기다리는 시간과 같다
숨김 시간 · 수신 한도60초 · 5번, 넘으면 -dead 큐(14일)6장
읽는 쪽kukie-dev-member 역할만 받기·지우기권한 경계와 inline 정책 양쪽에 적었다

HTTPS 구독 대신 보관함

  • 주제에서 서버로 바로 보내는(HTTPS 구독) 대신 보관함을 두었다
  • 이유: 서버가 재시작하거나 잠깐 내려가 있어도 엽서를 잃지 않기 위해서다
  • 서버는 보관함을 20초씩 길게 기다려 읽는다(long polling)
  • 처리를 끝낸 엽서만 지운다 → 지우지 않은 엽서는 숨김 시간이 지나면 다시 나타난다

다른 방법: 고객 계정의 Lambda

  • 고객 계정에 Lambda 를 두고 그 Lambda 가 Kukie API 를 부르게 하는 방법도 있었다
  • 그러면 고객 계정에 함수와 실행 역할, 로그 그룹이 더 생긴다
  • 고객은 그 함수가 무엇을 하는지까지 검토해야 한다
  • SNS 주제를 쓰면 고객 쪽에 새로 생기는 것이 없다

보관함 읽기의 시작 조건

  • 서버의 보관함 읽기는 보관함 주소 환경 변수(CLUSTER_AWS_NOTICE_QUEUE_URL)가 있을 때만 시작한다
  • 로컬과 테스트에서는 돌지 않는다
  • 빈 값으로 넘어와도 시작하지 않는다

5. Verification — 엽서를 믿지 않는 서버

1응답 주소가 CloudFormation S3?아니오버림회신 없음 · 가짜 주소를 부르지 않게예2요청이 Delete?예SUCCESS연결 정보는 그대로아니오3ExternalId 로 팀을 찾았나?아니오FAILEDKukie 가 발급하지 않은 값예4역할 이름이 KukieAccess-<그 값>?아니오FAILED역할 이름이 ExternalId 와 다름예5그 역할을 맡을 수 있나?아니오다시 받기다섯 번째 시도면 FAILED계정 저장 · SUCCESS 회신웹이 묻는 연결 상태에 계정 번호가 생긴다PhysicalResourceId = kukie-connection-…
③ ④ ⑤ 는 엽서의 값이 아니라 서버가 가진 것(DB 의 ExternalId, 실제 AssumeRole)으로 판단한다

판단 다섯 단계

  • 판단은 다섯 단계다
  • 그중 셋은 엽서에 적힌 값이 아니라 서버가 가진 것으로 판단한다
    • DB 의 ExternalId
    • 실제 AssumeRole 결과

첫 단계: 회신 주소 검사

  • 회신은 엽서에 적힌 ResponseURL 로 PUT 하는 것이다
  • 주소를 그대로 따르면 가짜 엽서 하나로 서버가 내부망이나 메타데이터 주소를 부르게 만들 수 있다(SSRF)
  • 그래서 다음을 모두 만족할 때만 회신한다
    • https
    • 기본 포트
    • 사용자 정보 없음
    • CloudFormation 응답 버킷의 이름 꼴
  • 리다이렉트도 따라가지 않는다
private val HOST = Regex("cloudformation-custom-resource-response-[a-z0-9]+\\.s3([.-][a-z0-9-]+)?\\.amazonaws\\.com")

fun matches(url: String): Boolean {
    val uri = runCatching { URI(url) }.getOrNull() ?: return false
    return uri.scheme == "https" && uri.port == -1 && uri.rawUserInfo == null && HOST.matches(uri.host.orEmpty())
}

회신 헤더: Content-Type 없이

  • 응답 주소는 S3 의 미리 서명된 URL 이고, 서명에 Content-Type 이 들어 있지 않다
  • 서버의 PUT 에 그 헤더를 붙이면 서명이 맞지 않아 거절된다
  • 그래서 헤더 없이 JSON 본문만 보낸다

삭제 알림 처리

  • 삭제 알림(Delete)에는 성공만 회신하고 연결 정보는 건드리지 않는다
  • 스택을 지우는 중에도 역할은 아직 있다 → 역할을 맡아 봐도 진짜 삭제와 가짜 삭제를 가릴 수 없다
  • 가짜 삭제로 연결을 끊게 두는 대신, 역할이 실제로 사라지면 다음 AssumeRole 이 실패하게 둔다

역할 이름 확인과 AssumeRole

  • 역할 ARN 은 형식만 보는 것이 아니라 이름이 정확히 KukieAccess-<그 팀의 ExternalId> 인지 본다
  • 그 뒤에 계정 번호를 꺼내 그 팀의 ExternalId 로 역할을 맡아 본다
  • 성공하면 계정을 저장하고 SUCCESS 를 회신한다
    • 웹이 묻는 연결 상태에 계정 번호가 생기는 것이 이 순간이다
private fun accountOf(roleArn: String?, externalId: String): String? {
    val match = roleArn?.let(ROLE_ARN::matchEntire) ?: return null
    val (accountId, roleName) = match.destructured
    return accountId.takeIf { roleName == clusterAwsProperties.roleNamePrefix + externalId }
}

6. Retries — IAM 전파와 1시간

0분1분2분3분4분5분경우 A전파가 늦음1번째못 맡음2번째못 맡음3번째SUCCESS경우 B끝내 못 맡음1번째다시2번째다시3번째다시4번째다시5번째FAILED← 못 맡으면 지우지 않는다 → 60초 뒤 다시 나타남스택CloudFormation 은 회신을 최대 1시간 기다린다→ 그 안에 SUCCESS 나 FAILED 를 보내야 고객 스택이 멈춰 있지 않는다보관함보관 1시간 · 숨김 60초 · 5번 받으면 -dead 큐(14일)서버는 ApproximateReceiveCount 가 5 면 마지막 시도로 본다
숨김 60초 × 5번 = 약 5분. 방금 만든 IAM 역할이 퍼지는 데 걸리는 수십 초를 넉넉히 덮는다

IAM 전파 지연

  • 엽서는 역할이 만들어진 직후에 온다
  • IAM 은 변경이 퍼지는 데 시간이 걸린다 → 방금 만든 역할을 몇 초 동안 맡지 못할 수 있다
  • 이때 서버는 실패를 회신하지 않고 엽서를 지우지 않는다
  • 보관함은 60초 뒤 같은 엽서를 다시 준다

한계 두 개와 마지막 시도

  • 보관함: 다섯 번 받아 간 엽서를 -dead 큐로 옮긴다
  • CloudFormation: 회신을 1시간까지 기다리고, 그 안에 오지 않으면 스택을 실패로 끝낸다
  • 그래서 서버는 SQS 가 붙여 주는 수신 횟수(ApproximateReceiveCount)가 5 인 엽서를 마지막 시도로 본다
    • 그때도 역할을 맡지 못하면 FAILED 를 회신한다
  • 신뢰 정책이 잘못된 역할처럼 끝내 맡을 수 없는 경우에도 고객은 1시간이 아니라 5분 안에 실패를 본다

회신 문구

  • 고객이 AWS 콘솔의 스택 이벤트에서 읽는다
  • 무엇이 문제인지와 무엇을 하면 되는지를 함께 적었다
    • 예: “Kukie could not assume the KukieAccess role. Recreate the stack from the Kukie link without editing the template.”
  • 영어인 이유는 8장에 있다

7. Threats — 열린 우체통

누가 · 무엇을서버가 보는 것결과가짜 연결 엽서남의 ExternalId그 역할을 그 ExternalId 로실제로 맡아 봄막힘연결 안 됨가짜 응답 주소내부망 · 메타데이터https · 기본 포트 · CFN응답 버킷 이름일 때만막힘회신 없이 버림가짜 삭제 엽서RequestType = Delete지우는 중에도 역할은 있음진짜와 가릴 수 없음무해SUCCESS 만 · 상태 그대로엽서 폭주익명 발행 대량누구나 발행 (Principal *)보관 1시간 · 5번 뒤 -dead!남은 위험진짜 엽서가 밀릴 수 있음
주제를 아무나 쓸 수 있게 연 대가는 '연결'이 아니라 '지연'에 있다. 연결은 언제나 실제 AssumeRole 이 결정한다

가장 큰 결정: 누구에게나 연 주제

  • 주제를 누구에게나 연 것은 이 설계에서 가장 큰 결정이다
  • 리뷰에서 처음 쓴 설명은 “모든 계정의 발행만 허용” 이었다
  • 그런데 Principal AWS "*" 는 계정이 있는 주체만이 아니라 로그인하지 않은 호출까지 포함한다
  • 문서와 테스트 메시지를 “익명 포함 누구나” 로 고쳤다
  • CloudFormation 이 어떤 주체로 발행하는지는 다른 계정에서 만든 실제 엽서의 CloudTrail 을 보고 확인한 뒤, 가능하면 좁히기로 했다

막히는 것과 남는 것

  • 가짜 연결 엽서
    • 남의 ExternalId 를 적어 보내도 서버는 그 역할을 그 ExternalId 로 맡아 본다
    • 역할이 없거나 신뢰 조건이 맞지 않으면 연결은 생기지 않는다
  • 가짜 응답 주소
    • 호스트 검사에서 회신 없이 버린다
  • 가짜 삭제 엽서
    • 성공만 회신하고 상태는 바꾸지 않는다
  • 엽서 폭주 — 막지 못한다
    • 가짜 엽서가 보관함을 채운다 → 진짜 엽서가 1시간 안에 처리되지 못하거나 -dead 로 밀린다
    • 고객 스택이 1시간 뒤 실패할 수 있다
    • 다시 만들면 되지만 남은 위험으로 적어 두었다

연결이 생기는 유일한 조건

  • 처음부터 끝까지 실제 AssumeRole 성공 하나다
  • 우체통을 연 대가는 “남이 연결된다” 가 아니라 “진짜 엽서가 늦어질 수 있다” 에 있다

8. Test Run — 실제 스택으로

+0초+2초+4초+6초+8초+10초+12초UPDATE 시작v1 → v2엽서 발송CREATE_IN_PROGRESSFAILED 회신CREATE_FAILED되돌리기ROLLBACK 시작삭제 엽서DELETE_IN_PROGRESSSUCCESS 회신DELETE_COMPLETEv1 으로 복귀ROLLBACK_COMPLETE2초2초발견 — 한국어 회신 사유가 콘솔에서 ?? 로 보였다"… Message returned: Kukie ?? ??? AWS ??? …"고객이 AWS 콘솔에서 읽는 문구라 서버의 실패 사유를 영어로 바꿨다
Kukie DB 에 없는 ExternalId 를 쓴 옛 시험 스택으로 실패 경로를 먼저 돌렸다 — 엽서부터 삭제 회신까지 11초

실패 시험: 옛 시험 스택

  • 배포 직후 우리 계정에 남아 있던 옛 시험 스택을 v2 로 업데이트했다
  • 이 스택의 ExternalId 는 서버가 발급한 값이 아니라 Kukie DB 에 없다 → 실패해야 맞는 시험이었다
  • 결과
    • 업데이트를 시작하고 4초 뒤 엽서가 나갔다
    • 2초 만에 FAILED 가 돌아와 스택이 v1 으로 되돌아갔다
    • 되돌리며 보낸 삭제 엽서에도 2초 안에 SUCCESS 가 왔다
    • 전체가 11초 만에 끝났다

예상 밖: 물음표가 된 한국어

  • 서버가 보낸 한국어 실패 사유가 스택 이벤트에 Kukie ?? ??? … 로 보였다
  • 서버는 UTF-8 로 보냈다
  • 어느 단계에서 바뀌는지는 따로 확인하지 않았지만, 콘솔에는 ASCII 밖의 글자가 모두 물음표로 보였다
  • 이 문구는 고객이 AWS 콘솔에서 읽는 것이다 → 서버의 실패 사유를 영어로 바꿨다

성공 경로: 웹에서 확인

  • 팀 설정에서 연결을 시작해 새 스택을 만들었다
  • 계정 번호를 적는 칸 없이 화면이 “AWS 계정 … 와 연결됐어요” 로 넘어갔다
  • EKS 후보 목록이 떴다

9. Discussion

남은 위험

  • 엽서 폭주 (7장)
    • 주제 발행을 좁힐 수 있는지는 다른 계정의 실제 엽서로 확인할 일로 남았다
  • 동시 처리
    • 같은 팀이 짧은 사이에 스택을 여러 번 만들면 엽서도 여러 장 온다
    • 모두 같은 ExternalId 라 마지막에 처리된 계정이 남는다
    • 한 팀이 계정 여러 개를 연결하는 경우는 아직 다루지 않는다
  • 삭제 알림을 쓰지 않음
    • 스택을 지워도 Kukie 의 연결 정보는 남는다
    • 다음 호출의 AWS_ROLE_NOT_ASSUMABLE 로 드러난다

핵심 메시지

  • 사람이 옮겨 적던 값
    • 그 값을 가진 쪽이 직접 알려 주게 바꿀 수 있다
    • CloudFormation 사용자 지정 리소스는 고객 계정에 아무것도 더하지 않고 그 통로가 된다
  • 누구나 보낼 수 있는 알림
    • 힌트로만 쓴다
    • 판단은 서버가 가진 값(DB 의 ExternalId)과 실제 동작(AssumeRole)으로 한다
  • 기다리는 쪽의 기준
    • 회신 주소, 재시도 한도, 회신 문구까지 “기다리는 쪽(CloudFormation)” 의 시간과 화면을 기준으로 정한다

10. References

사용자 지정 리소스의 동작과 요청·응답 형식. ServiceToken 이 같은 리전이어야 하는 제약, 회신을 기다리는 시간. 3장과 6장의 근거다.
→ AWS CloudFormation — Custom resources
SNS 를 ServiceToken 으로 쓰는 사용자 지정 리소스. 3장의 엽서가 이 방식이다.
→ AWS CloudFormation — Amazon SNS-backed custom resources
회신 본문(Status · Reason · PhysicalResourceId · StackId · RequestId · LogicalResourceId)과 미리 서명된 응답 URL.
→ AWS CloudFormation — Custom resource response objects
SNS 주제를 SQS 보관함이 구독하는 방법과 원문 전달(raw message delivery).
→ Amazon SNS — Fanout to Amazon SQS queues
수신 한도를 넘긴 메시지를 옮기는 dead-letter 큐와 redrive 정책.
→ Amazon SQS — Dead-letter queues
숨김 시간(visibility timeout)과 지우지 않은 메시지가 다시 나타나는 동작. 6장의 재시도가 이것을 쓴다.
→ Amazon SQS — Visibility timeout
Principal 의 "*" 가 익명 사용자까지 포함한다는 설명. 7장에서 문서를 고친 근거다.
→ AWS IAM — AWS JSON policy elements: Principal