//go:build linux package main // ops_memory_linux.go — an action op may use as much memory as the machine has // to spare, and is the first thing stopped when it has none. // // There is no ceiling on a run. A fixed one stops a legitimate build or scan // on a machine with memory to spare, which is exactly when it should not. // What a run must never do is take memory the machine's own services need, // and on a Kubernetes node that line comes before the kernel's: the kubelet // evicts pods once free memory falls under its hard eviction threshold. So: // // - the run's unit carries OOMScoreAdjust=1000: if the kernel must kill // something, it kills the op first; // - while a run is going, the bastion watches the machine's free memory // (MemAvailable), and when it falls to the floor it stops the op's unit, // before the kubelet or the kernel pick anything else, and says so. The // op yields whether or not it is what used the memory: of everything // running, it is the thing that can simply be run again; // - it is stopped too when the machine's own services start waiting on // memory: when any top-level cgroup other than the ops' own slice (the // services, the pods, the users) stalls on memory 7 points more of the // time than its lowest reading since the run began (PSI "some", 10-second average). A // rise, not a level: a pod reclaiming against its own limit keeps its // group stalling whatever the machine has free, and must not stop every // op. That is the partial-stall level at // which Android's low-memory killer stops background apps (70 ms of // every second); here it sits 15x above the worst reading on the three // ship machines (0.44%). It catches what free // memory alone misses on a machine with swap, where the kernel makes // room by swapping the services out and free memory hardly moves (an op // reached 7.8 GiB on a build machine while free memory fell by 5.8 GiB). The op's // own stalls do not count: it runs in its own slice; // - a run does not start on a machine already under the floor; // - the kill is tried on every tick the machine is short until it lands, // and a run counts as stopped only if the kill landed and the program // did not finish anyway. // // The floor is the largest of: 1 GiB; 5% of the machine's memory (earlyoom, // the common userspace killer, acts at 10% by default, and the op must go // well before a machine-wide killer does); and, on a k3s node, the kubelet's // own hard eviction threshold plus 512 MiB, so the op always yields before a // pod is evicted. import ( "bufio" "fmt" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "sync" "time" ) const ( opActionSlice = "privatae_ops.slice" opMemoryFloorMin = 1 << 30 opMemoryKubeletPad = 512 << 20 opMemoryWatchPeriod = 250 * time.Millisecond ) // Seams, so the tests can move the floor and read a unit's cgroup. var ( opMemoryFloor = opMemoryFloorForMachine opMemoryStallMax = 7.0 // percent, PSI some avg10, of any cgroup but the ops' opCgroupRoot = "/sys/fs/cgroup" // where the pressures are read ) // opUnitCgroupDir is an op unit's own cgroup, in the ops' slice. func opUnitCgroupDir(unit string) string { return filepath.Join("/sys/fs/cgroup", opActionSlice, unit+".service") } // opKillUnit kills every process of an op's unit at once, through the // kernel (cgroup.kill), which needs no bus; systemd is asked only if that // cannot be written. func opKillUnit(unit string) bool { if err := os.WriteFile(filepath.Join(opUnitCgroupDir(unit), "cgroup.kill"), []byte("1"), 0); err == nil { return true } return exec.Command("systemctl", "kill", "--signal=SIGKILL", unit).Run() == nil } // opOthersStalls is each top-level cgroup's memory stall (PSI some avg10, // percent), the ops' own slice left out. func opOthersStalls() map[string]float64 { out := map[string]float64{} entries, err := os.ReadDir(opCgroupRoot) if err != nil { return out } for _, e := range entries { if !e.IsDir() || e.Name() == opActionSlice { continue } raw, err := os.ReadFile(filepath.Join(opCgroupRoot, e.Name(), "memory.pressure")) if err != nil { continue } for _, line := range strings.Split(string(raw), "\n") { if !strings.HasPrefix(line, "some ") { continue } for _, f := range strings.Fields(line) { if v, ok := strings.CutPrefix(f, "avg10="); ok { if x, err := strconv.ParseFloat(v, 64); err == nil { out[e.Name()] = x } } } } } return out } // opStallRise is the cgroup whose stall rose most above its lowest reading // so far in the run, and by how much. A group stalling throughout (a pod // reclaiming against its own limit, a service at its MemoryMax) is that // group's own business, not a sign the machine is short: only a rise counts. func opStallRise(base, now map[string]float64) (float64, string) { var worst float64 var who string for cg, x := range now { if r := x - base[cg]; r > worst { worst, who = r, cg } } return worst, who } // opMemInfo reads MemTotal and MemAvailable, in bytes. func opMemInfo() (total, avail uint64, ok bool) { f, err := os.Open("/proc/meminfo") if err != nil { return 0, 0, false } defer f.Close() sc := bufio.NewScanner(f) got := 0 for sc.Scan() { fs := strings.Fields(sc.Text()) if len(fs) < 2 { continue } n, err := strconv.ParseUint(fs[1], 10, 64) if err != nil { continue } switch fs[0] { case "MemTotal:": total, got = n<<10, got+1 case "MemAvailable:": avail, got = n<<10, got+1 } } return total, avail, got == 2 } // The places a k3s node's kubelet eviction settings live: the config file, // its drop-ins, and --kubelet-arg flags on the k3s units and their drop-ins. // Seams, so the tests can point them at fixtures. var ( opK3sConfigGlobs = []string{"/etc/rancher/k3s/config.yaml", "/etc/rancher/k3s/config.yaml.d/*.yaml", // The kubelet config k3s writes (and a person may add to), v1.35. "/var/lib/rancher/k3s/agent/etc/kubelet.conf.d/*.conf"} opK3sUnitGlobs = []string{"/etc/systemd/system/k3s*.service", "/etc/systemd/system/k3s*.service.d/*.conf"} ) // memory.available= 0 { line = line[:i] } var vals []string if strings.Contains(line, "eviction-hard") || strings.Contains(line, "eviction-soft") { for _, m := range opEvictionFlagRe.FindAllStringSubmatch(line, -1) { vals = append(vals, m[1]) } } for _, m := range opEvictionYAMLRe.FindAllStringSubmatch(line, -1) { vals = append(vals, m[1]) } for _, v := range vals { n, ok := opParseMemQuantity(v, total) if !ok { unreadable = true continue } if n > bytes { bytes = n } } } } return bytes, unreadable } var opKubeletConfigArgRe = regexp.MustCompile(`(?:^|[\s"'=,\[-])(config|config-dir)=([^\s"',\]]+)`) // opKubeletNamed is every kubelet config file (config=) or folder // (config-dir=, its *.conf) a kubelet-arg in raw names. func opKubeletNamed(raw []byte) []string { var out []string for _, line := range strings.Split(string(raw), "\n") { if i := strings.Index(line, "#"); i >= 0 { line = line[:i] } // Every line, not only ones naming kubelet-arg: a unit file splits // `'--kubelet-arg' \` and `'config=…' \` over two lines. A match // that is not a kubelet's only adds a file to read, or raises the // floor. for _, m := range opKubeletConfigArgRe.FindAllStringSubmatch(line, -1) { if m[1] == "config-dir" { out = append(out, filepath.Join(m[2], "*.conf")) } else { out = append(out, m[2]) } } } return out } // opFloorNote explains a floor raised because a kubelet eviction setting on // this machine could not be read, so a stop it causes says why; "" otherwise. func opFloorNote() string { if _, unreadable := opKubeletEviction(); unreadable { return " (raised to 10% of memory plus 512 MiB because a kubelet eviction setting on this machine could not be read)" } return "" } func opMemoryFloorForMachine() uint64 { total, _, _ := opMemInfo() floor := uint64(opMemoryFloorMin) if total/20 > floor { floor = total / 20 } k, unreadable := opKubeletEviction() if k > 0 && k+opMemoryKubeletPad > floor { floor = k + opMemoryKubeletPad } // A kubelet setting this could not read is taken as high as a kubelet // is usually set (10%), never as absent: the op must still go first. if unreadable && total/10+opMemoryKubeletPad > floor { floor = total/10 + opMemoryKubeletPad } return floor } // opMemoryWatch stops unit if the machine's free memory falls to the floor, // and tracks the unit's own peak. stop() ends the watch and says whether it // stopped the op, the free memory when it did, and the op's peak. type opMemoryVerdict struct { Stopped bool Avail uint64 Floor uint64 Peak uint64 Stall float64 // set when others' stall stopped it Staller string } // reason is the sentence for a run the watch stopped. func (v opMemoryVerdict) reason() string { if v.Stall > 0 { return fmt.Sprintf("this machine's %s began waiting on memory %.1f%% more of the time than it had during the run, so the op was stopped first (it had used %s at its peak). Nothing limits an op while the machine has memory to spare", v.Staller, v.Stall, opMiB(v.Peak)) } return fmt.Sprintf("this machine ran down to %s of memory free, under the %s it keeps for its own services%s, so the op was stopped first (it had used %s at its peak). Nothing limits an op while the machine has memory to spare", opMiB(v.Avail), opMiB(v.Floor), opFloorNote(), opMiB(v.Peak)) } func opMemoryWatch(unit string, floor uint64) (stop func() opMemoryVerdict) { done := make(chan struct{}) var mu sync.Mutex v := opMemoryVerdict{Floor: floor} var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() t := time.NewTicker(opMemoryWatchPeriod) defer t.Stop() cur := filepath.Join(opUnitCgroupDir(unit), "memory.current") // Each group's lowest reading during the run: a group that spiked as // the run began must not hide a rise the run causes later, and one // held at its own limit throughout still never counts. low := opOthersStalls() for { if raw, err := os.ReadFile(cur); err == nil { if n, err := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64); err == nil { mu.Lock() if n > v.Peak { v.Peak = n } mu.Unlock() } } _, avail, ok := opMemInfo() short := ok && avail <= floor now := opOthersStalls() rise, who := opStallRise(low, now) for cg, x := range now { if l, ok := low[cg]; !ok || x < l { low[cg] = x } } // Killed on every tick the machine is short, until a kill lands: // one that finds no cgroup yet (the unit still starting) is tried // again, and only a kill that landed counts as the op stopped. if (short || rise >= opMemoryStallMax) && opKillUnit(unit) { mu.Lock() if !v.Stopped { v.Stopped, v.Avail = true, avail if !short { v.Stall, v.Staller = rise, who } } mu.Unlock() } select { case <-done: return case <-t.C: } } }() return func() opMemoryVerdict { close(done) wg.Wait() mu.Lock() defer mu.Unlock() return v } } func opMiB(n uint64) string { return strconv.FormatUint(n>>20, 10) + " MiB" }