🔥 실행 역할과 최소 권한: Lambda의 IAM

1676자
21분

Lambda 함수 아이콘과 IAM Role 배지를 함께 놓은 표지 이미지. 가운데 함수 캡슐 왼쪽에 IAM Role 방패와 AssumeRole 화살표가 있고, 오른쪽으로 S3, DynamoDB, CloudWatch Logs 아이콘이 부채꼴로 놓여 있다. 주황 배경 위에 권한 배지를 단순하게 배치한 표지 스타일이다.

Lambda란 무엇인가: 서버리스의 경계에서 관리형이 감추는 부담을 네 갈래로 적어 두었다. IAM Execution Role, 콜드스타트, VPC connectivity, 관측. 그날 그 네 줄을 적으면서 잠깐 멈춘 곳이 첫째 갈래다. Lambda는 코드만 올리면 된다는 말이 한 번도 진짜였던 적이 없는데, IAM이 그 거짓을 가장 일찍 드러낸다.

지금부터 그 갈래를 펼친다. Lambda 함수를 처음 만들었을 때 콘솔이 알아서 만들어 주는 그 Execution Role이 진짜로 무엇이고, 거기서부터 최소 권한으로 좁혀가는 길이 어떻게 생겼는지. 그리고 반대 방향, 즉 누가 이 함수를 호출할 수 있는가를 정하는 함수 정책(Resource-based Policy)까지.

Lambda 함수가 일을 하려면 누구의 자격증명이 필요한가

Lambda 서비스가 함수에 Execution Role을 넘기는 흐름 도식. 왼쪽 Lambda 서비스가 IAM Role을 AssumeRole 한 뒤 임시 자격증명을 함수 실행 환경에 환경 변수 세 개로 주입하고, 함수가 그 자격으로 S3, DynamoDB, CloudWatch Logs를 호출한다.

함수 코드 한 줄이 s3.getObject(...)를 호출한다. 그 한 줄을 받은 AWS SDK는 어딘가에서 임시 자격증명을 가져와 요청에 서명한다. 누구의 자격증명일까.

내 IAM User의 long-term key는 Lambda 컨테이너 안에 없다. 있어도 안 된다. Lambda는 수천 함수를 한 계정에 띄우는 도구이고, 그 모두에 사람 자격증명을 두는 모델은 곧장 운영 한계에 부딪힌다. 그래서 Lambda는 함수마다 다른 IAM Role을 매단다. 콘솔에서 함수를 처음 만들면 보이는 Execution Role 칸이 그 역할을 가리킨다.

Execution Role은 Trust Policy의 Principal에 lambda.amazonaws.com을 둔 IAM Role이다. Lambda 서비스 자신이 함수 호출 시점에 이 Role을 AssumeRole 한다. AssumeRole이 정확히 어떤 호출이고 임시 자격증명이 어떻게 만들어지는지는 Assume Role: 임시 자격증명이 만들어지는 순간에 정리해 두었다. 여기서는 Lambda가 그 메커니즘을 어떻게 쓰는지의 관점에서 다시 짚는다.

쟁점은 Role이 있느냐가 아니라 그 Role에 무엇을 허용했느냐다. 함수 안에서 boto3.client("s3")처럼 클라이언트만 만들면 그냥 동작한다. Access Key를 어디 적은 적이 없다. 답은 환경 변수다. Lambda 실행 환경 안에 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN 세 가지가 자동으로 주입돼 있다. SDK는 그 환경 변수를 default credential provider chain에서 읽는다. 이 자격증명은 임시 토큰이라 일정 시간 뒤에는 만료되지만, Lambda 실행 환경이 살아 있는 동안 AWS가 새 값을 환경 변수에 다시 채워 두므로 SDK가 끊김 없이 호출을 이어 간다.

Execution Role은 함수가 AWS 안에서 어떤 권한으로 움직이는지 정하는 IAM Role이다. 다른 서비스(S3, DynamoDB, Logs, KMS 등)에 가서 일할 때 이 Role의 권한이 게이트가 된다. 호출하러 나가는 방향의 권한이라는 말이 더 정확하겠다.

AWSLambdaBasicExecutionRole부터 붙이는 이유

콘솔이 함수를 만들면서 자동으로 attach해 주는 정책이 arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole이다. 안을 열어 보면 권한이 거의 두 갈래뿐이다.

  • logs:CreateLogGroup
  • logs:CreateLogStream + logs:PutLogEvents

CloudWatch Logs에 로그를 쓰는 권한 두 줄이 사실상 전부다. 왜 이 정도가 default냐면, Lambda가 함수 호출 로그를 자동으로 CloudWatch에 보내는데, 그 보내는 주체가 함수의 실행 환경 자신이기 때문이다. Logs 권한이 없으면 함수는 동작하지만 로그가 한 줄도 남지 않는다. 디버깅이 안 되는 함수는 production에 못 올라간다. 그래서 이 두 권한이 default로 미리 들어가 있다.

여기서부터 최소 권한 작업이 출발한다. 함수가 S3 버킷 하나에서 객체 하나를 읽어 DynamoDB 테이블 하나에 쓴다고 치자. 추가로 필요한 권한은 정확히 두 줄이다.

  • s3:GetObject on arn:aws:s3:::my-uploads/incoming/*
  • dynamodb:PutItem on arn:aws:dynamodb:ap-northeast-2:123456789012:table/Orders

처음 이 함수를 만들 때 내가 자주 빠진 함정은 "일단 작동시키자" 충동이었다. 콘솔에서 AmazonS3FullAccess를 attach하면 5초 만에 동작한다. 다음 주 보안 리뷰에서 그 함수 하나가 전체 계정의 모든 S3 버킷에 모든 작업을 할 수 있는 상태가 됐다는 그래프가 뜬다. 그제야 좁힌다. 처음부터 좁혀 두는 게 항상 더 빠르다.

좁혀가는 세 단계

관리형 정책과 인라인 정책 범위를 비교한 도식. 왼쪽부터 AWSLambdaBasicExecutionRole과 도메인 관리형 정책을 함께 쓰는 단계, 함수가 직접 호출하는 action만 customer-managed 정책에 적은 단계, 마지막으로 resource와 condition key까지 inline policy에 추가해 잠근 단계가 세 패널로 놓여 있다.

좁힐 때 길은 세 단계로 나눈다.

1단계: AWSLambdaBasicExecutionRole부터 붙인다. 여기에 더해 도메인 단위 관리형 정책 하나를 같이 붙인다. 예를 들어 AWSLambdaVPCAccessExecutionRole(VPC Lambda용 ENI 권한), AWSLambdaSQSQueueExecutionRole(SQS event source mapping 권한). 이쪽 정책은 AWS가 관리하니까 새 API가 추가되면 자동으로 권한이 늘어난다. 편하지만 내가 모르는 권한이 자동으로 붙는 채널이 생긴다. 진짜 production에서 두 번째 단계로 넘어가는 게 보통이다.

2단계: 함수가 직접 쓰는 action만 남긴다. 내가 직접 정책을 한 JSON으로 적고 Role에 붙인다. 위 S3·DynamoDB 두 줄짜리 정책을 만들어 Role에 attach. 권한이 자동으로 늘어나지 않는다. 새 API를 쓰려면 정책을 수정해야 하는데, 번거롭지만 그게 의도다.

3단계: resource와 condition까지 함께 좁힌다. Customer-managed 정책에 더해 호출 컨텍스트를 좁히는 condition을 넣는다. aws:SourceVpc 로 어떤 VPC에서 들어온 호출만 허용할지, aws:CalledViaFirst 로 어떤 서비스를 거쳐 도달한 호출인지, aws:ResourceTag/<key> 로 어떤 태그가 붙은 리소스에만 동작할지를 잠근다. 요청에 새로 붙는 태그를 검사하려면 aws:RequestTag/<key>를 쓴다. SourceArn은 호출 대상을 특정할 때 쓰고, SourceAccount는 호출 계정을 고정할 때 쓴다. 두 키를 한 문제에 아무렇게나 겹치지 말고 맡은 범위에 맞춰 고른다. 운영 정책이 길어지지만, 단일 함수가 프로덕션 데이터에만, 특정 VPC에서만 손을 댄다는 단서를 얻는다.

처음 함수에는 1단계로 시작한다. 트래픽을 받기 시작하면 2단계로 좁힌다. 보안 감사가 들어오면 3단계로 좁힌다. 1단계에서 3단계로 바로 뛰지 않는다. 그렇게 건너뛰면 함수가 초반부터 실패한다. 권한은 필요한 동작이 늘어날 때만 조금씩 넓힌다.

함수마다 Role 하나? 폭증을 어떻게 다루나

함수가 50개, 100개, 500개로 늘어나면 Role도 똑같이 50, 100, 500개로 자란다. 각 함수가 다른 권한을 가져야 최소 권한이 의미를 갖는데, Role 관리만으로 한 사람의 일이 된다.

여기서 길이 두 갈래로 나뉜다.

  • (a) 함수마다 별도 Role을 유지한다. CloudFormation이나 Terraform으로 코드화해 자동 생성. 권한 격리는 완벽하지만 운영 부담이 그대로 남는다.
  • (b) 도메인 단위 공유 Role. 즉 "주문 처리 람다 그룹"처럼 같은 권한 집합을 쓰는 함수들이 한 Role을 공유. 권한 격리는 그룹 단위에서만 유지하고, 한 함수가 의도치 않게 다른 함수의 권한 영역을 건드릴 수 있다.

production에서 내가 본 가장 흔한 길은 (b)에서 도메인 경계가 명확한 핵심 그룹만 따로 (a)로 떼어내는 절충이다. 주문/결제/사용자 같은 핵심은 함수마다 Role, 내부 admin 자동화는 도메인 단위 공유 Role 하나. 격리와 운영 부담의 맞바꿈을 한 그림에 담는다.

Lambda란 무엇인가: 서버리스의 경계에서 관리형 정책은 시작 속도를 높여 주지만 권한 목록을 통째로 가져온다고 적었다. 그래서 나중에 인라인 정책으로 옮겨 적을 때 어떤 action을 계속 쓸지 직접 골라야 한다. 비용은 AWS가 숨긴 게 아니라 우리가 점검을 미룬 만큼 뒤로 쌓아 둔다.

반대 방향: 누가 이 함수를 호출할 수 있는가

지금까지 본 모든 정책은 Lambda가 다른 서비스에 손을 뻗는 방향의 권한이었다. 반대 방향이 하나 더 있다. 외부에서 Lambda 함수를 invoke 하려면 누가 허락해야 하는가.

리소스 정책으로 넘어가면 질문이 바뀐다. Role 정책은 함수가 밖으로 나갈 권한을 정하고, 함수 정책은 밖에서 Lambda로 들어올 권한을 정한다. 둘을 섞어 읽으면 호출 경로를 놓친다. IAM User나 Role(즉 사람·앱 쪽 호출자)이 부르면 호출하는 쪽의 IAM 정책에 lambda:InvokeFunction이 있어야 하고, 함수 자체의 Resource-based Policy(함수 정책)에도 그 호출자를 허용하는 statement가 같이 있을 때만 통과한다. 반면 API Gateway·S3·EventBridge 같은 AWS 서비스가 부를 때는 그 서비스에 attach 된 identity-based 정책이 따로 없어, 함수 정책 한쪽만 게이트로 작동한다 (이 둘이 어떻게 나뉘는지는 AWS Lambda Developer Guide의 permissions 페이지에 정리돼 있다).

Identity-based 정책과 Resource-based 정책의 차이 자체는 Policy: JSON으로 권한을 표현하는 법에서 한 번 정리했다. 여기서는 그 두 갈래가 Lambda 한 함수에서 어디서 만나는지 확인한다.

함수 정책이 별도로 존재하는 이유는 AWS 서비스 자신이 함수를 호출하는 패턴 때문이다. API Gateway가 들어온 HTTP 요청을 Lambda로 forward 할 때, 그 invoke를 누가 하는가? API Gateway 서비스 자신이다. 그 서비스에는 평소 IAM User처럼 attach 된 Identity-based 정책이 없다. 그래서 함수 쪽에서 "특정 API Gateway가 나를 호출해도 좋다"고 명시한다.

lambda:AddPermission API가 이 statement를 함수 정책에 추가한다. 콘솔에서 트리거를 추가하면 자동으로 호출되는 API다.

함수 정책에 SourceArn과 SourceAccount 조건을 묶은 도식. 왼쪽은 SourceArn 없이 모든 API Gateway 호출이 통과하는 그림이고, 오른쪽은 두 조건 키로 호출 ARN과 계정 ID를 동시에 잠가 다른 계정의 호출을 막는 그림이다.

여기서 confused deputy 문제가 생긴다. 만약 함수 정책에 모든 API Gateway 호출을 허용해 두면, 다른 계정 누군가가 자기 API Gateway를 만들어 내 함수를 invoke 할 수도 있다. 권한이 대신 행사되는 상황이다.

처방은 condition 키 두 개다.

  • aws:SourceArn: 정확히 어떤 API Gateway / S3 버킷 / EventBridge 룰이 호출하는지 ARN을 명시한다.
  • aws:SourceAccount: 호출하는 리소스가 내 계정 안에 있는지 확인한다.

AddPermission을 콘솔(예: API Gateway 트리거 추가 화면)에서 부르면 보통 두 키가 같이 붙고, CLI aws lambda add-permission 한 줄에서는 기본적으로 --source-arn만 들어가는 식이다. 직접 정책 JSON을 쓰는 경우엔 내가 챙겨야 한다. ARN에 이미 계정 ID가 포함돼 있으면 SourceAccount가 필수까지는 아니지만, S3 버킷처럼 같은 이름이 재생성될 수 있는 자원이나 cross-account 호출에서는 두 키를 같이 두는 쪽을 docs도 권한다.

함수 정책의 한도는 함수당 약 20KB다 (Lambda quotas 페이지에 명시). 한 함수에 statement를 계속 붙이기 전에 함수를 쪼갤지부터 확인한다. 함수 하나가 너무 많은 리소스와 주체를 받기 시작했다는 신호가 먼저 보이기 때문이다. EventBridge 룰을 함수 하나에 수십 개 붙이는 패턴에서는 한도에 닿는데, 그때 내가 본 길은 함수를 도메인별로 쪼개거나, 들어오는 트리거 측에서 호출 권한을 한쪽으로 모아 함수 정책 statement 수를 줄이는 우회다. 조건 키를 빼고 statement 수만 늘리면 설계를 단순화한 게 아니라 검토를 뒤로 미룬 셈이다.

두 방향이 한 호출에 같이 동작하는 그림

Allow와 explicit Deny가 만나는 정책 평가 순서 도식. 한 번의 Lambda 호출에서 함수 정책 게이트와 Execution Role 게이트가 각각 별도로 평가되고, 한쪽만 막혀도 호출 전체가 막힌다는 점을 두 막대 게이트로 표현했다.

API Gateway가 한 HTTP 요청을 받아 Lambda로 forward 한다고 보자. 이 한 호출에는 IAM 정책 검사가 두 군데에서 일어난다.

  • 호출 시점: API Gateway 서비스가 lambda:InvokeFunction을 부른다. 함수 정책의 statement가 이 호출을 허용하는지 검증한다 (Resource-based 게이트).
  • 서비스 호출 시점: 함수 코드가 dynamodb:PutItem을 부른다. Execution Role의 Identity-based 정책이 이 호출을 허용하는지 검증한다.

두 게이트는 서로 모른다. 한쪽이 통과해도 다른 쪽이 막으면 호출 전체가 멈춘다. 정책 평가는 Allow 하나로 끝나지 않는다. explicit Deny를 먼저 확인하고, 그다음에 누가 어떤 정책으로 호출을 열었는지 따라간다. 이 순서를 놓치면 원인을 Role에서 찾다가 함수 정책을 늦게 본다.

이 두 게이트의 정책 평가가 어떤 순서로 어떤 결과를 만드는지는 정책 평가 흐름: Allow와 Deny가 만나면에 한 번 정리해 뒀다. 거기 적은 6단계가 invoke 게이트와 service-call 게이트 양쪽에 똑같이 동작한다.

다음 편으로 넘어가기 전에

Execution Role은 Lambda가 바깥으로 손을 뻗을 때의 권한을 정의하고, 함수 정책은 안으로 들어오는 호출을 통제한다. 이 두 가지가 함께 갖춰져야 Lambda 한 함수가 다른 AWS 서비스 사이에서 어떤 정체성으로 일하는지 그림 한 장으로 설명할 수 있다.

Cold Start: 왜 첫 호출이 느린가는 관리형이 감추는 부담의 둘째 갈래다. Execution Role이 논리적 정체성을 정한다면, 콜드스타트는 물리적 자원이 없을 때 처음 만들어지는 비용을 보여준다.

배포 전에 세 군데를 꼭 확인한다. 내 Role, 함수 정책, 호출 주체 쪽 정책이다. 이 셋을 매번 짚으면 권한 문제를 가장 빨리 잡는다. CloudWatch Logs에 한 줄도 안 남으면 AWSLambdaBasicExecutionRole이 빠진 것이고, 다른 서비스 호출이 AccessDenied로 끊기면 그 서비스용 정책이 빠진 것이고, 외부 트리거가 함수를 못 부르면 함수 정책이 빠진 것이다.

YouTube 영상

채널 보기
직교성과 벡터 투영 | 선형대수학
투영과 예측, 그리고 선형 결합 | 선형대수학
숫자 하나가 AI 모델의 운명을 바꾼다? | 선형대수학
트라이(Trie)에서 단어를 삭제하는 방법 | Trie 자료구조 이야기
AI는 왜 수백 차원의 벡터를 사용할까? 고차원 공간과 행렬 | 선형대수학
우리가 매일 쓰는 맞춤법 검사기와 라우터 속에 숨겨진 알고리즘은? | Trie 자료구조 이야기
Trie 자료구조 파이썬 구현: Search와 Starts With 연산 | Trie 자료구조 이야기
내적의 기하학적 의미와 코사인 유사도 원리 | 선형대수학