0. Contents
1. Overview
① 엽서 발송 ② 보관함에 쌓임 ③ 서버가 꺼내 확인 ④ 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 — 사람이 하던 한 단계
주황은 사람이, 초록은 Kukie 가 하는 일. v1 스택이나 10분 넘게 소식이 없을 때를 위해 직접 입력은 남겼다
사람이 하는 단계: 넷에서 하나로
- v1: 사람이 하던 일은 넷이었다 — 스택 생성, 계정 번호 찾기, 입력, 불러오기
- v2: 스택 생성 하나만 남는다
- 나머지는 엽서가 도착하면 서버가 처리한다
- 웹은 3초마다 연결 상태를 묻고, 계정이 생기면 바로 EKS 목록을 받는다
남겨 둔 직접 입력
- 직접 입력은 지우지 않았다
- 이미 v1 스택을 만든 고객이 있다
- 엽서가 어떤 이유로 오지 않을 수도 있다
- 웹은 10분 동안 기다린 뒤 묻기를 멈추고 “스택 상태를 확인하거나 계정 번호를 직접 넣어 달라” 고 안내한다
- 다시 물어도 달라지지 않는 응답에는 바로 멈춘다 — 권한 오류(403), 연결 정보 없음(404)
3. Custom Resource — 스택이 보내는 엽서
누구나 이 모양의 엽서를 만들어 보낼 수 있다 — 그래서 본문은 증거가 아니라 확인할 단서다
사용자 지정 리소스(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 — 엽서를 믿지 않는 서버
③ ④ ⑤ 는 엽서의 값이 아니라 서버가 가진 것(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시간
숨김 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 — 열린 우체통
주제를 아무나 쓸 수 있게 연 대가는 '연결'이 아니라 '지연'에 있다. 연결은 언제나 실제 AssumeRole 이 결정한다
가장 큰 결정: 누구에게나 연 주제
- 주제를 누구에게나 연 것은 이 설계에서 가장 큰 결정이다
- 리뷰에서 처음 쓴 설명은 “모든 계정의 발행만 허용” 이었다
- 그런데
Principal AWS "*"는 계정이 있는 주체만이 아니라 로그인하지 않은 호출까지 포함한다 - 문서와 테스트 메시지를 “익명 포함 누구나” 로 고쳤다
- CloudFormation 이 어떤 주체로 발행하는지는 다른 계정에서 만든 실제 엽서의 CloudTrail 을 보고 확인한 뒤, 가능하면 좁히기로 했다
막히는 것과 남는 것
- 가짜 연결 엽서
- 남의 ExternalId 를 적어 보내도 서버는 그 역할을 그 ExternalId 로 맡아 본다
- 역할이 없거나 신뢰 조건이 맞지 않으면 연결은 생기지 않는다
- 가짜 응답 주소
- 호스트 검사에서 회신 없이 버린다
- 가짜 삭제 엽서
- 성공만 회신하고 상태는 바꾸지 않는다
- 엽서 폭주 — 막지 못한다
- 가짜 엽서가 보관함을 채운다 → 진짜 엽서가 1시간 안에 처리되지 못하거나
-dead로 밀린다 - 고객 스택이 1시간 뒤 실패할 수 있다
- 다시 만들면 되지만 남은 위험으로 적어 두었다
- 가짜 엽서가 보관함을 채운다 → 진짜 엽서가 1시간 안에 처리되지 못하거나
연결이 생기는 유일한 조건
- 처음부터 끝까지 실제
AssumeRole성공 하나다 - 우체통을 연 대가는 “남이 연결된다” 가 아니라 “진짜 엽서가 늦어질 수 있다” 에 있다
8. Test Run — 실제 스택으로
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장의 근거다.
SNS 를 ServiceToken 으로 쓰는 사용자 지정 리소스. 3장의 엽서가 이 방식이다.
회신 본문(Status · Reason · PhysicalResourceId · StackId · RequestId · LogicalResourceId)과 미리 서명된 응답 URL.
SNS 주제를 SQS 보관함이 구독하는 방법과 원문 전달(raw message delivery).
수신 한도를 넘긴 메시지를 옮기는 dead-letter 큐와 redrive 정책.
숨김 시간(visibility timeout)과 지우지 않은 메시지가 다시 나타나는 동작. 6장의 재시도가 이것을 쓴다.
Principal 의 "*" 가 익명 사용자까지 포함한다는 설명. 7장에서 문서를 고친 근거다.
