diff --git a/docs/advanced/ttl.md b/docs/advanced/ttl.md index 5cc837e55..4dff4e6f3 100644 --- a/docs/advanced/ttl.md +++ b/docs/advanced/ttl.md @@ -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 service’s 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. diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go index 5dcdd0089..1c972d061 100644 --- a/endpoint/endpoint.go +++ b/endpoint/endpoint.go @@ -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 diff --git a/source/pod.go b/source/pod.go index 4ac0c0d5d..9a52653c0 100644 --- a/source/pod.go +++ b/source/pod.go @@ -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{} diff --git a/source/pod_test.go b/source/pod_test.go index 65dedfa50..40592279e 100644 --- a/source/pod_test.go +++ b/source/pod_test.go @@ -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, @@ -522,9 +525,11 @@ func TestPodSource(t *testing.T) { []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ - Name: "my-pod1", - Namespace: "kube-system", - Annotations: map[string]string{}, + Name: "my-pod1", + Namespace: "kube-system", + Annotations: map[string]string{ + ttlAnnotationKey: "1m", + }, }, Spec: corev1.PodSpec{ HostNetwork: false,