mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
8e11908dfb
* 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>
339 lines
12 KiB
Go
339 lines
12 KiB
Go
//go:build e2e
|
|
|
|
package diskperf
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
)
|
|
|
|
const (
|
|
// diskperfDevice is the guest-visible attach point requested for the volume
|
|
// under test.
|
|
diskperfDevice = "/dev/sdp"
|
|
|
|
// volumeHeadroomGiB pads the volume past the working set so fio's last job
|
|
// is not writing into the final block of the device.
|
|
volumeHeadroomGiB = 4
|
|
|
|
// volumeReadyTimeout bounds the wait for a hotplugged volume to reach the
|
|
// guest kernel.
|
|
volumeReadyTimeout = 90 * time.Second
|
|
)
|
|
|
|
// TestGuestDiskPerformance is the gate. It drives each pinned fio profile
|
|
// against its own freshly created raw volume and asserts two separable classes
|
|
// of property: that the guest and the host stayed responsive throughout, hard
|
|
// pass/fail; and that throughput and latency have not regressed past the band,
|
|
// against a committed baseline.
|
|
//
|
|
// The two are kept apart deliberately. The availability class is what would
|
|
// have caught the original blocker, and it would have caught it on the first
|
|
// run, because its thresholds are absolute rather than relative to a number
|
|
// somebody measured on a good day. Folding them together would let a run that
|
|
// left the host unreachable pass on the strength of a respectable IOPS figure.
|
|
func TestGuestDiskPerformance(t *testing.T) {
|
|
fix := requireDiskPerfFixture(t)
|
|
harness.Phase(t, "Guest Disk Performance")
|
|
|
|
base, err := loadBaseline()
|
|
if err != nil {
|
|
t.Fatalf("baseline: %v", err)
|
|
}
|
|
reps := envInt(t, "SPINIFEX_DISKPERF_REPS", 1)
|
|
|
|
instanceID, tgt := ensureDiskPerfInstance(t, fix)
|
|
az := harness.DiscoverDefaultAZ(t, fix.Harness)
|
|
harness.Detail(t, "instance", instanceID, "az", az, "reps", reps, "baseline_origin", base.Origin)
|
|
|
|
if err := installFio(tgt); err != nil {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
|
|
hostAddr := hostSSHAddr(fix.Env)
|
|
if err := readSSHBanner(hostAddr, hostProbeBound); err != nil {
|
|
// Establish the probe works before the workload starts. Skipping the
|
|
// host assertion loudly is honest; asserting on a channel that was
|
|
// already broken would report the harness as a storage regression.
|
|
t.Errorf("host sshd probe is unusable before any load (%s): %v — the host-responsiveness assertion cannot run", hostAddr, err)
|
|
hostAddr = ""
|
|
}
|
|
|
|
for _, job := range gateJobs {
|
|
t.Run(job.Name, func(t *testing.T) {
|
|
runJob(t, fix, job, base.Jobs[job.Name], instanceID, tgt, az, hostAddr, reps)
|
|
})
|
|
}
|
|
}
|
|
|
|
// runJob executes one profile reps times, each against a volume created for
|
|
// that repetition. The fresh volume is the reset: the extent index grows
|
|
// monotonically, so a reused volume makes each repetition scan a larger index
|
|
// than the last and drifts the gate's own numbers upward over time.
|
|
func runJob(t *testing.T, fix *Fixture, job jobSpec, base BaselineJob, instanceID string, tgt harness.SSHTarget, az, hostAddr string, reps int) {
|
|
t.Helper()
|
|
sizeGiB := int64(job.workingSetGiB() + volumeHeadroomGiB)
|
|
|
|
var results []runResult
|
|
var probes []probeReport
|
|
for rep := 1; rep <= reps; rep++ {
|
|
harness.Step(t, "rep %d/%d: %s on a fresh %d GiB volume", rep, reps, job.shape(), sizeGiB)
|
|
|
|
volID := createVolume(t, fix, az, sizeGiB)
|
|
before := harness.GuestDiskSet(t, tgt)
|
|
harness.AttachVolumeWait(t, fix.AWS, volID, instanceID, diskperfDevice)
|
|
dev := harness.WaitForNewGuestDisk(t, tgt, before, volumeReadyTimeout)
|
|
|
|
res, reports := driveWithProbes(t, job, tgt, dev, hostAddr)
|
|
harness.DetachVolumeWait(t, fix.AWS, volID)
|
|
|
|
assertAvailability(t, job, reports)
|
|
results = append(results, res)
|
|
probes = append(probes, reports...)
|
|
}
|
|
|
|
report := aggregate(t, job, base, results, probes)
|
|
harness.DumpFile(t, resultsDir(t, fix), job.Name+"-result.json", report)
|
|
}
|
|
|
|
// resultsDir is the run's top-level artifact directory rather than the
|
|
// per-test one. The per-test directory deletes itself when the test passes,
|
|
// which is right for failure diagnostics and wrong for these: a passing run is
|
|
// exactly the run whose numbers a baseline capture needs to read.
|
|
func resultsDir(t *testing.T, fix *Fixture) string {
|
|
t.Helper()
|
|
dir := fix.Env.ArtifactDir
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("results dir %s: %v", dir, err)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// driveWithProbes runs the job while the responsiveness probes watch. The
|
|
// probes only bracket the availability job: they cost a connection every few
|
|
// seconds, which is negligible against the workload but is still load, and it
|
|
// has no place in a run whose numbers feed a baseline.
|
|
func driveWithProbes(t *testing.T, job jobSpec, tgt harness.SSHTarget, dev, hostAddr string) (runResult, []probeReport) {
|
|
t.Helper()
|
|
if !job.Availability {
|
|
res, err := runFio(tgt, job, dev)
|
|
if err != nil {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
probes := []probe{guestProbe(tgt)}
|
|
if hostAddr != "" {
|
|
probes = append(probes, hostProbe(hostAddr))
|
|
}
|
|
|
|
stop := make(chan struct{})
|
|
done := make(chan []probeReport, 1)
|
|
go func() { done <- watch(probes, stop) }()
|
|
|
|
res, err := runFio(tgt, job, dev)
|
|
close(stop)
|
|
reports := <-done
|
|
|
|
if err != nil {
|
|
// A job that did not finish inside its budget is the stall itself, so
|
|
// what the probes saw while it ran is the diagnosis and has to be
|
|
// printed alongside the failure rather than dropped with the run.
|
|
for _, r := range reports {
|
|
t.Logf(" %s", r.summary())
|
|
if r.FirstFailure != nil {
|
|
t.Logf(" first breach at +%s: %v", r.FirstFailureAt.Round(time.Second), r.FirstFailure)
|
|
}
|
|
}
|
|
t.Fatalf("%v", err)
|
|
}
|
|
return res, reports
|
|
}
|
|
|
|
// assertAvailability turns the probe reports into hard pass/fail. Zero
|
|
// tolerance: one breach means the guest or the host stopped answering while a
|
|
// single tenant ran an ordinary workload.
|
|
func assertAvailability(t *testing.T, job jobSpec, reports []probeReport) {
|
|
t.Helper()
|
|
if !job.Availability {
|
|
return
|
|
}
|
|
if len(reports) == 0 {
|
|
t.Fatal("availability job ran with no probes — nothing was asserted")
|
|
}
|
|
for _, r := range reports {
|
|
harness.Detail(t, "probe", r.Name, "samples", r.Samples, "over_bound", r.Failures, "max_ms", r.Max.Milliseconds())
|
|
if r.Samples == 0 {
|
|
t.Errorf("%s: no samples taken — the probe never ran, so it proved nothing", r.Name)
|
|
continue
|
|
}
|
|
if r.ok() {
|
|
continue
|
|
}
|
|
t.Errorf("%s: %d of %d samples breached the bound, first at +%s: %v",
|
|
r.Name, r.Failures, r.Samples, r.FirstFailureAt.Round(time.Second), r.FirstFailure)
|
|
}
|
|
}
|
|
|
|
// aggregate compares the run against the baseline and returns the recorded
|
|
// result for the artifact bundle. Every metric is printed whatever its verdict,
|
|
// so a passing run still leaves the numbers behind for the next baseline
|
|
// capture to be judged against.
|
|
func aggregate(t *testing.T, job jobSpec, base BaselineJob, results []runResult, probes []probeReport) []byte {
|
|
t.Helper()
|
|
if len(results) == 0 {
|
|
t.Fatalf("job %q produced no results", job.Name)
|
|
}
|
|
|
|
agg := medianAggregate(results)
|
|
recorded := map[string]any{
|
|
"job": job.Name,
|
|
"reps": len(results),
|
|
"fio_version": results[0].Report.Version,
|
|
"read_iops": agg.Read.IOPS,
|
|
"write_iops": agg.Write.IOPS,
|
|
"read_p99_9": agg.Read.p999Ms(),
|
|
"write_p99_9": agg.Write.p999Ms(),
|
|
"read_max_ms": agg.Read.maxMs(),
|
|
"write_max_ms": agg.Write.maxMs(),
|
|
// Zero for the throughput profiles, which issue no fdatasync.
|
|
"fdatasync_ios": agg.Sync.TotalIOs,
|
|
"fdatasync_p99_9": agg.Sync.p999Ms(),
|
|
"fdatasync_max_ms": agg.Sync.maxMs(),
|
|
// The availability evidence belongs in the same record as the numbers.
|
|
// A throughput figure taken from a run that left the host unreachable
|
|
// is not a throughput figure worth keeping.
|
|
"probes": probeRecords(probes),
|
|
}
|
|
|
|
for _, m := range metricsFor(job, agg, base) {
|
|
v, msg := m.judge()
|
|
switch v {
|
|
case verdictFail:
|
|
t.Errorf("REGRESSION %s", msg)
|
|
case verdictWarn:
|
|
t.Logf(" warn: %s", msg)
|
|
default:
|
|
t.Logf(" %s", msg)
|
|
}
|
|
}
|
|
|
|
blob, err := json.MarshalIndent(recorded, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("encoding result: %v", err)
|
|
}
|
|
return blob
|
|
}
|
|
|
|
// medianAggregate reduces the repetitions to one aggregate by taking the median
|
|
// of each metric independently. Per-metric rather than picking a single
|
|
// representative run: the median run by IOPS is not necessarily the median run
|
|
// by tail latency, and the tail is the number this gate cares most about.
|
|
func medianAggregate(results []runResult) fioJob {
|
|
pick := func(f func(fioJob) float64) float64 {
|
|
xs := make([]float64, 0, len(results))
|
|
for _, r := range results {
|
|
xs = append(xs, f(r.Aggregate))
|
|
}
|
|
return median(xs)
|
|
}
|
|
|
|
var out fioJob
|
|
out.Name = results[0].Job
|
|
out.Read.IOPS = pick(func(j fioJob) float64 { return j.Read.IOPS })
|
|
out.Write.IOPS = pick(func(j fioJob) float64 { return j.Write.IOPS })
|
|
out.Read.Clat.Percentile = map[string]int64{p999Key: int64(pick(func(j fioJob) float64 { return float64(j.Read.Clat.Percentile[p999Key]) }))}
|
|
out.Write.Clat.Percentile = map[string]int64{p999Key: int64(pick(func(j fioJob) float64 { return float64(j.Write.Clat.Percentile[p999Key]) }))}
|
|
out.Read.Clat.Max = int64(pick(func(j fioJob) float64 { return float64(j.Read.Clat.Max) }))
|
|
out.Write.Clat.Max = int64(pick(func(j fioJob) float64 { return float64(j.Write.Clat.Max) }))
|
|
out.Sync.Lat.Percentile = map[string]int64{p999Key: int64(pick(func(j fioJob) float64 { return float64(j.Sync.Lat.Percentile[p999Key]) }))}
|
|
out.Sync.Lat.Max = int64(pick(func(j fioJob) float64 { return float64(j.Sync.Lat.Max) }))
|
|
|
|
// Carry the byte counts so metricsFor can still tell which streams the job
|
|
// actually exercised.
|
|
for _, r := range results {
|
|
out.Read.IOBytes += r.Aggregate.Read.IOBytes
|
|
out.Write.IOBytes += r.Aggregate.Write.IOBytes
|
|
out.Sync.TotalIOs += r.Aggregate.Sync.TotalIOs
|
|
}
|
|
return out
|
|
}
|
|
|
|
// createVolume makes a volume for a single repetition and registers its
|
|
// teardown. The harness Ensure* helper memoizes on (az, size) and would hand
|
|
// back the same volume every time, which is the opposite of the reset this
|
|
// needs.
|
|
func createVolume(t *testing.T, fix *Fixture, az string, sizeGiB int64) string {
|
|
t.Helper()
|
|
// e2e:allow-create — a fresh volume per repetition is the reset this gate depends on.
|
|
out, err := fix.AWS.EC2.CreateVolume(&ec2.CreateVolumeInput{
|
|
AvailabilityZone: aws.String(az),
|
|
Size: aws.Int64(sizeGiB),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateVolume(%d GiB in %s): %v", sizeGiB, az, err)
|
|
}
|
|
volID := aws.StringValue(out.VolumeId)
|
|
harness.RegisterVolumeTeardown(t, fix.AWS, volID)
|
|
harness.WaitForVolumeState(t, fix.AWS, volID, "available")
|
|
return volID
|
|
}
|
|
|
|
// ensureDiskPerfInstance launches (or returns the memoized) guest and waits for
|
|
// SSH.
|
|
func ensureDiskPerfInstance(t *testing.T, fix *Fixture) (instanceID string, tgt harness.SSHTarget) {
|
|
t.Helper()
|
|
instType, arch := harness.DiscoverNanoInstanceType(t, fix.Harness)
|
|
ami := harness.DiscoverUbuntuAMI(t, fix.Harness, arch)
|
|
keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness)
|
|
vpc := harness.EnsureDefaultVPC(t, fix.Harness)
|
|
harness.AuthorizeSSHIngress(t, fix.AWS, vpc.SGID)
|
|
|
|
instanceID = harness.EnsureInstance(t, fix.Harness, harness.InstanceSpec{
|
|
AMIID: ami,
|
|
InstanceType: instType,
|
|
KeyName: keyName,
|
|
SubnetID: vpc.SubnetID,
|
|
SGID: vpc.SGID,
|
|
})
|
|
inst := harness.WaitForInstanceState(t, fix.AWS, instanceID, "running")
|
|
host, port := harness.InstancePublicSSHHost(t, inst)
|
|
|
|
harness.Step(t, "waiting for guest SSH on %s:%d (instance_type=%s)", host, port, instType)
|
|
if !harness.TryGuestSSHReady(host, port, "ubuntu", keyPath, 5*time.Minute) {
|
|
t.Fatalf("guest %s SSH %s:%d not ready after 5m", instanceID, host, port)
|
|
}
|
|
return instanceID, harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath}
|
|
}
|
|
|
|
// hostSSHAddr is the address the host-responsiveness probe dials. WANHost is
|
|
// the externally reachable node address, which is what an operator locked out
|
|
// by the original defect would have been trying to reach.
|
|
func hostSSHAddr(env *harness.Env) string {
|
|
return net.JoinHostPort(env.WANHost, "22")
|
|
}
|
|
|
|
// envInt reads a positive integer override, failing outright on a malformed
|
|
// value rather than silently falling back to the default.
|
|
func envInt(t *testing.T, key string, def int) int {
|
|
t.Helper()
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n <= 0 {
|
|
t.Fatalf("%s=%q is not a positive integer", key, v)
|
|
}
|
|
return n
|
|
}
|