Files
Josh Siviour 8e11908dfb fix: make the EKS control plane's disk latency observable, and give it room to run (#1009)
* fix: report CPU and memory pressure in the EKS control-plane health diagnostic

An unhealthy control plane reported readyz, etcd reachability and etcd-disk
free space. When etcd goes unreachable with disk:ok and no OOM kill, that set
cannot distinguish a starved guest from a genuine etcd fault, so add the
loadavg and MemAvailable that separate them.

An empty /readyz body also rendered as readyz:[none], which reads as "no
subcheck failed" when it means the probe never answered. Report that as
unreachable instead.

* fix: sample the node's load, not just the runner host's

Every runner agent runs on one host while their nodes sit on several, so the
existing sample measured the runner's host and was labelled as the hypervisor.
It happens to coincide when the node is on that same host and misleads
otherwise. Relabel it and add a node-side sample, which is where the guests are
actually scheduled and so where a starved guest shows up.

* feat: build the mkosi EKS image in CI and give its control plane telemetry

The Ubuntu/systemd EKS image is what customers run, and nothing built it: the
composition existed in mkosi-build.sh's image_profiles table but had no caller,
so a change that broke it was found by hand or not at all. Cell-25 builds the
Alpine image, which leaves the published one with no gate. Add a Makefile target
beside the Alpine one and a job in the existing path-filtered image workflow.

The guest also collected nothing. k3s has served embedded-etcd metrics on
127.0.0.1:2381 since the daemon started setting etcd-expose-metrics, and no
collector ever read them, so an etcd stall was only ever visible as
"etcd:unreachable" in the state report — which cannot separate a slow disk from
a dead one. Ship otelcol-contrib pinned to the version the cluster hosts already
run, scraping etcd and guest host metrics.

The unit is gated on ConditionPathExists for its endpoint file rather than
started unconditionally: whether the control-plane guest can reach the sink from
the isolated CP VPC is unverified, and a collector with nowhere to export is a
restart loop, not telemetry.

* fix: report etcd fsync latency in the control-plane health diagnostic

disk:ok measures free space, which says nothing about how long a write takes.
etcd stalls on fsync latency long before it runs out of room, and this guest's
disk is an EBS volume served by viperblock, so a fsync is a network round trip.
An unhealthy control plane reporting healthy memory and disk was uninterpretable
without it.

Derived from the histogram's sum and count rather than a bucket percentile: two
greps against an interpolation this script has no business doing, and the
difference between a healthy single-digit reading and a stalled one is an order
of magnitude, not a tail. Rides the state report's existing gateway path, so it
needs no route the CP VPC does not already grant, and lands on both images
because the script is shared.

The scrape's failure is guarded explicitly. Under set -e a bare assignment from
a curl against a port nothing answers aborts the whole diagnosis, dropping the
reason field exactly when it is wanted.

* feat: publish etcd fsync on every control-plane report, not only failing ones

A figure that appears only once the apiserver is already unhealthy has no
baseline to be read against, which is the same gap a passing cell that uploads
no journal leaves: the number is unanchored when it finally arrives. Move the
scrape into its own helper, emit it on healthy reports too, and carry it as a
typed field rather than only inside the reason string.

Slowness is logged, never gated. observe() decides cluster health, and a working
cluster on a slow disk is working — returning an issue there would fail
customers' clusters on a latency threshold. The warning is latched on the
transition instead, because the control plane republishes on its own timer and a
line every interval is how a real signal gets tuned out.

An absent sample is not a fast one. Too few fsyncs to average, an etcd serving
no metrics and an older AMI all arrive as nil, and the field is omitted rather
than defaulted so none of them can be read as healthy. The minimum sample count
is the same lesson as the slow-shard floor: a mean over three fsyncs on a
just-started etcd is a number with no meaning.

* fix: log an etcd fsync baseline and name the two no-sample cases apart

A healthy cluster logs no health lines, so the fsync figure reached the
daemon on every report and was never surfaced — a passing cell left nothing
for a later slow reading to be compared against. Log the first known sample.

Split the "unknown" sentinel into nometrics (etcd served none) and warmup
(too few fsyncs to average): different faults, different fixes. The payload
guard now tests for a number rather than the sentinel word, so a future
sentinel cannot leak into the JSON as a bare value.

* feat: measure fdatasync latency in the diskperf gate

The two pinned profiles are buffered-bypassing throughput jobs at queue
depth 32. Neither measures the cost of making a write durable, which is what
a consensus datastore pays per raft entry and stalls on long before it runs
short of bandwidth. A 5ms barrier on bare metal went unseen as a result.

syncwrite-4k is the etcd-shaped profile: sequential 4k, queue depth 1,
fdatasync every write, buffered psync so the sync has a page cache to flush.
fio reports the sync distribution apart from the write stream, so the parser
reads it separately — gating on write clat would score the time to reach the
cache and call it durability.

No figures captured on this hardware yet, so the baseline entry is zero and
the job records without gating.

* fix: use the real otelcol checksums asset name in the EKS server image

The checksums file is named for the release repo and the distribution and
carries no version, unlike the tarball beside it. The guessed name 404'd and
took the image build with it.

* fix: extract otelcol as root in the EKS server image

The upstream tarball records its build uid (runner/docker, 1001:118), which
does not exist in the image. tar running as root tries to restore it and
fails outright rather than degrading, taking the build with it.

---------

Co-authored-by: Ben Duncan <5178243+benduncan@users.noreply.github.com>
2026-09-07 21:53:41 +10:00

224 lines
7.6 KiB
Go

//go:build e2e
package diskperf
import (
"strings"
"testing"
)
// sampleFio is a trimmed fio JSON document with the fields the gate reads.
const sampleFio = `{
"fio version": "fio-3.36",
"jobs": [
{
"jobname": "randwrite-4k",
"read": {"io_bytes": 0, "bw_bytes": 0, "iops": 0.0,
"clat_ns": {"max": 0, "mean": 0, "percentile": {}}},
"write": {"io_bytes": 17179869184, "bw_bytes": 38000000, "iops": 9280.5,
"clat_ns": {"max": 342000000, "mean": 3400000, "percentile": {"99.900000": 93000000}}}
}
]
}`
func TestParseFioReadsNamedJob(t *testing.T) {
res, err := parseFio("randwrite-4k", sampleFio)
if err != nil {
t.Fatalf("parseFio: %v", err)
}
if got := res.Aggregate.Write.IOPS; got != 9280.5 {
t.Errorf("write IOPS = %v, want 9280.5", got)
}
if got := res.Aggregate.Write.p999Ms(); got != 93 {
t.Errorf("write p99.9 = %v ms, want 93", got)
}
if got := res.Aggregate.Write.maxMs(); got != 342 {
t.Errorf("write max = %v ms, want 342", got)
}
if res.Report.Version != "fio-3.36" {
t.Errorf("fio version = %q, want fio-3.36", res.Report.Version)
}
}
// fio prefixes its JSON with progress output on some builds, so the object is
// located by its opening brace rather than assumed to start at byte zero.
func TestParseFioSkipsLeadingNoise(t *testing.T) {
if _, err := parseFio("randwrite-4k", "Jobs: 4 (f=4)\n"+sampleFio); err != nil {
t.Fatalf("parseFio with leading noise: %v", err)
}
}
// A job that ran but moved nothing must not be reported as a result: its
// numbers describe no workload, and comparing them against a baseline would
// manufacture either a pass or a regression from no evidence.
func TestParseFioRejectsEmptyJob(t *testing.T) {
empty := strings.ReplaceAll(sampleFio, `"io_bytes": 17179869184`, `"io_bytes": 0`)
empty = strings.ReplaceAll(empty, `"iops": 9280.5`, `"iops": 0`)
_, err := parseFio("randwrite-4k", empty)
if err == nil {
t.Fatal("parseFio accepted a job that moved no bytes")
}
if !strings.Contains(err.Error(), "moved no bytes") {
t.Errorf("error does not name the cause: %v", err)
}
}
func TestParseFioRejectsMissingJob(t *testing.T) {
if _, err := parseFio("randread-4k", sampleFio); err == nil {
t.Fatal("parseFio accepted output with no matching job name")
}
}
func TestParseFioRejectsNonJSON(t *testing.T) {
if _, err := parseFio("randwrite-4k", "fio: engine libaio not loadable"); err == nil {
t.Fatal("parseFio accepted output containing no JSON object")
}
}
func TestMedian(t *testing.T) {
for _, tc := range []struct {
name string
in []float64
want float64
}{
{"empty", nil, 0},
{"single", []float64{7}, 7},
{"odd is the middle value", []float64{9, 1, 5}, 5},
{"even averages the two central", []float64{1, 2, 3, 4}, 2.5},
} {
t.Run(tc.name, func(t *testing.T) {
if got := median(tc.in); got != tc.want {
t.Errorf("median(%v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
// median must not reorder its input: callers hold the per-repetition slices and
// an in-place sort would silently reassociate metrics across repetitions.
func TestMedianDoesNotMutateInput(t *testing.T) {
in := []float64{3, 1, 2}
median(in)
if in[0] != 3 || in[1] != 1 || in[2] != 2 {
t.Errorf("median reordered its input: %v", in)
}
}
// The device has to be large enough for every job's region, since
// offset_increment gives each job a disjoint slice rather than sharing one.
func TestJobCommandCoversTheWholeWorkingSet(t *testing.T) {
for _, j := range gateJobs {
cmd := j.command("vdc", "/tmp/out.json")
for _, want := range []string{"--filename=/dev/vdc", "--offset_increment=", "--output-format=json"} {
if !strings.Contains(cmd, want) {
t.Errorf("job %q command is missing %q:\n%s", j.Name, want, cmd)
}
}
if j.SizeMiB > 0 {
continue
}
if !strings.Contains(cmd, "--direct=1") {
t.Errorf("job %q is a throughput profile and must bypass the page cache:\n%s", j.Name, cmd)
}
if j.workingSetGiB() != j.SizeGiB*j.NumJobs {
t.Errorf("job %q working set %d does not match %d jobs x %d GiB", j.Name, j.workingSetGiB(), j.NumJobs, j.SizeGiB)
}
}
}
// A durability job measures the wrong thing under direct=1 or libaio: the point
// is the cost of making a buffered write durable, which is what etcd pays per
// raft entry. Guards the combination rather than the flag.
func TestFdatasyncJobIsBufferedAndSynchronous(t *testing.T) {
var found int
for _, j := range gateJobs {
if j.Fdatasync == 0 {
cmd := j.command("vdc", "/tmp/out.json")
if strings.Contains(cmd, "--fdatasync=") {
t.Errorf("job %q carries fdatasync without asking for it:\n%s", j.Name, cmd)
}
continue
}
found++
cmd := j.command("vdc", "/tmp/out.json")
for _, want := range []string{"--fdatasync=1", "--ioengine=psync", "--lat_percentiles=1"} {
if !strings.Contains(cmd, want) {
t.Errorf("job %q is missing %q:\n%s", j.Name, want, cmd)
}
}
if strings.Contains(cmd, "--direct=1") {
t.Errorf("job %q uses direct=1, which bypasses the cache the fdatasync exists to flush:\n%s", j.Name, cmd)
}
if j.IODepth != 1 {
t.Errorf("job %q runs at qd%d: a queue hides the per-commit latency being measured", j.Name, j.IODepth)
}
}
if found == 0 {
t.Error("no fdatasync job in the profile set — durability latency is unmeasured")
}
}
// The sync distribution is reported apart from the write stream, so a job that
// parses only write clat records the time to reach the page cache and calls it
// durability.
func TestParseFioReadsTheSyncStream(t *testing.T) {
const withSync = `{"fio version":"fio-3.36","jobs":[{"jobname":"syncwrite-4k",
"write":{"io_bytes":67108864,"bw_bytes":1000,"iops":250,"clat_ns":{"max":900,"mean":400,"percentile":{"99.900000":800}}},
"sync":{"total_ios":16384,"lat_ns":{"max":41000000,"mean":5100000,"percentile":{"99.900000":38000000}}}}]}`
res, err := parseFio("syncwrite-4k", withSync)
if err != nil {
t.Fatalf("parseFio: %v", err)
}
if got := res.Aggregate.Sync.TotalIOs; got != 16384 {
t.Errorf("sync total_ios = %d, want 16384", got)
}
if got := res.Aggregate.Sync.p999Ms(); got != 38 {
t.Errorf("sync p99.9 = %.1fms, want 38.0", got)
}
if got := res.Aggregate.Sync.maxMs(); got != 41 {
t.Errorf("sync max = %.1fms, want 41.0", got)
}
}
// An fdatasync result must be gated on the sync distribution. Judging it on
// write IOPS would pass a disk that buffers everything and commits slowly,
// which is the exact disk etcd cannot run on.
func TestMetricsForGatesTheSyncDistribution(t *testing.T) {
agg := fioJob{
Name: "syncwrite-4k",
Write: fioStream{IOBytes: 67108864, IOPS: 250},
Sync: fioSync{TotalIOs: 16384, Lat: fioClat{Percentile: map[string]int64{p999Key: 38000000}}},
}
ms := metricsFor(jobSpec{Name: "syncwrite-4k", Fdatasync: 1}, agg, BaselineJob{SyncP999Ms: 10})
var got *metric
for i := range ms {
if ms[i].Name == "fdatasync_p99_9_ms" {
got = &ms[i]
}
}
if got == nil {
t.Fatalf("no fdatasync metric produced from a job with %d syncs", agg.Sync.TotalIOs)
}
if got.HigherIsBetter {
t.Error("fdatasync latency was scored as higher-is-better")
}
if v, _ := got.judge(); v != verdictFail {
t.Errorf("38ms against a 10ms baseline judged %v, want fail", v)
}
}
// randrw needs a mix and the single-direction profiles must not carry one,
// since fio ignores rwmixread outside randrw and its presence would suggest a
// mix that is not being run.
func TestJobCommandSetsMixOnlyForRandrw(t *testing.T) {
for _, j := range gateJobs {
cmd := j.command("vdc", "/tmp/out.json")
hasMix := strings.Contains(cmd, "--rwmixread=")
if want := j.RW == "randrw"; hasMix != want {
t.Errorf("job %q rw=%s: rwmixread present=%v, want %v", j.Name, j.RW, hasMix, want)
}
}
}