feat(source): support ttl annotation on pod (#5527)

* feat(source/pod): add support ttl annotation

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/pod): add support ttl annotation

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
This commit is contained in:
Ivan Ka 2025-06-20 05:50:51 +01:00 committed by GitHub
parent 36e3e53190
commit 17fa4b4e7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 168 additions and 31 deletions

View File

@ -31,20 +31,70 @@ Both examples result in the same value of 60 seconds TTL.
TTL must be a positive value.
## Providers
## TTL annotation support
- [x] AWS (Route53)
- [x] Azure
- [x] Cloudflare
- [x] DigitalOcean
- [x] DNSimple
- [x] Google
- [ ] InMemory
- [x] Linode
- [x] TransIP
- [x] RFC2136
> Note: For TTL annotations to work, the `external-dns.alpha.kubernetes.io/hostname` annotation must be set on the resource and be supported by the provider as well as the source.
PRs welcome!
### Providers
| Provider | Supported |
|:---------------|:---------:|
| `Akamai` | ✅ |
| `AlibabaCloud` | ✅ |
| `AWS` | ✅ |
| `AWSSD` | ✅ |
| `Azure` | ✅ |
| `Civo` | ❌ |
| `Cloudflare` | ✅ |
| `CoreDNS` | ❌ |
| `DigitalOcean` | ✅ |
| `DNSSimple` | ✅ |
| `Exoscale` | ✅ |
| `Gandi` | ✅ |
| `GoDaddy` | ✅ |
| `Google GCP` | ✅ |
| `InMemory` | ❌ |
| `Linode` | ❌ |
| `NS1` | ❌ |
| `OCI` | ✅ |
| `OVH` | ❌ |
| `PDNS` | ❌ |
| `PiHole` | ✅ |
| `Plural` | ❌ |
| `RFC2136` | ✅ |
| `Scaleway` | ✅ |
| `Transip` | ✅ |
| `Webhook` | ✅ |
### Sources
| Source | Supported |
|:-----------------------|:---------:|
| `ambassador-host` | ✅ |
| `cloudfoundry` | ❌ |
| `connector` | ❌ |
| `contour-httpproxy` | ✅ |
| `crd` | ❌ |
| `empty` | ❌ |
| `f5-transportserver` | ✅ |
| `f5-virtualserver` | ✅ |
| `fake` | ❌ |
| `gateway-grpcroute` | ✅ |
| `gateway-httproute` | ✅ |
| `gateway-tcproute` | ✅ |
| `gateway-tlsroute` | ✅ |
| `gateway-udproute` | ✅ |
| `gloo-proxy` | ✅ |
| `ingress` | ✅ |
| `istio-gateway` | ✅ |
| `istio-virtualservice` | ✅ |
| `kong-tcpingress` | ✅ |
| `node` | ✅ |
| `openshift-route` | ✅ |
| `pod` | ✅ |
| `service` | ✅ |
| `skipper-routegroup` | ✅ |
| `traefik-proxy` | ✅ |
## Notes
@ -89,3 +139,82 @@ The Linode Provider default TTL is used when the TTL is 0. The default is 24 hou
### TransIP Provider
The TransIP Provider minimal TTL is used when the TTL is 0. The minimal TTL is 60s.
## Use Cases for `external-dns.alpha.kubernetes.io/ttl` annotation
The `external-dns.alpha.kubernetes.io/ttl` annotation allows you to set a custom **TTL (Time To Live)** for DNS records managed by `external-dns`.
Use the `external-dns.alpha.kubernetes.io/tt` annotation to fine-tune DNS caching behavior per record, balancing between update frequency and performance.
This is useful in several real-world scenarios depending on how frequently DNS records are expected to change.
---
### Fast Failover for Critical Services
For services that must be highly available—like APIs, databases, or external load balancers—set a **low TTL** (e.g., 30 seconds) so DNS clients quickly update to new IPs during:
- Node failures
- Region failovers
- Blue/green deployments
```yaml
annotations:
external-dns.alpha.kubernetes.io/ttl: "30s"
```
---
### Long TTL for Static Services
If your services IP or endpoint rarely changes (e.g., static websites, internal dashboards), you can set a long TTL (e.g., 86400 seconds = 24 hours) to:
- Reduce DNS query load
- Improve cache performance
- Lower cost with some DNS providers
```yml
annotations:
external-dns.alpha.kubernetes.io/ttl: "24h"
```
---
### Canary or Experimental Services
Use a short TTL for services under test or experimentation to allow fast DNS propagation when making changes, allowing easy rollback and testing.
---
### Provider-Specific Optimization
Some DNS providers charge per query or have query rate limits. Adjusting the TTL lets you:
- Reduce costs
- Avoid throttling
- Manage DNS traffic load efficiently
---
### Regulatory or Contractual SLAs
Certain environments may require TTL values to align with:
- Regulatory guidelines
- Legacy system compatibility
- Contractual service-level agreements
---
### Autoscaling Node Pools in GCP (or Other Cloud Providers)
In environments like Google Cloud Platform (GCP) using private node IPs for DNS resolution, ExternalDNS may register node IPs with a default TTL of 300 seconds.
During autoscaling events (e.g., node addition/removal or upgrades), DNS records may remain stale for several minutes, causing traffic to be routed to non-existent nodes.
By using the TTL annotation you can:
- Reduce TTL to allow faster DNS propagation
- Ensure quicker routing updates when node IPs change
- Improve resiliency during frequent cluster topology changes
- Fine-grained TTL control helps avoid downtime or misrouting in dynamic, autoscaling environments.

View File

@ -212,6 +212,7 @@ type EndpointKey struct {
DNSName string
RecordType string
SetIdentifier string
RecordTTL TTL
}
// Endpoint is a high-level way of a connection between a service and an IP

View File

@ -129,7 +129,7 @@ func (ps *podSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error)
var endpoints []*endpoint.Endpoint
for key, targets := range endpointMap {
endpoints = append(endpoints, endpoint.NewEndpoint(key.DNSName, key.RecordType, targets...))
endpoints = append(endpoints, endpoint.NewEndpointWithTTL(key.DNSName, key.RecordType, key.RecordTTL, targets...))
}
return endpoints, nil
}
@ -153,9 +153,9 @@ func (ps *podSource) addInternalHostnameAnnotationEndpoints(endpointMap map[endp
domainList := annotations.SplitHostnameAnnotation(domainAnnotation)
for _, domain := range domainList {
if len(targets) == 0 {
addToEndpointMap(endpointMap, domain, suitableType(pod.Status.PodIP), pod.Status.PodIP)
addToEndpointMap(endpointMap, pod, domain, suitableType(pod.Status.PodIP), pod.Status.PodIP)
} else {
addTargetsToEndpointMap(endpointMap, targets, domain)
addTargetsToEndpointMap(endpointMap, pod, targets, domain)
}
}
}
@ -167,7 +167,7 @@ func (ps *podSource) addHostnameAnnotationEndpoints(endpointMap map[endpoint.End
if len(targets) == 0 {
ps.addPodNodeEndpointsToEndpointMap(endpointMap, pod, domainList)
} else {
addTargetsToEndpointMap(endpointMap, targets, domainList...)
addTargetsToEndpointMap(endpointMap, pod, targets, domainList...)
}
}
}
@ -177,7 +177,7 @@ func (ps *podSource) addKopsDNSControllerEndpoints(endpointMap map[endpoint.Endp
if domainAnnotation, ok := pod.Annotations[kopsDNSControllerInternalHostnameAnnotationKey]; ok {
domainList := annotations.SplitHostnameAnnotation(domainAnnotation)
for _, domain := range domainList {
addToEndpointMap(endpointMap, domain, suitableType(pod.Status.PodIP), pod.Status.PodIP)
addToEndpointMap(endpointMap, pod, domain, suitableType(pod.Status.PodIP), pod.Status.PodIP)
}
}
@ -192,9 +192,9 @@ func (ps *podSource) addPodSourceDomainEndpoints(endpointMap map[endpoint.Endpoi
if ps.podSourceDomain != "" {
domain := pod.Name + "." + ps.podSourceDomain
if len(targets) == 0 {
addToEndpointMap(endpointMap, domain, suitableType(pod.Status.PodIP), pod.Status.PodIP)
addToEndpointMap(endpointMap, pod, domain, suitableType(pod.Status.PodIP), pod.Status.PodIP)
}
addTargetsToEndpointMap(endpointMap, targets, domain)
addTargetsToEndpointMap(endpointMap, pod, targets, domain)
}
}
@ -209,7 +209,7 @@ func (ps *podSource) addPodNodeEndpointsToEndpointMap(endpointMap map[endpoint.E
recordType := suitableType(address.Address)
// IPv6 addresses are labeled as NodeInternalIP despite being usable externally as well.
if address.Type == corev1.NodeExternalIP || (address.Type == corev1.NodeInternalIP && recordType == endpoint.RecordTypeAAAA) {
addToEndpointMap(endpointMap, domain, recordType, address.Address)
addToEndpointMap(endpointMap, pod, domain, recordType, address.Address)
}
}
}
@ -231,6 +231,7 @@ func (ps *podSource) hostsFromTemplate(pod *corev1.Pod) (map[endpoint.EndpointKe
key := endpoint.EndpointKey{
DNSName: target,
RecordType: suitableType(address.IP),
RecordTTL: annotations.TTLFromAnnotations(pod.Annotations, fmt.Sprintf("pod/%s", pod.Name)),
}
result[key] = append(result[key], address.IP)
}
@ -239,18 +240,19 @@ func (ps *podSource) hostsFromTemplate(pod *corev1.Pod) (map[endpoint.EndpointKe
return result, nil
}
func addTargetsToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, targets []string, domainList ...string) {
func addTargetsToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, targets []string, domainList ...string) {
for _, domain := range domainList {
for _, target := range targets {
addToEndpointMap(endpointMap, domain, suitableType(target), target)
addToEndpointMap(endpointMap, pod, domain, suitableType(target), target)
}
}
}
func addToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, domain string, recordType string, address string) {
func addToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, domain string, recordType string, address string) {
key := endpoint.EndpointKey{
DNSName: domain,
RecordType: recordType,
RecordTTL: annotations.TTLFromAnnotations(pod.Annotations, fmt.Sprintf("pod/%s", pod.Name)),
}
if _, ok := endpointMap[key]; !ok {
endpointMap[key] = []string{}

View File

@ -302,8 +302,8 @@ func TestPodSource(t *testing.T) {
true,
"",
[]*endpoint.Endpoint{
{DNSName: "a.foo.example.org", Targets: endpoint.Targets{"54.10.11.1"}, RecordType: endpoint.RecordTypeA},
{DNSName: "a.foo.example.org", Targets: endpoint.Targets{"2001:DB8::1"}, RecordType: endpoint.RecordTypeAAAA},
{DNSName: "a.foo.example.org", Targets: endpoint.Targets{"54.10.11.1"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(5400)},
{DNSName: "a.foo.example.org", Targets: endpoint.Targets{"2001:DB8::1"}, RecordType: endpoint.RecordTypeAAAA, RecordTTL: endpoint.TTL(5400)},
{DNSName: "b.foo.example.org", Targets: endpoint.Targets{"54.10.11.2"}, RecordType: endpoint.RecordTypeA},
},
false,
@ -339,6 +339,7 @@ func TestPodSource(t *testing.T) {
Namespace: "kube-system",
Annotations: map[string]string{
hostnameAnnotationKey: "a.foo.example.org",
ttlAnnotationKey: "1h30m",
},
},
Spec: corev1.PodSpec{
@ -374,8 +375,8 @@ func TestPodSource(t *testing.T) {
true,
"",
[]*endpoint.Endpoint{
{DNSName: "a.foo.example.org", Targets: endpoint.Targets{"54.10.11.1"}, RecordType: endpoint.RecordTypeA},
{DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"10.0.1.1"}, RecordType: endpoint.RecordTypeA},
{DNSName: "a.foo.example.org", Targets: endpoint.Targets{"54.10.11.1"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(1)},
{DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"10.0.1.1"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(1)},
},
false,
nodesFixturesIPv4(),
@ -387,6 +388,7 @@ func TestPodSource(t *testing.T) {
Annotations: map[string]string{
internalHostnameAnnotationKey: "internal.a.foo.example.org",
hostnameAnnotationKey: "a.foo.example.org",
ttlAnnotationKey: "1s",
},
},
Spec: corev1.PodSpec{
@ -453,6 +455,7 @@ func TestPodSource(t *testing.T) {
Annotations: map[string]string{
internalHostnameAnnotationKey: "internal.a.foo.example.org",
hostnameAnnotationKey: "a.foo.example.org",
ttlAnnotationKey: "1s",
},
},
Spec: corev1.PodSpec{
@ -514,7 +517,7 @@ func TestPodSource(t *testing.T) {
false,
"example.org",
[]*endpoint.Endpoint{
{DNSName: "my-pod1.example.org", Targets: endpoint.Targets{"192.168.1.1"}, RecordType: endpoint.RecordTypeA},
{DNSName: "my-pod1.example.org", Targets: endpoint.Targets{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(60)},
{DNSName: "my-pod2.example.org", Targets: endpoint.Targets{"192.168.1.2"}, RecordType: endpoint.RecordTypeA},
},
false,
@ -524,7 +527,9 @@ func TestPodSource(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{
Name: "my-pod1",
Namespace: "kube-system",
Annotations: map[string]string{},
Annotations: map[string]string{
ttlAnnotationKey: "1m",
},
},
Spec: corev1.PodSpec{
HostNetwork: false,