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

275 lines
7.2 KiB
Markdown

# 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:**
```bash
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:**
```bash
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
```yaml
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:**
```yaml
annotationPrefix: "internal.company.io/"
provider:
name: aws
aws:
zoneType: private
domainFilters:
- internal.company.com
txtOwnerId: internal-dns
sources:
- service
- ingress
```
**values-external.yaml:**
```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:**
```bash
# 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)
```yaml
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:**
```bash
# 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
```yaml
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:**
```bash
# 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:
```yaml
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:
```yaml
# ✅ 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 `/`:
```bash
# ✅ 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:
```yaml
# If using custom prefix
custom.io/hostname: example.com
custom.io/cloudflare-proxied: "true"
custom.io/ttl: "60"
```
## See Also
- [Configuration Precedence](configuration-precedence.md) - Understanding how external-dns processes configuration
- [FAQ](../faq.md) - Frequently asked questions
- [AWS Provider](../tutorials/aws.md) - AWS Route53 configuration
- [Cloudflare Provider](../tutorials/cloudflare.md) - Cloudflare configuration