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. TTL must be a positive value.
## Providers ## TTL annotation support
- [x] AWS (Route53) > 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.
- [x] Azure
- [x] Cloudflare
- [x] DigitalOcean
- [x] DNSimple
- [x] Google
- [ ] InMemory
- [x] Linode
- [x] TransIP
- [x] RFC2136
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 ## Notes
@ -89,3 +139,82 @@ The Linode Provider default TTL is used when the TTL is 0. The default is 24 hou
### TransIP Provider ### TransIP Provider
The TransIP Provider minimal TTL is used when the TTL is 0. The minimal TTL is 60s. 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 DNSName string
RecordType string RecordType string
SetIdentifier string SetIdentifier string
RecordTTL TTL
} }
// Endpoint is a high-level way of a connection between a service and an IP // 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 var endpoints []*endpoint.Endpoint
for key, targets := range endpointMap { 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 return endpoints, nil
} }
@ -153,9 +153,9 @@ func (ps *podSource) addInternalHostnameAnnotationEndpoints(endpointMap map[endp
domainList := annotations.SplitHostnameAnnotation(domainAnnotation) domainList := annotations.SplitHostnameAnnotation(domainAnnotation)
for _, domain := range domainList { for _, domain := range domainList {
if len(targets) == 0 { 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 { } 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 { if len(targets) == 0 {
ps.addPodNodeEndpointsToEndpointMap(endpointMap, pod, domainList) ps.addPodNodeEndpointsToEndpointMap(endpointMap, pod, domainList)
} else { } 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 { if domainAnnotation, ok := pod.Annotations[kopsDNSControllerInternalHostnameAnnotationKey]; ok {
domainList := annotations.SplitHostnameAnnotation(domainAnnotation) domainList := annotations.SplitHostnameAnnotation(domainAnnotation)
for _, domain := range domainList { 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 != "" { if ps.podSourceDomain != "" {
domain := pod.Name + "." + ps.podSourceDomain domain := pod.Name + "." + ps.podSourceDomain
if len(targets) == 0 { 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) recordType := suitableType(address.Address)
// IPv6 addresses are labeled as NodeInternalIP despite being usable externally as well. // IPv6 addresses are labeled as NodeInternalIP despite being usable externally as well.
if address.Type == corev1.NodeExternalIP || (address.Type == corev1.NodeInternalIP && recordType == endpoint.RecordTypeAAAA) { 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{ key := endpoint.EndpointKey{
DNSName: target, DNSName: target,
RecordType: suitableType(address.IP), RecordType: suitableType(address.IP),
RecordTTL: annotations.TTLFromAnnotations(pod.Annotations, fmt.Sprintf("pod/%s", pod.Name)),
} }
result[key] = append(result[key], address.IP) result[key] = append(result[key], address.IP)
} }
@ -239,18 +240,19 @@ func (ps *podSource) hostsFromTemplate(pod *corev1.Pod) (map[endpoint.EndpointKe
return result, nil 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 _, domain := range domainList {
for _, target := range targets { 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{ key := endpoint.EndpointKey{
DNSName: domain, DNSName: domain,
RecordType: recordType, RecordType: recordType,
RecordTTL: annotations.TTLFromAnnotations(pod.Annotations, fmt.Sprintf("pod/%s", pod.Name)),
} }
if _, ok := endpointMap[key]; !ok { if _, ok := endpointMap[key]; !ok {
endpointMap[key] = []string{} endpointMap[key] = []string{}

View File

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