Published on

EKS ExternalDNS가 Route53 생성 실패할 때 IRSA 점검

Authors

서버리스나 Ingress를 붙인 뒤 external-dns를 배포했는데 Route53 레코드가 생성되지 않으면, 대개 AWS API 호출이 실패하고 있습니다. 이때 가장 흔한 패턴은 다음 둘 중 하나입니다.

  • IRSA가 제대로 붙지 않아 Pod가 의도치 않게 노드 IAM Role 또는 권한 없는 자격증명으로 Route53를 호출
  • Route53 IAM 정책 범위가 부족(특히 ChangeResourceRecordSets의 Hosted Zone 리소스 지정/조건이 틀림)

이 글에서는 ExternalDNS가 Route53 레코드 생성에 실패할 때의 전형적인 로그 시그널을 기준으로, IRSA(OIDC/TrustPolicy/ServiceAccount) → IAM Policy → ExternalDNS 설정 순서로 빠르게 원인을 좁히고 복구하는 방법을 정리합니다.

관련해서 IRSA 자체가 AccessDenied를 내는 케이스는 아래 글의 체크리스트도 함께 보면 진단 속도가 빨라집니다.


1) 증상: ExternalDNS 로그에서 먼저 확인할 것

가장 먼저 external-dns Pod 로그를 봅니다.

kubectl -n external-dns logs deploy/external-dns -f

자주 보이는 실패 로그 예시는 다음과 같습니다.

  • IRSA 미적용/자격증명 문제
AccessDenied: User: arn:aws:sts::<ACCOUNT_ID>:assumed-role/<NODE_ROLE>/<i-...> is not authorized to perform: route53:ChangeResourceRecordSets

여기서 핵심은 assumed-role/<NODE_ROLE> 입니다. ServiceAccount Role이 아니라 노드 Role로 호출하고 있다는 뜻이라 IRSA가 안 먹은 겁니다.

  • Hosted Zone 리소스/조건 문제
AccessDenied: User: arn:aws:sts::<ACCOUNT_ID>:assumed-role/<IRSA_ROLE>/... is not authorized to perform: route53:ChangeResourceRecordSets on resource: arn:aws:route53:::hostedzone/Z123...

여기서는 IRSA Role로 호출은 되지만, 정책이 Hosted Zone에 대해 Change 권한을 허용하지 않거나 조건이 맞지 않는 상황입니다.

  • 권한은 있는데 대상 Zone을 못 찾는 설정 문제
No hosted zones matched; skipping

이 경우는 --domain-filter, --zone-id-filter, --aws-zone-type(public/private), --txt-owner-id 충돌 등 설정 이슈일 가능성이 큽니다.


2) IRSA가 실제로 적용됐는지: ServiceAccount → Pod에서 검증

ExternalDNS는 기본적으로 AWS SDK의 WebIdentity(IRSA) 를 통해 자격증명을 얻습니다. 따라서 “정책을 잘 줬는데도 AccessDenied”라면, 먼저 정말 그 Role로 호출 중인지를 확인해야 합니다.

2.1 ServiceAccount에 role-arn annotation 확인

kubectl -n external-dns get sa external-dns -o yaml

다음이 있어야 합니다.

metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::<ACCOUNT_ID>:role/eks-externaldns-irsa

없다면 Helm values 또는 매니페스트에서 ServiceAccount 생성/주입이 꼬였을 확률이 높습니다.

2.2 Pod가 해당 SA를 쓰는지 확인

kubectl -n external-dns get pod -l app.kubernetes.io/name=external-dns -o jsonpath='{.items[0].spec.serviceAccountName}'

external-dns가 아니면 Deployment/Helm 설정에서 SA 연결이 빠진 겁니다.

2.3 Pod 내부에서 실제 호출 주체(STS CallerIdentity) 확인

ExternalDNS 컨테이너에 AWS CLI가 없을 수 있으니, 같은 ServiceAccount로 디버그 Pod를 띄워 확인하는 방식이 안전합니다.

cat <<'YAML' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: irsa-debug
  namespace: external-dns
spec:
  serviceAccountName: external-dns
  containers:
  - name: awscli
    image: public.ecr.aws/aws-cli/aws-cli:2.15.0
    command: ["sh", "-c", "sleep 36000"]
YAML

kubectl -n external-dns exec -it irsa-debug -- aws sts get-caller-identity

정상이라면 결과의 Arn이 아래처럼 assumed-role/eks-externaldns-irsa 로 나와야 합니다.

{
  "Arn": "arn:aws:sts::<ACCOUNT_ID>:assumed-role/eks-externaldns-irsa/...."
}

만약 노드 Role이 나오면 IRSA가 적용되지 않은 것입니다. 이때는 아래 섹션의 OIDC/TrustPolicy를 집중 점검합니다.


3) IRSA 핵심: OIDC Provider와 Trust Policy가 맞는지

IRSA는 결국 “Kubernetes의 ServiceAccount 토큰(JWT)을 AWS STS가 신뢰”해야 동작합니다. 여기서 자주 틀리는 포인트는 다음 3가지입니다.

  1. 클러스터에 OIDC Provider가 등록되지 않음
  2. IAM Role의 Trust Policy 조건(sub/aud)이 불일치
  3. ServiceAccount namespace/name을 바꿨는데 Trust Policy를 안 바꿈

3.1 OIDC Provider 존재 여부

aws eks describe-cluster --name <CLUSTER_NAME> --query "cluster.identity.oidc.issuer" --output text

출력된 issuer(https://oidc.eks.<region>.amazonaws.com/id/<OIDC_ID>)가 IAM OIDC Provider로 등록돼 있어야 합니다.

aws iam list-open-id-connect-providers | grep <OIDC_ID>

없다면 eksctl utils associate-iam-oidc-provider 또는 Terraform로 연결해야 합니다.

3.2 Trust Policy 예시(ExternalDNS 전용)

아래는 ExternalDNS ServiceAccount(external-dns 네임스페이스, external-dns SA)를 대상으로 한 전형적인 Trust Policy입니다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/oidc.eks.<REGION>.amazonaws.com/id/<OIDC_ID>"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.<REGION>.amazonaws.com/id/<OIDC_ID>:aud": "sts.amazonaws.com",
          "oidc.eks.<REGION>.amazonaws.com/id/<OIDC_ID>:sub": "system:serviceaccount:external-dns:external-dns"
        }
      }
    }
  ]
}
  • sub는 반드시 system:serviceaccount:<namespace>:<serviceaccount> 형식
  • Helm으로 네임스페이스/SA 이름이 바뀌면 즉시 불일치가 발생합니다.

이 주제는 케이스가 많아, IRSA AccessDenied가 반복된다면 위 내부 링크의 체크리스트를 병행하는 것이 효율적입니다.


4) Route53 최소 권한 IAM Policy: Hosted Zone 리소스 지정이 핵심

IRSA가 정상인데도 ChangeResourceRecordSets에서 AccessDenied가 나면, 대부분 IAM Policy가 Hosted Zone ARN을 제대로 허용하지 않는 문제입니다.

ExternalDNS의 기본 동작은 대략 아래 API를 사용합니다.

  • route53:ListHostedZones
  • route53:ListResourceRecordSets
  • route53:ChangeResourceRecordSets

4.1 Hosted Zone을 특정해 최소 권한 부여(권장)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowListZones",
      "Effect": "Allow",
      "Action": [
        "route53:ListHostedZones",
        "route53:ListHostedZonesByName",
        "route53:ListTagsForResource"
      ],
      "Resource": "*"
    },
    {
      "Sid": "AllowReadWriteRecordsInSpecificZone",
      "Effect": "Allow",
      "Action": [
        "route53:ChangeResourceRecordSets",
        "route53:ListResourceRecordSets"
      ],
      "Resource": "arn:aws:route53:::hostedzone/Z1234567890ABCDE"
    }
  ]
}
  • List* 계열은 Resource가 *인 경우가 많습니다(Route53 특성)
  • ChangeResourceRecordSets는 Hosted Zone ARN을 정확히 지정해야 합니다.

4.2 여러 Hosted Zone을 운영한다면

  • zone이 여러 개면 Resource에 Hosted Zone ARN 배열을 넣거나
  • ExternalDNS 실행 옵션에서 --zone-id-filter=Z...로 대상 zone을 제한하세요.

5) ExternalDNS Helm/매니페스트 설정에서 자주 터지는 지점

권한이 맞아도 “레코드가 안 생긴다”는 케이스는 설정 충돌이 원인일 수 있습니다.

5.1 domain-filter / zone-id-filter / zone-type

  • --domain-filter=example.com 이라면 foo.example.com만 대상
  • private zone인데 --aws-zone-type=public이면 매칭 실패

예시(Deployment args):

args:
  - --provider=aws
  - --registry=txt
  - --txt-owner-id=eks-prod
  - --policy=sync
  - --domain-filter=example.com
  - --aws-zone-type=public

5.2 TXT 레코드 소유권 충돌

ExternalDNS는 기본적으로 TXT 레코드로 “내가 만든 레코드”를 추적합니다.

  • 같은 도메인/zone에 ExternalDNS가 여러 개면
    • --txt-owner-id를 환경별로 다르게
    • 또는 --txt-prefix로 분리

충돌 시 로그에 “owned by another controller” 류의 메시지가 나타납니다.


6) 재현 가능한 트러블슈팅 플로우(10분 루틴)

아래 순서로 보면 대부분 10분 내로 원인이 좁혀집니다.

  1. kubectl logsAccessDenied 주체가 노드 Role인지 IRSA Role인지 확인
  2. ServiceAccount annotation에 eks.amazonaws.com/role-arn 존재 확인
  3. 디버그 Pod로 aws sts get-caller-identity 실행해 실제 호출 ARN 확인
  4. IAM Role Trust Policy에서 sub/aud가 현재 SA와 일치하는지 확인
  5. IAM Policy에서 route53:ChangeResourceRecordSets정확한 hostedzone ARN에 허용되는지 확인
  6. ExternalDNS args에서 domain-filter/zone-type/txt-owner-id로 매칭/소유권 문제 확인

7) 예시: IRSA + ExternalDNS Helm values (실전 템플릿)

아래는 Helm 차트 사용 시 자주 쓰는 형태입니다(차트마다 키는 다를 수 있으니 개념 위주로 보세요).

serviceAccount:
  create: true
  name: external-dns
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::<ACCOUNT_ID>:role/eks-externaldns-irsa

provider: aws
policy: sync
registry: txt
txtOwnerId: eks-prod

domainFilters:
  - example.com

extraArgs:
  - --aws-zone-type=public

배포 후에는 반드시 아래로 “SA가 적용된 Pod인지”를 확인합니다.

kubectl -n external-dns get deploy external-dns -o jsonpath='{.spec.template.spec.serviceAccountName}'; echo

8) 마무리: 실패 원인을 ‘권한’이 아닌 ‘주체’부터 본다

ExternalDNS의 Route53 레코드 생성 실패는 겉으로는 전부 AccessDenied처럼 보이지만, 실제로는 누가(어떤 ARN으로) 호출했는지가 절반 이상을 결정합니다.

  • 노드 Role로 호출 중이면: IRSA(OIDC/TrustPolicy/SA) 문제
  • IRSA Role로 호출 중이면: Route53 Hosted Zone 권한 범위/조건 문제

IRSA 관련 AccessDenied를 더 체계적으로 점검하려면 아래 글의 OIDC/TrustPolicy/SA 체크리스트를 함께 참고하면 좋습니다.