251344f880
The status page answered "are the components running" but said nothing about the machine underneath them, so a server slowed to a crawl by a busy processor or one about to have Postfix OOM-killed looked entirely healthy until the queue backed up. internal/health/machine.go reads the kernel's counters in /proc: the aggregate processor times and core count from /proc/stat, the load average from /proc/loadavg, memory and swap from /proc/meminfo, and per-interface byte counters from /proc/net/dev. CPU busy time and network throughput are rates, so a MachineSampler holds the previous reading and each call reports the difference — one shared sampler on the Server, since a per-request one would never have anything to subtract. A window longer than a minute only re-baselines: a page opened after the panel sat idle would otherwise average that whole stretch and present it as the current load. Memory is derived from MemAvailable rather than MemFree, because Linux spends every spare page on cache and MemFree would report a permanent emergency. A fully busy processor (>=90%) warns and an exhausted machine (>=97%) errors, both counting towards the page's headline verdict, since either delays or kills the mail path. Throughput has no comparable threshold — what counts as a lot depends on the link — so it is reported and never graded. Loopback is excluded: that traffic is the container talking to itself. Like every other check here, an unreadable counter degrades to "unknown" with an explanation instead of failing the page, so the panel still runs outside Linux for development. The usage bars are <meter> elements. The panel's CSP has no inline-style exemption, so a bar's length has to travel on an attribute; the element also grades its own colour from low/high/optimum, and the percentage is printed beside it for anything that does not render meters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
558 lines
17 KiB
Go
558 lines
17 KiB
Go
package health
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// machineSampleWindow is the longest gap between two readings that still counts
|
||
// as "now". CPU busy time and network throughput are differences between two
|
||
// samples, and the status fragment polls every five seconds — but a page opened
|
||
// after the panel sat idle for an hour would otherwise average that whole hour
|
||
// and present it as the current load. Past this the reading only re-baselines,
|
||
// and the rates say they are still being measured.
|
||
const machineSampleWindow = time.Minute
|
||
|
||
// Thresholds for the two resources whose exhaustion actually threatens mail: a
|
||
// fully busy processor slows queue processing, and a machine out of memory has
|
||
// its processes killed. Network throughput has no comparable threshold — what
|
||
// counts as a lot depends entirely on the link — so it is reported, not graded.
|
||
const (
|
||
cpuWarnPct = 90.0
|
||
memWarnPct = 90.0
|
||
memErrorPct = 97.0
|
||
)
|
||
|
||
// Machine is the resource usage of the machine this container runs on: the
|
||
// status page's answer to "is the server itself under strain", next to the
|
||
// component checks that answer "is mail flowing".
|
||
//
|
||
// The numbers come from the kernel's /proc filesystem, which a container shares
|
||
// with its host unless it was started with its own namespaces — so CPU and
|
||
// memory describe the host, while /proc/net/dev describes whatever network
|
||
// namespace the container is in (its own veth under the default bridge, the
|
||
// host's interfaces under network_mode: host).
|
||
type Machine struct {
|
||
CPU CPU
|
||
Memory Memory
|
||
Network Network
|
||
// Window is the interval the rates were measured over; zero until a
|
||
// second reading exists.
|
||
Window time.Duration
|
||
Status Status
|
||
}
|
||
|
||
// WindowText names the sampling interval for the card's description.
|
||
func (m Machine) WindowText() string {
|
||
if m.Window <= 0 {
|
||
return ""
|
||
}
|
||
return m.Window.Round(time.Second).String()
|
||
}
|
||
|
||
// CPU is processor load over the sampling window.
|
||
type CPU struct {
|
||
// Measured is false until two readings exist to compare; BusyPct means
|
||
// nothing until it is true.
|
||
Measured bool
|
||
BusyPct float64
|
||
Cores int
|
||
// Load is the 1/5/15-minute load average, present when /proc/loadavg
|
||
// could be read. Unlike BusyPct it needs no previous sample, so it is
|
||
// there on the very first page load.
|
||
Load [3]float64
|
||
HasLoad bool
|
||
Status Status
|
||
Detail string
|
||
}
|
||
|
||
// Percent is BusyPct as a whole number, for the <meter> element's value
|
||
// attribute. The bar carries its value in an attribute rather than a width in a
|
||
// style attribute because the panel's CSP has no inline-style exemption
|
||
// (security.md).
|
||
func (c CPU) Percent() int { return percent(c.BusyPct) }
|
||
|
||
// BusyText is the reading as it appears beside the bar.
|
||
func (c CPU) BusyText() string { return fmt.Sprintf("%d%%", c.Percent()) }
|
||
|
||
// Memory is main memory (and swap, where the machine has any) at the moment of
|
||
// the reading. Unlike CPU and network it is a level, not a rate, so a single
|
||
// reading is enough and it is never in the "measuring" state.
|
||
type Memory struct {
|
||
Measured bool
|
||
TotalBytes uint64
|
||
AvailableBytes uint64
|
||
UsedBytes uint64
|
||
UsedPct float64
|
||
SwapTotalBytes uint64
|
||
SwapUsedBytes uint64
|
||
Status Status
|
||
Detail string
|
||
}
|
||
|
||
// Percent, UsedText and TotalText render the reading for the template; see
|
||
// CPU.Percent for why the bar's value travels as an attribute.
|
||
func (m Memory) Percent() int { return percent(m.UsedPct) }
|
||
func (m Memory) UsedText() string { return humanBytes(m.UsedBytes) }
|
||
func (m Memory) TotalText() string { return humanBytes(m.TotalBytes) }
|
||
func (m Memory) PctText() string { return fmt.Sprintf("%d%%", m.Percent()) }
|
||
|
||
// Interface is one network interface's traffic: the counters since the
|
||
// interface came up, and the throughput over the sampling window.
|
||
type Interface struct {
|
||
Name string
|
||
RxBytes uint64
|
||
TxBytes uint64
|
||
RxRate float64 // bytes per second, valid when the Network is Measured
|
||
TxRate float64
|
||
Measured bool
|
||
}
|
||
|
||
func (i Interface) InText() string { return humanBytes(i.RxBytes) }
|
||
func (i Interface) OutText() string { return humanBytes(i.TxBytes) }
|
||
func (i Interface) InRateText() string { return humanRate(i.RxRate) }
|
||
func (i Interface) OutRateText() string { return humanRate(i.TxRate) }
|
||
|
||
// Network is the traffic across every interface that has carried any, loopback
|
||
// excluded — loopback traffic is the container talking to itself (the panel to
|
||
// SQLite, Postfix to its milters) and says nothing about the link.
|
||
type Network struct {
|
||
Measured bool
|
||
Interfaces []Interface
|
||
RxRate float64
|
||
TxRate float64
|
||
Status Status
|
||
Detail string
|
||
}
|
||
|
||
func (n Network) InRateText() string { return humanRate(n.RxRate) }
|
||
func (n Network) OutRateText() string { return humanRate(n.TxRate) }
|
||
|
||
// MachineSampler reads those counters. Its zero value is ready to use and it is
|
||
// safe for concurrent use, but one sampler has to be shared by every caller:
|
||
// the rates are measured against the reading the previous call left behind, so
|
||
// a fresh sampler per request would never have anything to compare against.
|
||
type MachineSampler struct {
|
||
// procRoot replaces /proc in tests; empty means the real one.
|
||
procRoot string
|
||
|
||
mu sync.Mutex
|
||
prevAt time.Time
|
||
prevCPU cpuTimes
|
||
prevNet map[string]netCounters
|
||
}
|
||
|
||
func (m *MachineSampler) root() string {
|
||
if m.procRoot == "" {
|
||
return "/proc"
|
||
}
|
||
return m.procRoot
|
||
}
|
||
|
||
// cpuTimes is the aggregate of /proc/stat's "cpu" line: all time accounted for,
|
||
// and the part of it the processor spent doing nothing.
|
||
type cpuTimes struct {
|
||
total uint64
|
||
idle uint64
|
||
}
|
||
|
||
// netCounters is one interface's byte counters from /proc/net/dev.
|
||
type netCounters struct {
|
||
rx uint64
|
||
tx uint64
|
||
}
|
||
|
||
// Sample reads the current counters and reports usage since the previous call.
|
||
// Like every other check here it never fails: a counter that cannot be read
|
||
// becomes an unknown status with an explanation, so a kernel that does not
|
||
// publish one of these files (or a panel run outside Linux for development)
|
||
// costs one line of the card rather than the page.
|
||
func (m *MachineSampler) Sample() Machine {
|
||
root := m.root()
|
||
now := time.Now()
|
||
cpuNow, cores, cpuErr := readCPUTimes(root)
|
||
netNow, netErr := readNetDev(root)
|
||
|
||
m.mu.Lock()
|
||
prevAt, prevCPU, prevNet := m.prevAt, m.prevCPU, m.prevNet
|
||
if cpuErr == nil {
|
||
m.prevCPU = cpuNow
|
||
}
|
||
if netErr == nil {
|
||
m.prevNet = netNow
|
||
}
|
||
if cpuErr == nil || netErr == nil {
|
||
m.prevAt = now
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
// A window of zero (two calls in the same instant) would divide by zero;
|
||
// one longer than machineSampleWindow is no longer a description of now.
|
||
window := now.Sub(prevAt)
|
||
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
|
||
|
||
mach := Machine{
|
||
CPU: cpuUsage(prevCPU, cpuNow, cores, readLoadAvg(root), fresh, cpuErr),
|
||
Memory: readMemory(root),
|
||
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
|
||
}
|
||
if fresh {
|
||
mach.Window = window
|
||
}
|
||
mach.Status = Worst(mach.CPU.Status, mach.Memory.Status, mach.Network.Status)
|
||
return mach
|
||
}
|
||
|
||
// cpuUsage grades the processor over the window. The load average is reported
|
||
// alongside it because the two answer different questions — how busy the
|
||
// processor was, and how many tasks were waiting for it — and a machine can
|
||
// look idle while work queues up behind a slow disk.
|
||
func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err error) CPU {
|
||
c := CPU{Cores: cores}
|
||
if !isZeroLoad(load) {
|
||
c.Load, c.HasLoad = load, true
|
||
}
|
||
if err != nil {
|
||
c.Status = StatusUnknown
|
||
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
|
||
return c
|
||
}
|
||
if !fresh || cur.total <= prev.total {
|
||
c.Status = StatusUnknown
|
||
c.Detail = joinDetail("Measuring — this reading sets the baseline; the next refresh has the figure.", c.contextText())
|
||
return c
|
||
}
|
||
|
||
total := cur.total - prev.total
|
||
idle := uint64(0)
|
||
if cur.idle > prev.idle {
|
||
idle = cur.idle - prev.idle
|
||
}
|
||
if idle > total {
|
||
idle = total
|
||
}
|
||
c.Measured = true
|
||
c.BusyPct = 100 * float64(total-idle) / float64(total)
|
||
|
||
if c.BusyPct >= cpuWarnPct {
|
||
c.Status = StatusWarn
|
||
c.Detail = joinDetail(c.contextText(), "The processor is close to fully busy, which slows queue processing and every panel page.")
|
||
} else {
|
||
c.Status = StatusOK
|
||
c.Detail = c.contextText()
|
||
}
|
||
return c
|
||
}
|
||
|
||
// contextText is the CPU's supporting figures: what the percentage is a
|
||
// percentage of, and how deep the run queue is.
|
||
func (c CPU) contextText() string {
|
||
var parts []string
|
||
if c.Cores > 0 {
|
||
parts = append(parts, fmt.Sprintf("%d core(s)", c.Cores))
|
||
}
|
||
if c.HasLoad {
|
||
parts = append(parts, fmt.Sprintf("load average %.2f, %.2f, %.2f", c.Load[0], c.Load[1], c.Load[2]))
|
||
}
|
||
return strings.Join(parts, " · ")
|
||
}
|
||
|
||
// readMemory reports main memory from /proc/meminfo. Used is derived from
|
||
// MemAvailable rather than MemFree: Linux spends every spare page on cache, so
|
||
// MemFree on a healthy machine is near zero and would report a permanent
|
||
// emergency. MemAvailable is the kernel's own estimate of what a new workload
|
||
// could actually get.
|
||
func readMemory(root string) Memory {
|
||
var m Memory
|
||
fields, err := readMeminfo(root)
|
||
if err != nil {
|
||
m.Status = StatusUnknown
|
||
m.Detail = "The kernel's memory counters (/proc/meminfo) could not be read here."
|
||
return m
|
||
}
|
||
total, available := fields["MemTotal"], fields["MemAvailable"]
|
||
if total == 0 {
|
||
m.Status = StatusUnknown
|
||
m.Detail = "/proc/meminfo does not report a total memory size."
|
||
return m
|
||
}
|
||
if available > total {
|
||
available = total
|
||
}
|
||
|
||
m.Measured = true
|
||
m.TotalBytes = total
|
||
m.AvailableBytes = available
|
||
m.UsedBytes = total - available
|
||
m.UsedPct = 100 * float64(m.UsedBytes) / float64(total)
|
||
m.SwapTotalBytes = fields["SwapTotal"]
|
||
if swapFree := fields["SwapFree"]; m.SwapTotalBytes > swapFree {
|
||
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
|
||
}
|
||
|
||
detail := fmt.Sprintf("%s used of %s; %s available to new work.",
|
||
humanBytes(m.UsedBytes), humanBytes(total), humanBytes(available))
|
||
if m.SwapTotalBytes > 0 {
|
||
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
|
||
}
|
||
switch {
|
||
case m.UsedPct >= memErrorPct:
|
||
m.Status = StatusError
|
||
m.Detail = detail + " Memory is exhausted; the kernel kills processes to reclaim it, and Postfix or the panel are candidates."
|
||
case m.UsedPct >= memWarnPct:
|
||
m.Status = StatusWarn
|
||
m.Detail = detail + " Little headroom left."
|
||
default:
|
||
m.Status = StatusOK
|
||
m.Detail = detail
|
||
}
|
||
return m
|
||
}
|
||
|
||
// networkUsage turns two readings of the interface counters into throughput.
|
||
// It never grades: there is no usage figure that is wrong for a mail server, so
|
||
// the row is informational and only reports "unknown" when the counters are
|
||
// unreadable.
|
||
func networkUsage(prev, cur map[string]netCounters, window time.Duration, fresh bool, err error) Network {
|
||
var n Network
|
||
if err != nil {
|
||
n.Status = StatusUnknown
|
||
n.Detail = "The kernel's network counters (/proc/net/dev) could not be read here."
|
||
return n
|
||
}
|
||
|
||
names := make([]string, 0, len(cur))
|
||
for name := range cur {
|
||
names = append(names, name)
|
||
}
|
||
// Map order is random, and this table is re-rendered every few seconds:
|
||
// without a sort the rows would shuffle under the reader.
|
||
sort.Strings(names)
|
||
|
||
n.Measured = fresh
|
||
for _, name := range names {
|
||
c := cur[name]
|
||
// An interface that has never carried a byte is a veth or a bridge
|
||
// the deployment happens to have, not part of the mail path.
|
||
if c.rx == 0 && c.tx == 0 {
|
||
continue
|
||
}
|
||
iface := Interface{Name: name, RxBytes: c.rx, TxBytes: c.tx, Measured: fresh}
|
||
if fresh {
|
||
p := prev[name]
|
||
secs := window.Seconds()
|
||
// Counters only go up; a drop means the interface (or the
|
||
// container) was recreated, so there is no rate to report.
|
||
if c.rx >= p.rx {
|
||
iface.RxRate = float64(c.rx-p.rx) / secs
|
||
}
|
||
if c.tx >= p.tx {
|
||
iface.TxRate = float64(c.tx-p.tx) / secs
|
||
}
|
||
n.RxRate += iface.RxRate
|
||
n.TxRate += iface.TxRate
|
||
}
|
||
n.Interfaces = append(n.Interfaces, iface)
|
||
}
|
||
|
||
n.Status = StatusOK
|
||
switch {
|
||
case len(n.Interfaces) == 0:
|
||
n.Detail = "No interface outside loopback has carried any traffic."
|
||
case !fresh:
|
||
n.Detail = "Measuring — this reading sets the baseline; the next refresh has the throughput."
|
||
}
|
||
return n
|
||
}
|
||
|
||
// readCPUTimes returns the aggregate processor times and the number of cores
|
||
// from /proc/stat. The times are in USER_HZ ticks, which cancel out because
|
||
// only their ratio is used.
|
||
func readCPUTimes(root string) (cpuTimes, int, error) {
|
||
data, err := os.ReadFile(filepath.Join(root, "stat"))
|
||
if err != nil {
|
||
return cpuTimes{}, 0, err
|
||
}
|
||
var (
|
||
times cpuTimes
|
||
cores int
|
||
found bool
|
||
)
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
fields := strings.Fields(line)
|
||
if len(fields) < 5 || !strings.HasPrefix(fields[0], "cpu") {
|
||
continue
|
||
}
|
||
if fields[0] != "cpu" {
|
||
cores++ // cpu0, cpu1, … — one line per core
|
||
continue
|
||
}
|
||
// user nice system idle iowait irq softirq steal (guest fields are
|
||
// already counted inside user/nice, so they are left out).
|
||
for i, f := range fields[1:] {
|
||
if i >= 8 {
|
||
break
|
||
}
|
||
v, err := strconv.ParseUint(f, 10, 64)
|
||
if err != nil {
|
||
return cpuTimes{}, 0, fmt.Errorf("/proc/stat: unreadable cpu time %q", f)
|
||
}
|
||
times.total += v
|
||
// idle plus iowait: both are time the processor had nothing to
|
||
// run, and separating them tells the reader nothing here.
|
||
if i == 3 || i == 4 {
|
||
times.idle += v
|
||
}
|
||
}
|
||
found = true
|
||
}
|
||
if !found {
|
||
return cpuTimes{}, 0, fmt.Errorf("/proc/stat: no aggregate cpu line")
|
||
}
|
||
return times, cores, nil
|
||
}
|
||
|
||
// readLoadAvg reads the 1/5/15-minute load averages. A machine without
|
||
// /proc/loadavg simply has none reported, so the failure is a zero value rather
|
||
// than an error.
|
||
func readLoadAvg(root string) [3]float64 {
|
||
var load [3]float64
|
||
data, err := os.ReadFile(filepath.Join(root, "loadavg"))
|
||
if err != nil {
|
||
return load
|
||
}
|
||
fields := strings.Fields(string(data))
|
||
if len(fields) < 3 {
|
||
return load
|
||
}
|
||
for i := range load {
|
||
v, err := strconv.ParseFloat(fields[i], 64)
|
||
if err != nil {
|
||
return [3]float64{}
|
||
}
|
||
load[i] = v
|
||
}
|
||
return load
|
||
}
|
||
|
||
func isZeroLoad(load [3]float64) bool {
|
||
return load == [3]float64{}
|
||
}
|
||
|
||
// readMeminfo returns the /proc/meminfo entries this package uses, in bytes.
|
||
// The file reports kB (kibibytes, despite the label) for these fields.
|
||
func readMeminfo(root string) (map[string]uint64, error) {
|
||
data, err := os.ReadFile(filepath.Join(root, "meminfo"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
want := map[string]bool{"MemTotal": true, "MemAvailable": true, "SwapTotal": true, "SwapFree": true}
|
||
out := make(map[string]uint64, len(want))
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
name, rest, ok := strings.Cut(line, ":")
|
||
if !ok || !want[name] {
|
||
continue
|
||
}
|
||
fields := strings.Fields(rest)
|
||
if len(fields) == 0 {
|
||
continue
|
||
}
|
||
v, err := strconv.ParseUint(fields[0], 10, 64)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if len(fields) > 1 && strings.EqualFold(fields[1], "kB") {
|
||
v *= 1024
|
||
}
|
||
out[name] = v
|
||
}
|
||
if len(out) == 0 {
|
||
return nil, fmt.Errorf("/proc/meminfo: no readable fields")
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// readNetDev returns each interface's byte counters from /proc/net/dev, loopback
|
||
// excluded. Each line is " eth0: <rx bytes> <rx packets> … <tx bytes> …" — the
|
||
// name is separated by a colon, which may or may not have a space after it, so
|
||
// the split is on the colon and not on whitespace.
|
||
func readNetDev(root string) (map[string]netCounters, error) {
|
||
data, err := os.ReadFile(filepath.Join(root, "net", "dev"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make(map[string]netCounters)
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
name, rest, ok := strings.Cut(line, ":")
|
||
name = strings.TrimSpace(name)
|
||
if !ok || name == "" || name == "lo" || strings.Contains(name, " ") {
|
||
continue // header lines carry no name, or several words
|
||
}
|
||
fields := strings.Fields(rest)
|
||
if len(fields) < 9 {
|
||
continue
|
||
}
|
||
rx, err1 := strconv.ParseUint(fields[0], 10, 64)
|
||
tx, err2 := strconv.ParseUint(fields[8], 10, 64)
|
||
if err1 != nil || err2 != nil {
|
||
continue
|
||
}
|
||
out[name] = netCounters{rx: rx, tx: tx}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// percent rounds a 0–100 reading to a whole number and clamps it, so a bar's
|
||
// value attribute is always inside the range the element declares.
|
||
func percent(v float64) int {
|
||
switch {
|
||
case v <= 0:
|
||
return 0
|
||
case v >= 100:
|
||
return 100
|
||
default:
|
||
return int(v + 0.5)
|
||
}
|
||
}
|
||
|
||
// humanBytes renders a byte count in the binary units memory and traffic are
|
||
// conventionally read in.
|
||
func humanBytes(b uint64) string {
|
||
const unit = 1024
|
||
if b < unit {
|
||
return fmt.Sprintf("%d B", b)
|
||
}
|
||
div, exp := uint64(unit), 0
|
||
for n := b / unit; n >= unit && exp < 4; n /= unit {
|
||
div *= unit
|
||
exp++
|
||
}
|
||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTP"[exp])
|
||
}
|
||
|
||
// humanRate renders throughput in bytes per second, to match the totals beside
|
||
// it rather than the bits per second a link is sold in.
|
||
func humanRate(perSec float64) string {
|
||
if perSec < 0 {
|
||
perSec = 0
|
||
}
|
||
return humanBytes(uint64(perSec+0.5)) + "/s"
|
||
}
|
||
|
||
// joinDetail joins the non-empty parts of a detail line.
|
||
func joinDetail(parts ...string) string {
|
||
var kept []string
|
||
for _, p := range parts {
|
||
if p != "" {
|
||
kept = append(kept, p)
|
||
}
|
||
}
|
||
return strings.Join(kept, " ")
|
||
}
|