Published on

EKS IRSA로 AccessDenied 해결과 권한 최소화

Authors

서버리스나 EC2 인스턴스 프로파일에 익숙한 팀이 EKS로 넘어오면, 가장 흔한 장애가 AccessDenied 입니다. 특히 파드가 AWS API를 호출하는 순간부터 문제가 시작됩니다. 노드 IAM Role에 권한을 몰아주면 당장은 해결되지만, 보안 관점에서 과도 권한이 노드 전체로 전파되고(노드 위의 모든 파드가 공유), 사고 범위가 커집니다.

이 글은 EKS에서 IRSA(IAM Roles for Service Accounts)로 AccessDenied 를 해결하는 과정을 “진단 체크리스트 → 신뢰 정책(Trust) → 권한 정책(Permissions) → 배포/검증” 순서로 정리하고, 마지막에 권한 최소화 패턴까지 다룹니다.

관련해서 AssumeRoleWithWebIdentity 자체가 403 으로 막히는 케이스는 아래 글에 더 상세한 원인/대응이 정리되어 있습니다.

왜 IRSA에서 AccessDenied가 자주 발생하나

IRSA는 크게 3개의 축이 맞아야 동작합니다.

  1. EKS 클러스터에 OIDC Provider가 연결되어 있어야 함
  2. Kubernetes ServiceAccount 에 올바른 IAM Role ARN이 annotation 되어야 함
  3. IAM Role의 Trust Policy가 해당 OIDC의 sub(서비스어카운트)와 aud 조건을 만족해야 함

여기까지 맞아도 실제 AWS API 호출 권한이 부족하면 AccessDenied 가 납니다. 즉,

  • AssumeRoleWithWebIdentity 단계에서 막히면 “신뢰(Trust) 문제”
  • AssumeRole은 되는데 API가 막히면 “권한(Permissions) 문제”

로 분리해서 보는 것이 핵심입니다.

증상별 빠른 분기: 어디서 막히는지 확인

1) 파드 로그에 AccessDenied 만 보일 때

애플리케이션이 호출한 AWS API가 거부된 것입니다. 예를 들면 S3라면 s3:GetObjects3:PutObject 가 부족할 수 있습니다.

2) AssumeRoleWithWebIdentity 관련 에러가 보일 때

IRSA 자체가 성립하지 않은 상태입니다. 대표적으로 다음과 같은 메시지가 나옵니다.

  • AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity
  • InvalidIdentityToken (OIDC issuer나 audience 불일치)

이 경우는 Trust Policy, OIDC Provider, ServiceAccount annotation/namespace/name 불일치부터 점검해야 합니다.

IRSA 기본 구성 요소 정리

OIDC Issuer 확인

aws eks describe-cluster \
  --name my-eks \
  --query "cluster.identity.oidc.issuer" \
  --output text

출력은 보통 https://oidc.eks.ap-northeast-2.amazonaws.com/id/XXXX 형태입니다. 여기서 https:// 를 제외한 값이 IAM OIDC Provider ARN에 들어갑니다.

ServiceAccount에 Role annotation

apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-sa
  namespace: app
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/app-irsa-role

중요 포인트는 namespacename 이 Trust Policy 조건의 sub 와 1:1로 맞아야 한다는 점입니다.

Trust Policy(신뢰 정책)에서 가장 많이 틀리는 부분

IRSA의 Trust Policy는 “누가 이 Role을 Assume할 수 있는가”를 정의합니다. EKS OIDC 기반이라 sts:AssumeRoleWithWebIdentity 와 OIDC 조건이 핵심입니다.

아래 예시는 가장 보편적인 형태입니다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.ap-northeast-2.amazonaws.com/id/XXXX"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.ap-northeast-2.amazonaws.com/id/XXXX:aud": "sts.amazonaws.com",
          "oidc.eks.ap-northeast-2.amazonaws.com/id/XXXX:sub": "system:serviceaccount:app:app-sa"
        }
      }
    }
  ]
}

자주 발생하는 실수

  • sub 에서 namespace 를 잘못 적음
  • serviceaccount 이름이 실제와 다름
  • OIDC Provider의 id/XXXX 가 클러스터와 불일치
  • audsts.amazonaws.com 이 아닌 값으로 들어감

이 중 하나라도 틀리면 AssumeRole 단계에서 막히고, 애플리케이션은 노드 Role로 떨어지거나(환경에 따라), 아예 자격 증명을 못 얻어 에러가 납니다.

Permissions Policy(권한 정책)로 AccessDenied를 해결하는 방법

AssumeRole은 성공했는데도 AccessDenied 가 난다면, 이제는 Role에 붙은 권한 정책을 줄이거나 늘리는 문제입니다. 여기서 중요한 목표는 두 가지입니다.

  • 필요한 액션만 허용해서 장애를 해결
  • 리소스 범위를 좁혀 권한 최소화

CloudTrail로 “정확히 무엇이 거부됐는지” 찾기

가장 빠른 방법은 CloudTrail Event에서 errorCodeAccessDenied 인 이벤트를 찾아 eventNameresources 를 확인하는 것입니다.

  • eventSource: 예) s3.amazonaws.com
  • eventName: 예) GetObject, PutObject, ListBucket
  • userIdentity.sessionContext.sessionIssuer.arn: 어떤 Role로 호출했는지

이렇게 확인하면 “정확히 어떤 API가 막혔는지”가 명확해져서 불필요하게 s3:* 같은 와일드카드로 뭉개지 않게 됩니다.

예시: S3 특정 버킷만 읽기 권한

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBucket",
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": ["arn:aws:s3:::my-private-bucket"]
    },
    {
      "Sid": "ReadObjects",
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::my-private-bucket/*"]
    }
  ]
}

여기서 흔한 함정은 ListBucket 은 버킷 ARN(슬래시 없는 리소스)이고, GetObject 는 오브젝트 ARN(뒤에 /*) 이라는 점입니다. 이 구분이 틀리면 계속 AccessDenied 가 납니다.

예시: SQS 특정 큐만 SendMessage

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "sqs:SendMessage",
        "sqs:GetQueueAttributes",
        "sqs:GetQueueUrl"
      ],
      "Resource": "arn:aws:sqs:ap-northeast-2:123456789012:my-queue"
    }
  ]
}

SendMessage 만 주면 SDK가 내부적으로 GetQueueUrl 을 호출하는 구성도 있어, 최소 권한을 하되 “SDK가 실제로 호출하는 보조 API” 까지 포함해야 합니다.

권한 최소화(Least Privilege)를 IRSA에서 실전으로 만드는 패턴

1) “서비스 단위 Role”을 기본으로, “기능 단위 Role”은 선택적으로

가장 안전한 기본은 마이크로서비스(또는 배포 단위)마다 Role을 하나씩 가지는 것입니다.

  • payments-api 파드: 결제 관련 DynamoDB 테이블만
  • batch-worker 파드: 특정 S3 prefix 쓰기만

기능 단위로 Role을 더 쪼개면 보안은 좋아지지만 운영 복잡도가 증가합니다. 따라서 기본은 서비스 단위로 두고, 고위험 권한(예: KMS Decrypt, SecretsManager GetSecretValue)은 별도 Role로 분리하는 식이 균형이 좋습니다.

2) 네임스페이스 경계를 Trust Policy에 강제

Trust Policy의 sub 를 와일드카드로 풀어버리면(system:serviceaccount:app:*) 같은 네임스페이스의 다른 서비스어카운트가 Role을 가져갈 수 있습니다.

가능하면 아래처럼 정확히 고정하세요.

  • system:serviceaccount:app:app-sa

정말 예외적으로 여러 SA를 허용해야 한다면, 최소한 네임스페이스는 고정하고 SA 이름 패턴도 제한하는 방식이 낫습니다.

3) 리소스 ARN을 최대한 좁히고, 조건(Condition)으로 보강

S3는 버킷 전체가 아니라 prefix 단위로 제한하고 싶을 때가 많습니다. 이때는 Resourcearn:aws:s3:::bucket/prefix/* 로 제한하는 것만으로도 상당히 줄어듭니다.

또한 서비스가 특정 VPC 엔드포인트를 통해서만 접근해야 한다면(환경에 따라) IAM Condition을 추가해 방어선을 늘릴 수 있습니다. 다만 Condition은 운영 중 디버깅 난이도를 크게 올릴 수 있으니, 먼저 “정확한 Action/Resource 매칭”을 완성한 뒤 단계적으로 추가하는 것을 권장합니다.

4) 노드 Role에서 불필요한 권한 제거

IRSA를 도입했는데도 AccessDenied 를 피하려고 노드 Role에 권한을 남겨두면, 결국 “권한이 어디서 왔는지”가 불명확해집니다.

  • 파드가 IRSA로 동작해야 하는 API 권한은 노드 Role에서 제거
  • 노드 Role은 EKS 노드 운영에 필요한 최소 권한으로 유지

이렇게 해야 사고 시 영향 범위가 줄고, 권한 감사도 쉬워집니다.

디버깅: 파드 안에서 현재 호출 주체 확인

IRSA가 제대로 적용됐는지 확인하려면 파드 내부에서 STS 호출로 현재 ARN을 찍는 것이 가장 확실합니다.

kubectl -n app exec -it deploy/app -- sh

# 컨테이너에 awscli가 없다면 임시 디버그 파드를 띄우는 것도 방법입니다.
aws sts get-caller-identity

정상이라면 Arnassumed-role/app-irsa-role/ 형태가 보입니다. 만약 노드 Role이 찍히면 IRSA가 적용되지 않은 것입니다(대개 ServiceAccount 미지정, annotation 누락, 또는 파드가 예전 SA로 떠 있는 경우).

또한 다음 환경 변수가 주입되는지도 확인하면 좋습니다.

  • AWS_ROLE_ARN
  • AWS_WEB_IDENTITY_TOKEN_FILE
env | grep AWS_
ls -al "$AWS_WEB_IDENTITY_TOKEN_FILE"

배포에서 자주 놓치는 체크리스트

Deployment/PodSpec에 ServiceAccount 지정 누락

ServiceAccount를 만들어도, 실제 워크로드가 그 SA를 사용하지 않으면 의미가 없습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
  namespace: app
spec:
  template:
    spec:
      serviceAccountName: app-sa
      containers:
        - name: app
          image: myrepo/app:latest

파드 재시작 없이 변경 반영 기대

ServiceAccount annotation을 바꿨다면, 기존 파드는 토큰/환경변수 주입이 갱신되지 않을 수 있습니다. 안전하게 롤링 재시작을 하세요.

kubectl -n app rollout restart deploy/app

운영 관점 권장 흐름: AccessDenied를 “안전하게” 해결하는 절차

  1. 파드에서 aws sts get-caller-identity 로 호출 주체가 IRSA Role인지 확인
  2. CloudTrail에서 거부된 eventNameResource 를 확인
  3. IAM Policy에 필요한 Action을 추가하되, Resource를 구체 ARN으로 제한
  4. 동작 확인 후, 사용하지 않는 권한을 다시 제거(정리 단계)
  5. 같은 유형의 권한은 재사용 가능한 Managed Policy로 분리하거나 Terraform 모듈화

이 흐름을 지키면, 급한 장애 대응 중에도 노드 Role에 권한을 덕지덕지 붙이는 방식으로 되돌아가지 않게 됩니다.

마무리

EKS에서 AccessDenied 는 단순히 “권한이 없다”가 아니라, IRSA의 Trust와 Permissions 중 어디가 깨졌는지를 먼저 분리해야 빠르게 해결됩니다. IRSA를 제대로 쓰면 파드 단위로 권한을 최소화할 수 있고, 노드 전체 권한 공유라는 구조적 리스크를 크게 줄일 수 있습니다.

특히 AssumeRoleWithWebIdentity 단계에서 막히는 경우는 증상이 비슷해도 원인이 다양하니, 아래 글의 체크리스트를 함께 참고하면 진단 시간을 더 줄일 수 있습니다.