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>
308 lines
10 KiB
Go
308 lines
10 KiB
Go
//go:build e2e
|
|
|
|
package diskperf
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
)
|
|
|
|
// Guest-side exit codes, distinct from fio's own so an infra fault is never
|
|
// reported as a performance result. The suite is only meaningful when the tool
|
|
// under measurement actually ran.
|
|
const (
|
|
exitAptUpdate = 10
|
|
exitAptInstall = 11
|
|
)
|
|
|
|
// p999Key is fio's percentile map key for p99.9. fio emits percentiles as
|
|
// fixed-format decimal strings, so this is a literal rather than a formatted
|
|
// float.
|
|
const p999Key = "99.900000"
|
|
|
|
// jobSpec is one pinned fio profile. Every field is fixed in code rather than
|
|
// read from the environment: a gate whose workload can drift is comparing
|
|
// against a baseline that no longer describes it.
|
|
type jobSpec struct {
|
|
// Name is both the fio job name and the baseline key, so renaming one
|
|
// without the other retires the baseline entry rather than silently
|
|
// comparing against the wrong workload.
|
|
Name string
|
|
// RW is an fio --rw value. MixRead applies to randrw only.
|
|
RW string
|
|
MixRead int
|
|
|
|
BlockSize string
|
|
// SizeGiB is per job. NumJobs jobs each take their own SizeGiB slice of the
|
|
// device via offset_increment, so the working set is NumJobs*SizeGiB.
|
|
SizeGiB int
|
|
// SizeMiB overrides SizeGiB when non-zero, for jobs whose per-op cost makes
|
|
// a GiB-scale working set take hours rather than minutes.
|
|
SizeMiB int
|
|
NumJobs int
|
|
IODepth int
|
|
|
|
// Fdatasync, when non-zero, issues fdatasync every N writes and switches the
|
|
// job to buffered psync. Durability latency is only meaningful through the
|
|
// page cache: direct=1 and libaio measure the wrong thing.
|
|
Fdatasync int
|
|
|
|
// Budget bounds the guest-side invocation. Exceeding it is an availability
|
|
// failure, not a slow result: the workload did not complete.
|
|
Budget time.Duration
|
|
|
|
// Availability marks the job whose run the responsiveness probes bracket.
|
|
// Sustained random write is the pattern that produced the original stall.
|
|
Availability bool
|
|
}
|
|
|
|
// gateJobs is the pinned profile set. randwrite-4k is the job that took the
|
|
// guest and the host down, at the 4x4 GiB shape the exit criterion names;
|
|
// randrw-70-30-4k is the mixed workload the headline IOPS figure came from.
|
|
var gateJobs = []jobSpec{
|
|
{
|
|
Name: "randwrite-4k",
|
|
RW: "randwrite",
|
|
BlockSize: "4k",
|
|
SizeGiB: 4,
|
|
NumJobs: 4,
|
|
IODepth: 32,
|
|
Budget: 45 * time.Minute,
|
|
Availability: true,
|
|
},
|
|
{
|
|
Name: "randrw-70-30-4k",
|
|
RW: "randrw",
|
|
MixRead: 70,
|
|
BlockSize: "4k",
|
|
SizeGiB: 4,
|
|
NumJobs: 4,
|
|
IODepth: 32,
|
|
Budget: 45 * time.Minute,
|
|
},
|
|
// Durability latency, not throughput. etcd commits every raft entry with an
|
|
// fdatasync at queue depth 1 and stalls on that latency long before it runs
|
|
// short of bandwidth, which the two profiles above cannot see.
|
|
{
|
|
Name: "syncwrite-4k",
|
|
RW: "write",
|
|
BlockSize: "4k",
|
|
SizeMiB: 64,
|
|
NumJobs: 1,
|
|
IODepth: 1,
|
|
Fdatasync: 1,
|
|
Budget: 20 * time.Minute,
|
|
},
|
|
}
|
|
|
|
// workingSetGiB is the span of the device the job touches, and so the minimum
|
|
// volume size it needs. A sub-GiB job rounds to zero: the volume headroom the
|
|
// caller adds already covers it.
|
|
func (j jobSpec) workingSetGiB() int {
|
|
if j.SizeMiB > 0 {
|
|
return j.SizeMiB * j.NumJobs / 1024
|
|
}
|
|
return j.SizeGiB * j.NumJobs
|
|
}
|
|
|
|
// size renders the per-job size as an fio --size value.
|
|
func (j jobSpec) size() string {
|
|
if j.SizeMiB > 0 {
|
|
return fmt.Sprintf("%dM", j.SizeMiB)
|
|
}
|
|
return fmt.Sprintf("%dG", j.SizeGiB)
|
|
}
|
|
|
|
// shape describes the job for a progress line, since the size unit varies.
|
|
func (j jobSpec) shape() string {
|
|
return fmt.Sprintf("%d jobs x %s %s %s at qd%d", j.NumJobs, j.size(), j.BlockSize, j.RW, j.IODepth)
|
|
}
|
|
|
|
// command renders the fio invocation. The target is a raw device with
|
|
// direct=1, so the guest page cache and any filesystem are out of the path;
|
|
// offset_increment gives each job a disjoint region rather than four jobs
|
|
// overwriting the same one.
|
|
func (j jobSpec) command(dev, outPath string) string {
|
|
args := []string{
|
|
"sudo fio",
|
|
"--name=" + j.Name,
|
|
"--filename=/dev/" + dev,
|
|
"--rw=" + j.RW,
|
|
"--bs=" + j.BlockSize,
|
|
"--size=" + j.size(),
|
|
fmt.Sprintf("--offset_increment=%s", j.size()),
|
|
fmt.Sprintf("--numjobs=%d", j.NumJobs),
|
|
fmt.Sprintf("--iodepth=%d", j.IODepth),
|
|
"--group_reporting",
|
|
"--output-format=json",
|
|
"--output=" + outPath,
|
|
}
|
|
if j.Fdatasync > 0 {
|
|
// lat_percentiles is what makes fio emit the sync-latency distribution;
|
|
// without it the run reports a mean and the tail is lost, and the tail
|
|
// is what a consensus datastore actually stalls on.
|
|
args = append(args,
|
|
fmt.Sprintf("--fdatasync=%d", j.Fdatasync),
|
|
"--ioengine=psync",
|
|
"--lat_percentiles=1")
|
|
} else {
|
|
args = append(args, "--ioengine=libaio", "--direct=1")
|
|
}
|
|
if j.RW == "randrw" {
|
|
args = append(args, fmt.Sprintf("--rwmixread=%d", j.MixRead))
|
|
}
|
|
return strings.Join(args, " ")
|
|
}
|
|
|
|
// fioReport is the subset of fio's JSON output the gate reads.
|
|
type fioReport struct {
|
|
Version string `json:"fio version"`
|
|
Jobs []fioJob `json:"jobs"`
|
|
}
|
|
|
|
type fioJob struct {
|
|
Name string `json:"jobname"`
|
|
Read fioStream `json:"read"`
|
|
Write fioStream `json:"write"`
|
|
// Sync is the fdatasync distribution, which fio reports separately from the
|
|
// write stream: write clat is the time to reach the page cache, and the
|
|
// sync is what makes it durable. For a fdatasync job this is the result.
|
|
Sync fioSync `json:"sync"`
|
|
}
|
|
|
|
// fioSync is fio's sync section. It reports total latency (lat_ns), not the
|
|
// completion latency the queued streams report, so it carries its own field.
|
|
type fioSync struct {
|
|
TotalIOs int64 `json:"total_ios"`
|
|
Lat fioClat `json:"lat_ns"`
|
|
}
|
|
|
|
// p999Ms returns the p99.9 fdatasync latency in milliseconds.
|
|
func (s fioSync) p999Ms() float64 {
|
|
return float64(s.Lat.Percentile[p999Key]) / 1e6
|
|
}
|
|
|
|
// maxMs returns the worst fdatasync latency in milliseconds.
|
|
func (s fioSync) maxMs() float64 { return float64(s.Lat.Max) / 1e6 }
|
|
|
|
type fioStream struct {
|
|
IOBytes int64 `json:"io_bytes"`
|
|
BWBytes int64 `json:"bw_bytes"`
|
|
IOPS float64 `json:"iops"`
|
|
Clat fioClat `json:"clat_ns"`
|
|
}
|
|
|
|
type fioClat struct {
|
|
Max int64 `json:"max"`
|
|
Mean float64 `json:"mean"`
|
|
Percentile map[string]int64 `json:"percentile"`
|
|
}
|
|
|
|
// p999Ms returns the p99.9 completion latency in milliseconds, or 0 when fio
|
|
// did not emit the percentile because the stream moved no I/O.
|
|
func (s fioStream) p999Ms() float64 {
|
|
return float64(s.Clat.Percentile[p999Key]) / 1e6
|
|
}
|
|
|
|
// maxMs returns the worst completion latency in milliseconds.
|
|
func (s fioStream) maxMs() float64 { return float64(s.Clat.Max) / 1e6 }
|
|
|
|
// runResult is one execution of one job.
|
|
type runResult struct {
|
|
Job string
|
|
Report fioReport
|
|
// Aggregate is the group_reporting job entry. fio emits one per --name
|
|
// under group_reporting, so this is the whole run for that job.
|
|
Aggregate fioJob
|
|
}
|
|
|
|
// installFio makes fio available in the guest. Distinct exit codes keep an apt
|
|
// failure -- a network or mirror fault, not a storage one -- out of the
|
|
// performance verdict.
|
|
func installFio(tgt harness.SSHTarget) error {
|
|
script := strings.Join([]string{
|
|
"set -e",
|
|
"if command -v fio >/dev/null 2>&1; then exit 0; fi",
|
|
fmt.Sprintf("sudo apt-get update -y || exit %d", exitAptUpdate),
|
|
fmt.Sprintf("sudo DEBIAN_FRONTEND=noninteractive apt-get install -y fio || exit %d", exitAptInstall),
|
|
}, "\n")
|
|
if out, err := harness.GuestExecTimeout(tgt, script, 10*time.Minute); err != nil {
|
|
return fmt.Errorf("installing fio in the guest failed — this is an infra fault, not a performance result: %w\n%s", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runFio drives one job to completion and returns its parsed report. The JSON
|
|
// is written to a file in the guest and fetched by a second command: fio's
|
|
// forced-exit path on a wedged job corrupts its own heap and produces no
|
|
// output, so a run that exceeds its budget must still be distinguishable from
|
|
// one that failed to parse.
|
|
func runFio(tgt harness.SSHTarget, j jobSpec, dev string) (runResult, error) {
|
|
outPath := "/tmp/diskperf-" + j.Name + ".json"
|
|
if out, err := harness.GuestExec(tgt, "sudo rm -f "+outPath); err != nil {
|
|
return runResult{}, fmt.Errorf("clearing %s: %w\n%s", outPath, err, out)
|
|
}
|
|
|
|
if out, err := harness.GuestExecTimeout(tgt, j.command(dev, outPath), j.Budget); err != nil {
|
|
return runResult{}, fmt.Errorf("fio job %q did not complete within %s — the workload stalled: %w\n%s", j.Name, j.Budget, err, out)
|
|
}
|
|
|
|
raw, err := harness.GuestExec(tgt, "sudo cat "+outPath)
|
|
if err != nil {
|
|
return runResult{}, fmt.Errorf("reading %s: %w\n%s", outPath, err, raw)
|
|
}
|
|
return parseFio(j.Name, raw)
|
|
}
|
|
|
|
// parseFio extracts the named job's aggregate from fio's JSON. fio prefixes
|
|
// its output with progress lines on some builds, so the object is located by
|
|
// its opening brace rather than assumed to start at byte zero.
|
|
func parseFio(name, raw string) (runResult, error) {
|
|
start := strings.Index(raw, "{")
|
|
if start < 0 {
|
|
return runResult{}, fmt.Errorf("fio produced no JSON object for %q:\n%s", name, raw)
|
|
}
|
|
var rep fioReport
|
|
if err := json.Unmarshal([]byte(raw[start:]), &rep); err != nil {
|
|
return runResult{}, fmt.Errorf("parsing fio JSON for %q: %w\n%s", name, err, raw)
|
|
}
|
|
for _, jb := range rep.Jobs {
|
|
if jb.Name != name {
|
|
continue
|
|
}
|
|
if jb.Read.IOBytes == 0 && jb.Write.IOBytes == 0 {
|
|
return runResult{}, fmt.Errorf("fio job %q moved no bytes — it ran but did nothing, so its numbers describe nothing", name)
|
|
}
|
|
return runResult{Job: name, Report: rep, Aggregate: jb}, nil
|
|
}
|
|
return runResult{}, fmt.Errorf("fio JSON has no job named %q (found %s)", name, strings.Join(jobNames(rep), ", "))
|
|
}
|
|
|
|
func jobNames(rep fioReport) []string {
|
|
names := make([]string, 0, len(rep.Jobs))
|
|
for _, j := range rep.Jobs {
|
|
names = append(names, j.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// median returns the middle value of xs, averaging the two central values for
|
|
// an even count. xs is copied so the caller's ordering survives.
|
|
func median(xs []float64) float64 {
|
|
if len(xs) == 0 {
|
|
return 0
|
|
}
|
|
s := append([]float64(nil), xs...)
|
|
sort.Float64s(s)
|
|
mid := len(s) / 2
|
|
if len(s)%2 == 1 {
|
|
return s[mid]
|
|
}
|
|
return (s[mid-1] + s[mid]) / 2
|
|
}
|