* feat(annotations): add custom annotation prefix support for split horizon DNS Add --annotation-prefix flag to allow customizing the annotation prefix used by external-dns. This enables split horizon DNS scenarios where multiple instances process different sets of annotations from the same Kubernetes resources. Changes: - Add AnnotationPrefix field to Config with validation - Convert annotation constants to variables that can be reconfigured - Add SetAnnotationPrefix() function to rebuild annotation keys - Integrate annotation prefix setting in controller startup - Update Helm chart with annotationPrefix value - Add comprehensive split horizon DNS documentation - Update FAQ with annotation prefix examples This maintains full backward compatibility - the default prefix remains "external-dns.alpha.kubernetes.io/". Co-Authored-By: Claude <noreply@anthropic.com> * docs(advanced): fix markdown formatting in split-horizon guide Add blank lines before code blocks to improve markdown rendering and comply with markdownlint rules. Co-Authored-By: Claude <noreply@anthropic.com> * docs(advanced): fix markdown formatting in split-horizon guide Co-Authored-By: Claude <noreply@anthropic.com> * docs(charts): regenerate Helm chart documentation Co-Authored-By: Claude <noreply@anthropic.com> * test: add AnnotationPrefix field to test configs Add missing AnnotationPrefix field to minimalConfig and overriddenConfig test configurations to match the new default value set in NewConfig(). Co-Authored-By: Claude <noreply@anthropic.com> * test(charts): update error pattern in json-schema test Update expected error message pattern to match current Helm validation output format. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(annotations): remove init() for explicit initialization - Remove init() function from annotations package - Add explicit SetAnnotationPrefix() call in controller/execute.go - Remove annotation key aliases from source/source.go - Replace all alias usages with annotations.* references (348 changes in 28 files) - Add TestMain to existing test files (service_test.go, cloudflare_test.go) This change makes annotation initialization explicit and predictable, avoiding hidden global state initialization at import time. Co-Authored-By: Claude <noreply@anthropic.com> * docs: update changelog and mkdocs to include annotationPrefix and split horizon DNS Signed-off-by: Aleksei Sviridkin <f@lex.la> * docs(split-horizon): fix linting Signed-off-by: Aleksei Sviridkin <f@lex.la> * refactor(annotations): replace hardcoded annotation prefix with constant Replace all hardcoded "external-dns.alpha.kubernetes.io/" strings with annotations.DefaultAnnotationPrefix constant to establish a single source of truth. Changes: - Add DefaultAnnotationPrefix constant in source/annotations/annotations.go - Replace hardcoded string in controller/execute.go with constant reference - Replace hardcoded strings in pkg/apis/externaldns/types.go (2 occurrences) - Add helm unit tests for annotationPrefix value This eliminates string duplication and makes future changes easier. Co-Authored-By: Claude <noreply@anthropic.com> --------- Signed-off-by: Aleksei Sviridkin <f@lex.la> Co-authored-by: Claude <noreply@anthropic.com>
7.2 KiB
Split Horizon DNS
Split horizon DNS allows you to serve different DNS responses based on the client's location - internal clients receive private IPs while external clients receive public IPs. External-DNS supports split horizon DNS by running multiple instances with different annotation prefixes.
Overview
By default, all external-dns instances use the same annotation prefix: external-dns.alpha.kubernetes.io/. This means all instances process the same annotations. To enable split horizon DNS, you can configure each instance to use a different annotation prefix via the --annotation-prefix flag.
Use Cases
- Internal/External separation: Internal DNS points to private IPs (ClusterIP), external DNS points to public Load Balancer IPs
- Multiple DNS providers: Route different services to different DNS providers (e.g., internal to CoreDNS, external to Route53)
- Geographic split: Different DNS records for different regions
Configuration
Basic Split Horizon Setup
Internal DNS Instance:
external-dns \
--annotation-prefix=internal.company.io/ \
--source=service \
--source=ingress \
--provider=aws \
--aws-zone-type=private \
--domain-filter=internal.company.com \
--txt-owner-id=internal-dns
External DNS Instance:
external-dns \
--annotation-prefix=external-dns.alpha.kubernetes.io/ \ # default, can be omitted
--source=service \
--source=ingress \
--provider=aws \
--aws-zone-type=public \
--domain-filter=company.com \
--txt-owner-id=external-dns
Service with Both Annotations
apiVersion: v1
kind: Service
metadata:
name: myapp
annotations:
# Internal DNS reads this
internal.company.io/hostname: myapp.internal.company.com
internal.company.io/ttl: "300"
internal.company.io/target: 10.0.1.50 # Private IP
# External DNS reads this
external-dns.alpha.kubernetes.io/hostname: myapp.company.com
external-dns.alpha.kubernetes.io/ttl: "60"
# No target = uses LoadBalancer IP automatically
spec:
type: LoadBalancer
clusterIP: 10.0.1.50
ports:
- port: 80
targetPort: 8080
selector:
app: myapp
Result:
- Internal DNS (Route53 Private Zone
internal.company.com):myapp.internal.company.com → 10.0.1.50 - External DNS (Route53 Public Zone
company.com):myapp.company.com → 203.0.113.10(LoadBalancer IP)
Helm Chart Configuration
You can use the Helm chart to deploy multiple instances:
values-internal.yaml:
annotationPrefix: "internal.company.io/"
provider:
name: aws
aws:
zoneType: private
domainFilters:
- internal.company.com
txtOwnerId: internal-dns
sources:
- service
- ingress
values-external.yaml:
# annotationPrefix defaults to "external-dns.alpha.kubernetes.io/"
# can be omitted or set explicitly:
# annotationPrefix: "external-dns.alpha.kubernetes.io/"
provider:
name: aws
aws:
zoneType: public
domainFilters:
- company.com
txtOwnerId: external-dns
sources:
- service
- ingress
Deploy:
# Internal instance
helm install external-dns-internal external-dns/external-dns \
--namespace external-dns-internal \
--create-namespace \
--values values-internal.yaml
# External instance
helm install external-dns-external external-dns/external-dns \
--namespace external-dns-external \
--create-namespace \
--values values-external.yaml
Advanced Examples
Three-Way Split (Internal / DMZ / External)
apiVersion: v1
kind: Service
metadata:
name: api
annotations:
# Internal (private network only)
internal.company.io/hostname: api.internal.company.com
internal.company.io/ttl: "300"
# DMZ (accessible from office network)
dmz.company.io/hostname: api.dmz.company.com
dmz.company.io/ttl: "120"
# External (public internet)
external-dns.alpha.kubernetes.io/hostname: api.company.com
external-dns.alpha.kubernetes.io/ttl: "60"
external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"
spec:
type: LoadBalancer
# ...
Deploy three instances:
# Internal
--annotation-prefix=internal.company.io/ --provider=aws --aws-zone-type=private
# DMZ
--annotation-prefix=dmz.company.io/ --provider=aws --aws-zone-type=private
# External
--annotation-prefix=external-dns.alpha.kubernetes.io/ --provider=cloudflare
Different Providers Per Instance
apiVersion: v1
kind: Service
metadata:
name: webapp
annotations:
# Route53 for AWS internal
aws.company.io/hostname: webapp.aws.company.com
aws.company.io/aws-alias: "true"
# Cloudflare for public
cf.company.io/hostname: webapp.company.com
cf.company.io/cloudflare-proxied: "true"
spec:
type: LoadBalancer
# ...
Deploy:
# AWS instance
--annotation-prefix=aws.company.io/ --provider=aws
# Cloudflare instance
--annotation-prefix=cf.company.io/ --provider=cloudflare
Important Notes
- Annotation prefix must end with
/- The validation will fail if the prefix doesn't end with a forward slash. - Backward compatibility - If you don't specify
--annotation-prefix, the defaultexternal-dns.alpha.kubernetes.io/is used, maintaining full backward compatibility. - All annotations use the same prefix - When you set a custom prefix, ALL external-dns annotations (hostname, ttl, target, cloudflare-proxied, etc.) must use that prefix.
- TXT ownership records - Each instance should have a unique
--txt-owner-idto avoid conflicts in ownership tracking. - Provider-specific annotations - Provider-specific annotations (like
cloudflare-proxied,aws-alias) also use the custom prefix:
custom.io/hostname: example.com
custom.io/cloudflare-proxied: "true" # NOT external-dns.alpha.kubernetes.io/cloudflare-proxied
Troubleshooting
Both instances processing the same resources
Problem: Both internal and external instances are creating records for the same service.
Solution: Make sure you're using different annotation prefixes and that your services have the correct annotations:
# ✅ Correct - different prefixes
internal.company.io/hostname: internal.example.com
external-dns.alpha.kubernetes.io/hostname: example.com
# ❌ Wrong - same prefix
external-dns.alpha.kubernetes.io/hostname: internal.example.com
external-dns.alpha.kubernetes.io/hostname: example.com # Second one overwrites first
Validation error: "annotation-prefix must end with '/'"
Problem: The annotation prefix doesn't end with a forward slash.
Solution: Always end your custom prefix with /:
# ✅ Correct
--annotation-prefix=custom.io/
# ❌ Wrong
--annotation-prefix=custom.io
Provider-specific annotations not working
Problem: Cloudflare/AWS-specific annotations are not being applied.
Solution: Provider-specific annotations must use the same prefix as the hostname:
# If using custom prefix
custom.io/hostname: example.com
custom.io/cloudflare-proxied: "true"
custom.io/ttl: "60"
See Also
- Configuration Precedence - Understanding how external-dns processes configuration
- FAQ - Frequently asked questions
- AWS Provider - AWS Route53 configuration
- Cloudflare Provider - Cloudflare configuration