Skip to content

Commit c1394dc

Browse files
alexreinkingclaude
andauthored
Add access logging + host TCP sampler to diagnose download failures (#362)
Halide CI intermittently fails to reach pypi.halide-lang.org with uv "client error (Connect): operation timed out" (3 retries, ~47s). The appliance had no request-level visibility, so failures couldn't be localized. This adds two complementary, low-overhead data sources: - Caddy JSON access log for the pypi site -> dedicated caddy-logs volume, rotated, kept off the ACME/TLS log stream. Distinguishes a truncated download (status 200, size < content-length) from a request that never arrived (absent), and shows whether the server was serving other clients during an incident. - monitor/sample.sh: a network_mode:host sidecar (netmon) that samples host TCP stack counters every 15s (accept-queue drops, conntrack, SYN_RECV, retransmits, load) to net-samples.tsv. Tells us whether lost SYNs were dropped on this host vs upstream of it. - Caddy global `metrics` on 127.0.0.1:2019 for on-demand in-flight snapshots. Correlating these against a live failure showed the server serving identical requests from other runners in the same window while the failing runner's request never reached Caddy and every host drop counter stayed flat -- i.e. the loss is upstream of the host (network path / OpenStack NAT), not the server software. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 96a9587 commit c1394dc

3 files changed

Lines changed: 136 additions & 0 deletions

File tree

caddy/Caddyfile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,29 @@
1+
{
2+
# Expose Prometheus metrics on the admin endpoint (localhost:2019/metrics
3+
# inside the container) so we can snapshot in-flight requests and accepted
4+
# vs. active connections on demand while diagnosing download failures:
5+
# docker compose exec caddy wget -qO- 127.0.0.1:2019/metrics | grep caddy_
6+
# (use 127.0.0.1, not localhost: admin binds IPv4 only and wget prefers ::1)
7+
metrics
8+
}
9+
110
pypi.halide-lang.org {
11+
# Access log for every request that reaches Caddy. This is the key signal
12+
# for the intermittent wheel-download failures: a truncated download shows
13+
# up as status 200 with a `size` smaller than the wheel's content-length,
14+
# whereas a client-side connect timeout never appears here at all (which
15+
# would point at the network path rather than the server). Written to a
16+
# dedicated volume so it is not interleaved with Caddy's TLS/ACME logs and
17+
# is not rolled off by Docker's log driver.
18+
log {
19+
output file /var/log/caddy/access.log {
20+
roll_size 100MiB
21+
roll_keep 20
22+
roll_keep_for 2160h
23+
}
24+
format json
25+
}
26+
227
# Serve the (large, static) wheel files directly from disk so that
328
# downloads don't tie up pypiserver's small Waitress thread pool. Only
429
# the generated indexes and uploads (POST) still hit the Python server.

docker-compose.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,33 @@ services:
6868
- ${HALIDE_BB_PYPI_PACKAGES_DIR:-./data/packages}:/wheels:ro
6969
- caddy-data:/data
7070
- caddy-config:/config
71+
- caddy-logs:/var/log/caddy
72+
logging:
73+
driver: local
74+
restart: unless-stopped
75+
76+
# Host-level TCP/network sampler for diagnosing the intermittent
77+
# wheel-download connect timeouts. network_mode: host so the /proc/net
78+
# counters it reads reflect the host stack that terminates inbound :443,
79+
# not this container's own namespace. See monitor/sample.sh.
80+
netmon:
81+
image: alpine:3.20
82+
network_mode: host
83+
command: sh /monitor/sample.sh
84+
volumes:
85+
- ./monitor:/monitor:ro
86+
- netmon-logs:/var/log/netmon
87+
environment:
88+
SAMPLE_INTERVAL: "15"
7189
logging:
7290
driver: local
7391
restart: unless-stopped
7492

7593
volumes:
7694
caddy-data:
7795
caddy-config:
96+
caddy-logs:
97+
netmon-logs:
7898
db-data:
7999

80100
secrets:

monitor/sample.sh

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/bin/sh
2+
# Host-level network sampler for diagnosing the intermittent wheel-download
3+
# connect timeouts (uv reports "client error (Connect): operation timed out").
4+
#
5+
# Runs as a docker-compose sidecar with network_mode: host, so the /proc/net
6+
# counters below reflect the *host* TCP stack -- where inbound :443 SYNs are
7+
# accepted and NATed to the caddy container -- rather than the sidecar's own
8+
# namespace. It only reads /proc, so no extra tooling is needed.
9+
#
10+
# Emits one TSV row per interval. The failure is a connect timeout, which
11+
# never reaches Caddy and so leaves no trace in Caddy's access log; these
12+
# host counters are how we tell *where* the connection is being lost:
13+
#
14+
# * listen_overflows / listen_drops / reqq_full_drop climbing, or syn_recv
15+
# spiking, or conntrack near conntrack_max, or load1 high -> the loss is
16+
# on THIS host (accept-queue overflow / table exhaustion / CPU stall), and
17+
# we tune the host or Caddy accordingly.
18+
# * all of the above flat and low while CI reports failures -> the SYNs are
19+
# being dropped UPSTREAM (OpenStack floating-IP NAT / network path) and
20+
# never reached us; the fix is an infra/network escalation, not the server.
21+
#
22+
# Counters from /proc/net/{snmp,netstat} are cumulative since boot -- diff
23+
# adjacent rows (by epoch) during analysis to get per-interval rates.
24+
#
25+
# Caveat: with Docker's userland-proxy enabled (the default), host:443 has a
26+
# docker-proxy listener whose accept-queue drops show up here as
27+
# listen_drops/overflows; with it disabled (pure iptables DNAT) accept-queue
28+
# pressure instead lands in the caddy container's namespace, so cross-check
29+
# against Caddy's caddy_http_requests_in_flight metric when interpreting.
30+
31+
set -u
32+
33+
INTERVAL="${SAMPLE_INTERVAL:-15}"
34+
OUT="${SAMPLE_OUT:-/var/log/netmon/net-samples.tsv}"
35+
MAX_BYTES="${SAMPLE_MAX_BYTES:-104857600}" # rotate at 100 MiB, keep one prior
36+
37+
mkdir -p "$(dirname "$OUT")"
38+
39+
# Pull one named field out of /proc/net/snmp or /proc/net/netstat. These files
40+
# store, per protocol label, a header row of field names followed by a values
41+
# row; map names->columns from the header, then print the matching value.
42+
field() {
43+
# $1=file $2=label (e.g. Tcp, TcpExt) $3=field name
44+
awk -v L="$2:" -v F="$3" '
45+
$1==L {
46+
if (!(L in seen)) { for (i=2;i<=NF;i++) col[$i]=i; seen[L]=1; next }
47+
print $(col[F])
48+
}' "$1"
49+
}
50+
51+
if [ ! -f "$OUT" ]; then
52+
printf 'epoch\tiso\tload1\tconntrack\tconntrack_max\tcurr_estab\tsyn_recv\tactive_opens\tpassive_opens\tattempt_fails\tretrans_segs\tout_rsts\tlisten_overflows\tlisten_drops\treqq_full_drop\tsyncookies_sent\ttcp_timeouts\tsyn_retrans\n' > "$OUT"
53+
fi
54+
55+
while :; do
56+
epoch=$(date +%s)
57+
iso=$(date -u +%Y-%m-%dT%H:%M:%SZ)
58+
load1=$(cut -d' ' -f1 /proc/loadavg)
59+
ct=$(cat /proc/sys/net/netfilter/nf_conntrack_count 2>/dev/null || echo -1)
60+
ctmax=$(cat /proc/sys/net/netfilter/nf_conntrack_max 2>/dev/null || echo -1)
61+
62+
curr_estab=$(field /proc/net/snmp Tcp CurrEstab)
63+
active_opens=$(field /proc/net/snmp Tcp ActiveOpens)
64+
passive_opens=$(field /proc/net/snmp Tcp PassiveOpens)
65+
attempt_fails=$(field /proc/net/snmp Tcp AttemptFails)
66+
retrans_segs=$(field /proc/net/snmp Tcp RetransSegs)
67+
out_rsts=$(field /proc/net/snmp Tcp OutRsts)
68+
69+
listen_overflows=$(field /proc/net/netstat TcpExt ListenOverflows)
70+
listen_drops=$(field /proc/net/netstat TcpExt ListenDrops)
71+
reqq_full_drop=$(field /proc/net/netstat TcpExt TCPReqQFullDrop)
72+
syncookies_sent=$(field /proc/net/netstat TcpExt SyncookiesSent)
73+
tcp_timeouts=$(field /proc/net/netstat TcpExt TCPTimeouts)
74+
syn_retrans=$(field /proc/net/netstat TcpExt TCPSynRetrans)
75+
76+
# SYN_RECV (TCP state 0x03) across IPv4+IPv6: half-open / accept-queue depth.
77+
syn_recv=$(cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | awk '$4=="03"' | wc -l | tr -d ' ')
78+
79+
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
80+
"$epoch" "$iso" "$load1" "$ct" "$ctmax" "$curr_estab" "$syn_recv" \
81+
"$active_opens" "$passive_opens" "$attempt_fails" "$retrans_segs" "$out_rsts" \
82+
"$listen_overflows" "$listen_drops" "$reqq_full_drop" "$syncookies_sent" \
83+
"$tcp_timeouts" "$syn_retrans" >> "$OUT"
84+
85+
sz=$(wc -c < "$OUT" 2>/dev/null || echo 0)
86+
if [ "$sz" -gt "$MAX_BYTES" ]; then
87+
mv "$OUT" "$OUT.1"
88+
fi
89+
90+
sleep "$INTERVAL"
91+
done

0 commit comments

Comments
 (0)