DevOps
도메인 관리 자동화를 위한 ExternalDNS
Jensen여기어때
2025년 7월 10일
원문에서 보기 ↗안녕하세요. 여기어때컴퍼니 SRE팀에서 EKS(Elastic Kubernetes Service, AWS의 관리형 Kubernetes 서비스)를 담당하고 있는 젠슨입니다. 이번 글에서는 EKS 관리의 효율성을 높이기 위한 ExtenalDNS 활용에 대해 이야기 해보겠습니다.
EKS 외부 에서 EKS 내부에 구동중인 Pod의 Application을 호출하기 위해서는 Ingress를 통해 접근해야 합니다. Ingress는 보통 ALB로 생성 되며 ALB의 DNS 정보를 통해 호출할 수 있습니다. DNS 정보는 internal-k8s-xxx-xxxxxxxxxx-xxxxxxxx.ap-northeast-2.elb.amazonaws.com 와 같은 형식이며 해당 DNS를 브라우저에 입력 후 호출하면 Pod로 생성된 Application에 접근할 수 있습니다.
이러한 ALB의 DNS 주소를 사람이 매번 입력하여 접속하기는 어렵기 때문에 우리는 Route53과 같은 서비스를 이용하여 사용하기 쉬운 도메인 주소를 사용합니다. 위의 DNS 정보를 alias 형태로 Route53 에 등록하여 test.example.com과 같은 형태로 사용하는 것입니다.
Ingress로 생성한 Host 주소가 몇개 없다면 Route53에 레코드를 등록하는 과정이 번거롭지 않지만 EKS에서 구동중인 Application의 숫자가 늘어날수록 사람이 직접 관리 하기가 힘들어지게 됩니다. 이렇게 번거롭고 어려운 도메인 관리를 ExternalDNS를 통해 자동으로 해결할 수 있습니다.

즉, ExternalDNS는 Ingress나 Service의 Host에 선언한 도메인 주소들을 자동으로 Route53에 등록 및 관리해 주는 역할을 담당합니다.
1. ExternalDNS 환경 구성
ExternalDNS를 사용하기 전에 ExternalDNS에서 사용할 IAM Role과 Policy 를 생성해야 합니다. 일반적으로 여러 개의 AWS 계정을 사용 하더라도 보통 도메인은 1개의 계정에서 관리 합니다. 여기서는 도메인을 관리하는 별도의 AWS 계정이 있다는 가정하에 설명 하도록 하겠습니다.
ExternalDNS 설치 계정
EKS가 설치 되어 있고 ExternalDNS를 설치하는 계정 입니다.
- Policy Name : eks-external-dns-policy
- Policy
{
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Resource": "arn:aws:iam::{{도메인 관리 AWS Account ID}}:role/cross-account-external-dns-role"
}
],
"Version": "2012-10-17"
}
- Role Name : eks-external-dns-role
- 신뢰 관계
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::{{EKS가 설치된 AWS Account ID}}:oidc-provider/oidc.eks.ap-northeast-2.amazonaws.com/id/{{EKS OIDC}}"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.ap-northeast-2.amazonaws.com/id/{{EKS OIDC}}:sub": "system:serviceaccount:external-dns:external-dns",
"oidc.eks.ap-northeast-2.amazonaws.com/id/{{EKS OIDC}}:aud": "sts.amazonaws.com"
}
}
}
]
}
도메인 관리 계정
도메인을 관리하는 AWS 계정 입니다.
- Policy Name : cross-account-external-dns-policy
- Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets"
],
"Resource": [
"arn:aws:route53:::hostedzone/{{관리할 hostedzone ID}}"
]
},
{
"Effect": "Allow",
"Action": [
"route53:ListHostedZones",
"route53:ListResourceRecordSets",
"route53:ListTagsForResources"
],
"Resource": [
"*"
]
}
]
}
- Role Name : cross-account-external-dns-role
- 신뢰 관계
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::{{EKS가 설치된 AWS Account ID}}:role/eks-external-dns-role"
},
"Action": "sts:AssumeRole"
}
]
}
위와 같이 생성 하면 ExternalDNS가 설치된 계정의 eks-external-dns-role이 도메인을 관리하는 계정의 cross-account-external-dns-role을 이용하여 레코드를 ChangeResourceRecordSets 할 수 있습니다.
2. ExternalDNS 설치
Helm을 이용하여 ExternalDNS를 설치하도록 하겠습니다.
# Repo 등록
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns
# 버전 확인(최신 버전 1.17.0)
helm search repo external-dns
NAME CHART VERSION APP VERSION DESCRIPTION
external-dns/external-dns 1.17.0 0.17.0 ExternalDNS synchronizes exposed Kubernetes Ser...
# 설치
helm upgrade --install external-dns/external-dns --version 1.17.0 \
--set provider=aws \
--set domainFilters[0]=example.com \
--set registry=txt \
--set txtOwnerId=external-dns \
--set policy=sync \
--set managedRecordTypes[0]=A \
--set sources[0]=ingress \
--set env[0].name=AWS_DEFAULT_REGION \
--set env[0].value=us-east-1 \
--set serviceAccount.annotations."eks\\.amazonaws\\.com/role-arn"="arn:aws:iam::{{EKS가 설치된 AWS Account ID}}:role/eks-external-dns-role" \
--set serviceAccount.name=external-dns \
--set extraArgs[0]="--aws-assume-role=arn:aws:iam::{{도메인 관리 AWS Account ID}}:role/cross-account-external-dns-role" \
--set extraArgs[1]="--aws-zone-type=private" \
--set extraArgs[2]="--txt-prefix=%{record_type}-"
set 옵션을 하나씩 알아보도록 하겠습니다.
- provider : EKS를 사용중이기 때문에 aws로 입력 합니다.
- domainFilters[0] : ExternalDNS에서 관리할 도메인 리스트 입니다. 만약 다른 도메인도 관리해야 한다면 리스트를 하단에 추가해 주면 됩니다.
- registry : ExternalDNS에서 관리하는 도메인의 소유권을 어디에 저장할지에 대한 것으로 txt로 설정하면 동일한 HostedZone에 txt 레코드로 저장합니다. dynamodb와 같은 값으로 선언하여 다른 곳에 저장해도 됩니다.
- txtOwnerId : ExternalDNS에서 관리하는 도메인의 OwnerID 값으로 위에서 설정한 txt 레코드의 소유자 ID가 무엇인지에 대한 정보 입니다.
- policy : 도메인을 수정할 수 있는 권한으로 실제로 사용한다면 upsert-only와 sync 중 하나를 사용 합니다. 2개의 권한이 비슷한데 도메인을 생성할 경우 해당 도메인이 기존에 등록되어 있지 않았던 도메인 이라면 해당 도메인을 등록 합니다. 만약 이미 등록되어 있던 도메인 이지만 ExternalDNS를 이용하여 등록한 도메인이 아니라면 수정이나 삭제를 하지 않습니다. 여기까지는 2개의 권한이 동일 합니다. 다른 점은 이미 등록되어 있던 도메인이 ExternalDNS를 이용하여 등록했던 도메인 이며 해당 도메인 정보가 있는 Ingress나 Service를 삭제할 경우 입니다. upsert-only 는 Ingress나 Service가 삭제될 때 기존에 등록한 도메인을 삭제하지 않습니다. 반면에 sync 설정은 Ingress나 Service가 삭제될 경우 해당 도메인도 같이 레코드에서 삭제 해 줍니다.
- managedRecordTypes : ExternalDNS에서 관리할 레코드 type 입니다. A, AAAA, CNAME, MX 등이 있습니다.
- sources : ExternalDNS에서 등록할 도메인 정보를 어디에서 가져올지를 정하는 것으로 ingress와 service가 있습니다.
- env : 도메인 정보를 가지고 있는 Default Region 을 선언하는 것으로 us-east-1 입니다.
- serviceaccount.annotation : 위에서 생성한 eks-external-dns-role의 ARN 입니다.
- serviceaccount.name : eks-external-dns-policy에서 external-dns로 생성했기 때문에 같은 값을 입력 합니다.
- aws-assume-role : 도메인을 관리하는 계정에서 생성한 cross-account-external-dns-role의 ARN 입니다.
- aws-zone-type : Route53의 HostedZone에 있는 도메인 type이 public 인지 private 인지를 구분하는 값입니다.
- txt-prefix : txt로 도메인의 소유권을 레코드에 저장할 때 어떤 형태의 이름으로 저장할지에 대한 정보 입니다. 만약 이 값을 선언하지 않으면 도메인이 test.example.com일 경우 test.example.com 이름의 A 레코드, test.example.com 이름의 txt 레코드, cname-test.example.com 이름의 txt 레코드로 도메인 1개당 총 3개의 레코드가 생성 됩니다. 동일한 txt 레코드를 2개 생성할 필요는 없기 때문에 위와 같은 prefix 를 선언하여 txt 레코드를 1개로 줄여서 도메인 1개당 2개의 레코드가 생성되도록 설정하는게 좋습니다.
설치가 완료 되면 아래와 같이 Pod가 정상적으로 Running 상태가 되어야 합니다.
kubectl get pod -n external-dns
NAME READY STATUS RESTARTS AGE
external-dns-f8cf9dc57-xnqgv 1/1 Running 0 48m
해당 Pod의 Log를 살펴보면 지속적으로 위에서 등록한 도메인을 1분 주기로 확인하는 것을 알 수 있습니다.
time="2025-07-03T06:25:21Z" level=info msg="Assuming role: arn:aws:iam::{{도메인 관리 AWS Account ID}}:role/cross-account-external-dns-role"
time="2025-07-03T06:25:32Z" level=info msg="Applying provider record filter for domains: [example.com.]"
time="2025-07-03T06:25:32Z" level=info msg="All records are already up to date"
time="2025-07-03T06:26:32Z" level=info msg="Applying provider record filter for domains: [example.com.]"
time="2025-07-03T06:26:32Z" level=info msg="All records are already up to date"
3. 도메인 등록 Test
이제 Ingress를 생성하여 해당 도메인이 정상적으로 등록되는지 확인해 보겠습니다. 아래와 같은 예제 파일을 준비 하였습니다.
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: default
name: external-dns-test
spec:
selector:
matchLabels:
app.kubernetes.io/name: external-dns-test
replicas: 5
template:
metadata:
labels:
app.kubernetes.io/name: external-dns-test
spec:
containers:
- image: nginx
imagePullPolicy: Always
name: external-dns-test
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
namespace: default
name: external-dns-test
spec:
ports:
- port: 80
targetPort: 80
protocol: TCP
type: NodePort
selector:
app.kubernetes.io/name: external-dns-test
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: default
name: external-dns-test
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
external-dns.alpha.kubernetes.io/ingress-hostname-source: annotation-only
external-dns.alpha.kubernetes.io/hostname: test.example.com
spec:
ingressClassName: alb
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: external-dns-test
port:
number: 80
위 예제에서 주의 깊게 보셔야 하는건 Ingress의 annotation에 있는 external-dns로 시작하는 값들 입니다.
- external-dns.alpha.kubernetes.io/ingress-hostname-source: annotation-only
위의 annotation을 설정하지 않으면 ingress에 등록된 모든 host 정보를 Route53에 등록하게 됩니다. 실제로 등록해야 할 도메인만 따로 분류하려면 반드시 위의 값을 선언해야 합니다.
- external-dns.alpha.kubernetes.io/hostname: test.example.com
위의 annotation에 설정된 도메인만 Route53 에 등록 합니다. 만약 ingress-hostname-source: annotation-only를 선언하지 않았다면 위의 hostname에 입력한 도메인과 관계없이 ingress 내부의 host에 선언한 모든 도메인이 Route53에 등록 됩니다.
위의 yaml을 배포 후 ExternalDNS의 Log를 살펴보면 아래와 같이 2개의 레코드가 등록된 것을 확인할 수 있습니다.
time="2025-07-03T07:56:19Z" level=info msg="Applying provider record filter for domains: [example.com.]"
time="2025-07-03T07:56:20Z" level=info msg="Desired change: CREATE cname-test.example.com TXT" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
time="2025-07-03T07:56:20Z" level=info msg="Desired change: CREATE test.example.com A" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
time="2025-07-03T07:56:20Z" level=info msg="2 record(s) were successfully updated" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
Route53에서 확인해 보면 test.example.com 레코드가 Alias로 등록 되었고 ingress의 DNS 정보로 등록 되었습니다. 이외에 cname-test.example.com 레코드가 등록되었고 해당 도메인을 txt로 ExternalDNS에서 관리하는 것을 확인할 수 있습니다.

txt의 값을 보면 “heritage=external-dns,external-dns/owner=external-dns,external-dns/resource=ingress/default/external-dns-test”와 같이 적용되어 있으며 owner=external-dns로 설치할때 선언 했던 owner 값이 정상적으로 등록된 것을 확인할 수 있습니다.
ExternalDNS가 기존에 등록되어 있던 도메인을 수정하거나 삭제 하지 않고 안전하게 사용할 수 있는지 검증해 보도록 하겠습니다. 우선 위의 yaml로 배포한 내용을 삭제 합니다.
ExternalDNS의 Log를 살펴보면 아래와 같이 2개의 레코드가 삭제되는 것을 확인할 수 있습니다.
time="2025-07-03T08:16:30Z" level=info msg="Applying provider record filter for domains: [example.com.]"
time="2025-07-03T08:16:30Z" level=info msg="Desired change: DELETE cname-test.example.com TXT" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
time="2025-07-03T08:16:30Z" level=info msg="Desired change: DELETE test.example.com A" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
time="2025-07-03T08:16:30Z" level=info msg="2 record(s) were successfully updated" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
Route53에서 확인해 보면 레코드가 삭제되었을 것입니다. 만약 설치 시 policy를 upsert-only로 설정했다면 Ingress만 삭제되고 위의 도메인 레코드는 삭제되지 않았을 것입니다.
이제 콘솔에서 강제로 test.example.com을 미리 등록 해 놓은 후 위의 yaml 을 배포 했을 때 레코드 값이 변화 하는지와 Ingress를 삭제한 후에 레코드가 삭제되는지를 Test 해보도록 하겠습니다. 100.100.100.100으로 등록 하였습니다.

Ingress가 정상적으로 생성된 후 약 5분을 기다렸지만 ExternalDNS Log에는 별다른 변화가 없습니다.
time="2025-07-03T08:26:37Z" level=info msg="Applying provider record filter for domains: [example.com.]"
time="2025-07-03T08:26:37Z" level=info msg="All records are already up to date"
time="2025-07-03T08:27:35Z" level=info msg="Applying provider record filter for domains: [example.com.]"
time="2025-07-03T08:27:35Z" level=info msg="All records are already up to date"
Route53의 레코드 값도 100.100.100.100에서 변화가 없습니다. 이미 등록되어 있는 도메인일 경우 ExternalDNS에서 해당 도메인을 수정하지 않는 것을 확인할 수 있습니다. 이제 삭제를 통해 레코드가 삭제되지 않는지 확인해 보겠습니다.
Ingress가 정상적으로 삭제된 후 약 5분을 기다렸지만 위와 마찬가지로 ExternalDNS Log에 별다른 변화가 없습니다.
time="2025-07-03T08:33:38Z" level=info msg="Applying provider record filter for domains: [example.com.]"
time="2025-07-03T08:33:38Z" level=info msg="All records are already up to date"
time="2025-07-03T08:34:40Z" level=info msg="Applying provider record filter for domains: [example.com.]"
time="2025-07-03T08:34:40Z" level=info msg="All records are already up to date"
Route53에도 위에서 등록한 레코드 값에 변화가 없는 것을 확인할 수 있습니다.
결론…
EKS에서 관리하는 Application이 많아지면서 이와 맵핑되는 도메인을 수동으로 관리하는 것은 매우 어려우면서도 번거로운 일입니다. 이를 자동화 해줄 수 있는 도구가 있을까? 라는 물음과 동시에 찾아온 것은 도메인을 도구에게 자동으로 맡길 수 있을까? 라는 것이였습니다.
위의 Test 내용을 읽어 보신 분들은 아마도 저와 같은 깨달음을 얻으셨을 거라고 생각 합니다. ExternalDNS를 사용하면 도메인 관리를 안전하게 할 수 있고 자동화 할 수 있어 하나의 관리 포인트가 줄어 든다는 것입니다.
마지막으로 언제나 그렇듯 이 글을 읽어 보시는 분들이 ExternalDNS를 처음 접하실 때 도움이 되었으면 좋겠습니다. 긴 글 읽어 주셔서 깊이 감사 드리며 한가지 주의사항을 추가해 드리겠습니다.
주의사항!
ExternalDNS에서는 도메인 1개당 2개의 레코드가 등록되고 관리 되는데 만약 실수로 2개의 레코드 중 1개의 레코드가 삭제 되면 다음과 같은 에러가 발생하면서 레코드 관리가 정상적으로 되지 않습니다.
time="2025-07-03T08:43:45Z" level=info msg="Desired change: CREATE cname-temp.example.com TXT" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
time="2025-07-03T08:43:45Z" level=info msg="Desired change: CREATE temp.example.com A" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
time="2025-07-03T08:43:45Z" level=error msg="Failure in zone example.com. when submitting change batch: operation error Route 53: ChangeResourceRecordSets, https response error StatusCode: 400, RequestID: ee1ed26a-ec90-493f-95dc-226c1750d3ad, InvalidChangeBatch: [Tried to create resource record set [name='cname-test.example.com.', type='TXT'] but it already exists]" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
time="2025-07-03T08:43:46Z" level=error msg="Failed to do run once: soft error\nfailed to submit all changes for the following zones: [/hostedzone/XXXXXXXXXXXXX]"
이런 경우 실수로 삭제한 레코드와 한쌍이 되는 레코드를 삭제 해 주면 다시 정상적으로 레코드가 등록 되고 관리를 시작합니다.
time="2025-07-03T08:48:49Z" level=info msg="Desired change: CREATE cname-test.example.com TXT" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
time="2025-07-03T08:48:49Z" level=info msg="Desired change: CREATE test.example.com A" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.
time="2025-07-03T08:48:49Z" level=info msg="2 record(s) were successfully updated" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=example.com.