Skip to content

Commit 9bb9723

Browse files
authored
fix(e2e): make instance metadata endpoint unreachable on kind nodes (#1352)
Since 2026-08-29 the e2e tests fail on CI regardless of the branch under test: the collector container of the daemonset collector does not become ready in time, the BeforeAll node fails and every remaining spec is skipped. c94a444 bought time by raising the startup probe to 120s and the test timeout to 150s. This commit adds a workaround at the network level. This commit also reverts the workarounds from an earlier commit: waitForCollectorToStart waits 60s again (instead of 150) and the daemonset collector is back to the default startup probe timeouts. A probe that ran the eight resource detectors of the daemonset collector, one at a time (run [33278618771](https://github.com/dash0hq/dash0-operator/actions/runs/33278618771)), singled out gcp: ``` eks 0.003s ecs 0.000s ec2 2.000s gcp 27.947s azure 2.000s aks 2.000s k8snode 0.004s system 0.000s all 27.972s ``` ec2, azure and aks stop at the configured timeout of 2s, the rest finish in milliseconds, and all eight together take no longer than gcp alone. A second probe replayed the steps of the gcp detector inside a pod (run 33307695809): ``` onGKE/metadata 12.824s dial tcp 169.254.169.254:80: i/o timeout onGCE/metadata 13.928s dial tcp 169.254.169.254:80: i/o timeout metadata.OnGCE 0.003s false tcp-dial 10.001s i/o timeout (reference) dns-lookup 0.003s no such host (reference) CloudPlatform() 26.519s UnknownPlatform ``` DNS is healthy and answers in 3ms, so name resolution was never involved. The IP 169.254.169.254 is blackholed on the runner: the dials end in "i/o timeout", not "connection refused", so the packets are dropped rather than rejected. The delay comes from CloudPlatform() of github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp. Two of the platform checks query the metadata server, and both pass context.TODO(), which carries no deadline, so the timeout of the resourcedetection processor cannot stop them: onGKE() reads KUBERNETES_SERVICE_HOST, which is always set inside a pod, and then calls InstanceAttributeValueWithContext(context.TODO(), "cluster-location") onGCE() calls GetWithContext(context.TODO(), "instance/machine-type") metadata.NewClient(nil) returns the package default client, which dials with a timeout of 2s and retries up to five times, hence about 13s per call. The daemonset collector runs the detectors once per pipeline and has three, which is about 80s against a startup probe that grants 90s. This is a known upstream defect, tracked in GoogleCloudPlatform/opentelemetry-operations-go#1026, and not fixed yet. The workaround, on every kind node: * Add an unreachable route for the endpoint that makes connect() fail immediately with EHOSTUNREACH, which the metadata client neither retries nor treats as temporary, so the detector gives up immediately. * Write a sysctl `net.ipv4.icmp_ratelimit=0`; without it, only the first connections fail fast, then Linux throttles the generation of ICMP errors per destination via net.ipv4.icmp_ratelimit, one message per second by default, so every connection after the first gets no ICMP at all and waits for the retransmission of its SYN instead. The retry burst of the detector is exactly the pattern that runs into this, which is why isolated calls end at 0s while the detector as a whole would still > 18s. An iptables REJECT rule would have been the worse choice: both --reject-with tcp-reset and the default icmp-port-unreachable produce ECONNREFUSED, and syscallRetryable in retry_linux.go of cloud.google.com/go/compute/metadata retries exactly ECONNRESET and ECONNREFUSED, so the five retries would still run and only the dial timeouts would be saved. Disabling the rate limit on every node should make all of the calls fail immediately rather than only the first few. There is still an unexplained 1s delay for every consecutive call after the first call, e.g. a total lag of ~2 seconds. But since that is coincidentally roughly the same as the 2 second timeout that is configured for the resourcedetection processor, it is acceptable. Nothing in the kind cluster needs the instance metadata endpoint, so making it unreachable costs nothing. Note that this only repairs CI. Any cluster that blackholes the link-local range still pays about 80s of collector startup, and the e2e suite no longer covers that case.
1 parent 02177de commit 9bb9723

4 files changed

Lines changed: 101 additions & 10 deletions

File tree

.github/workflows/e2e.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ jobs:
128128
echo "All pods:"
129129
kubectl get pods -A
130130
131+
# Workaround for resourcedetection bug in the gcp detector that makes the collector slow to become ready.
132+
# https://github.com/GoogleCloudPlatform/opentelemetry-operations-go/issues/1026
133+
- name: Make the instance metadata endpoint unreachable
134+
run: |
135+
./.github/workflows/e2e/add-instance-metadata-unreachable-route.sh
136+
131137
- name: Verify registry is running
132138
run: |
133139
echo "Checking registry container..."
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env bash
2+
3+
# SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
# Adds an unreachable route for the cloud instance metadata endpoint to every node of the kind cluster.
7+
#
8+
# The CI runner drops the packets to 169.254.169.254 rather than rejecting them. The gcp resource detector of the
9+
# collector queries that address twice while it starts, in onGKE() and in onGCE() of
10+
# github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp, and both calls pass context.TODO(), which
11+
# carries no deadline. See also: https://github.com/GoogleCloudPlatform/opentelemetry-operations-go/issues/1026.
12+
# The timeout of the resourcedetection processor therefore cannot stop them. Each call runs into the dial timeout of
13+
# 2s five times, which costs about 13s, so the collector needs about 80s to become ready instead of the 6s it needs when
14+
# the endpoint answers.
15+
#
16+
# An unreachable route makes connect() fail at once with EHOSTUNREACH. The metadata client neither retries that error
17+
# nor treats it as temporary, so the detector gives up immediately.
18+
#
19+
# The route on its own is not enough, though. Linux throttles the generation of ICMP errors per destination via
20+
# net.ipv4.icmp_ratelimit, one message per second by default. The first connections get their ICMP host unreachable at
21+
# once, the ones after that get nothing and fall back to the retransmission of the SYN, which is why a measurement of
22+
# the route alone showed the first two calls at 0s but the detector as a whole still at 18s, down from 28s. The rate
23+
# limit is therefore disabled as well, which is what makes every call fail immediately rather than only the first few.
24+
#
25+
# Note: an iptables REJECT rule is the worse choice here. Both --reject-with tcp-reset and the default
26+
# icmp-port-unreachable produce ECONNREFUSED, and syscallRetryable in retry_linux.go of
27+
# cloud.google.com/go/compute/metadata retries exactly ECONNRESET and ECONNREFUSED. The five retries would still run,
28+
# only the dial timeouts would be saved, not the backoff between them.
29+
#
30+
# Nothing in the kind cluster needs the instance metadata endpoint, so making it unreachable costs nothing. This works
31+
# around the CI environment, it does not fix the underlying problem, which is that the gcp detector discards the
32+
# deadline it is given, see https://github.com/GoogleCloudPlatform/opentelemetry-operations-go/issues/1026.
33+
34+
set -euo pipefail
35+
36+
metadata_ip=169.254.169.254
37+
38+
add_route() {
39+
local node=$1
40+
docker exec "$node" ip route replace unreachable "$metadata_ip/32"
41+
}
42+
43+
# Without this, only the first ICMP error per second reaches the caller and every connection after that waits for the
44+
# retransmission of its SYN instead of failing at once.
45+
disable_icmp_rate_limit() {
46+
local node=$1
47+
docker exec "$node" sysctl --quiet --write net.ipv4.icmp_ratelimit=0
48+
}
49+
50+
# Verifies the two settings rather than the latency they produce. Measuring the latency from the node would prove
51+
# nothing: a request that starts on the node runs into the unreachable route during the route lookup and fails locally
52+
# in microseconds, without an ICMP packet ever being generated, whether the rate limit is in place or not. Only traffic
53+
# that the node forwards, which means traffic from a pod, makes the node generate the ICMP error that the rate limit
54+
# applies to. The latency that matters is therefore only measurable from a pod, and it is what the gcp detector probe
55+
# measures.
56+
verify_settings() {
57+
local node=$1
58+
59+
local route
60+
route=$(docker exec "$node" ip route show | grep -F "unreachable $metadata_ip" || true)
61+
if [[ -z $route ]]; then
62+
echo "error: node $node has no unreachable route for $metadata_ip."
63+
exit 1
64+
fi
65+
66+
local rate_limit
67+
rate_limit=$(docker exec "$node" sysctl --values net.ipv4.icmp_ratelimit)
68+
if [[ $rate_limit != 0 ]]; then
69+
echo "error: node $node still limits the rate of ICMP errors, net.ipv4.icmp_ratelimit=$rate_limit."
70+
exit 1
71+
fi
72+
73+
printf ' %-34s %s, net.ipv4.icmp_ratelimit=%s\n' "$node" "$route" "$rate_limit"
74+
}
75+
76+
main() {
77+
local kind_cluster="${DASH0_KIND_CLUSTER:?DASH0_KIND_CLUSTER needs to be set}"
78+
79+
local nodes
80+
nodes=$(docker ps --filter "label=io.x-k8s.kind.cluster=$kind_cluster" --format '{{.Names}}')
81+
if [[ -z $nodes ]]; then
82+
echo "error: no running node container of cluster $kind_cluster found."
83+
exit 1
84+
fi
85+
86+
local node
87+
for node in $nodes; do
88+
add_route "$node"
89+
disable_icmp_rate_limit "$node"
90+
verify_settings "$node"
91+
done
92+
}
93+
94+
main "$@"

test/e2e/collector.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,7 @@ func waitForCollectorToStart(operatorNamespace string, operatorHelmChart string)
125125
))).To(Succeed())
126126
}
127127

128-
// The collector's resourcedetection processor queries the cloud instance metadata endpoints once per pipeline on
129-
// startup. On a machine where these endpoints neither answer nor refuse the connection, each of these runs takes
130-
// about 25 seconds instead of the configured two seconds, so the daemonset collector needs about 80 seconds to
131-
// become ready. See also the startup probe that executeOperatorHelmChart grants it.
132-
Eventually(verifyCollectorIsUp, 150*time.Second, time.Second).Should(Succeed())
128+
Eventually(verifyCollectorIsUp, 60*time.Second, time.Second).Should(Succeed())
133129

134130
verifyCollectorHasOwnerReference(operatorNamespace, operatorHelmChart)
135131
}

test/e2e/operator.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,6 @@ func executeOperatorHelmChart(
254254
arguments = append(arguments, "--create-namespace")
255255
}
256256
arguments = append(arguments, "--set", "operator.developmentMode=true")
257-
// The collector's resourcedetection processor queries the cloud instance metadata endpoints once per pipeline on
258-
// startup. On a machine where these endpoints neither answer nor refuse the connection, each of these runs takes
259-
// about 25 seconds instead of the configured two seconds, which exceeds the 90 seconds that the default startup
260-
// probe grants the daemonset collector. 60 failures at a period of two seconds grant it 120 seconds.
261-
arguments = append(arguments, "--set", "operator.collectors.daemonSetProbes.startup.failureThreshold=60")
262257
if images != nil {
263258
arguments = addHelmParametersForImages(arguments, *images)
264259
}

0 commit comments

Comments
 (0)