external-dns/docs/advanced/split-horizon.md
Aleksei Sviridkin 5a55b09f48
feat(annotations): add custom annotation prefix support for split horizon DNS (#5889)
* 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>
2025-11-08 03:56:52 -08:00

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

  1. Annotation prefix must end with / - The validation will fail if the prefix doesn't end with a forward slash.
  2. Backward compatibility - If you don't specify --annotation-prefix, the default external-dns.alpha.kubernetes.io/ is used, maintaining full backward compatibility.
  3. 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.
  4. TXT ownership records - Each instance should have a unique --txt-owner-id to avoid conflicts in ownership tracking.
  5. 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