Files
3x-ui/internal/util/sys/lxc_metrics_linux.go
T
VAIO73 746bdbe9f4 fix(monitoring): read LXC CPU, swap and uptime from the container (#6920)
In an LXC container with LXCFS the Overview showed the container's RAM
beside the host's swap, the host's CPU topology and a CPU percentage from
the host's view. Swap and uptime now come from LXCFS's virtualised files
and CPU from the container's cgroup v2 counter, normalised by the visible
vCPUs and any quota; container CPU and swap history is kept apart from
the earlier host samples.

A value the reader cannot measure (the first CPU sample, or a layout it
cannot verify such as cgroup v1) is reported as unavailable instead of 0:
the dashboard, the node list, node CPU history and the Telegram and
Discord uptime show a dash, and nodes keep the flags in a new
unavailable_metrics column.
2026-10-24 20:24:34 +02:00

275 lines
6.8 KiB
Go

//go:build linux
package sys
import (
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
type LXCReader struct {
ProcRoot, CgroupRoot string
mu sync.Mutex
lastCPU uint64
lastAt time.Time
lastCapacity float64
sampled bool
}
func (r *LXCReader) roots() (string, string) {
proc := r.ProcRoot
if proc == "" {
proc = HostProc()
}
cg := r.CgroupRoot
if cg == "" {
cg = "/sys/fs/cgroup"
}
return proc, cg
}
type lxcMounts struct {
sources map[string]bool
cgroupRoot bool
}
func (r *LXCReader) detect() (lxcMounts, error) {
proc, cg := r.roots()
data, err := os.ReadFile(filepath.Join(proc, "self/mountinfo"))
if err != nil {
return lxcMounts{}, err
}
mounts := lxcMounts{sources: map[string]bool{}}
valid := false
for _, line := range strings.Split(string(data), "\n") {
parts := strings.SplitN(line, " - ", 2)
if len(parts) != 2 {
continue
}
mount, fs := strings.Fields(parts[0]), strings.Fields(parts[1])
if len(mount) <= 4 || len(fs) == 0 {
continue
}
valid = true
if fs[0] == "cgroup2" && mount[3] == "/" && mount[4] == cg {
mounts.cgroupRoot = true
}
if fs[0] == "fuse.lxcfs" {
switch mount[4] {
case "/proc/meminfo", "/proc/cpuinfo", "/proc/uptime":
mounts.sources[mount[4]] = true
}
}
}
if !valid {
return lxcMounts{}, errors.New("invalid mountinfo")
}
return mounts, nil
}
func (r *LXCReader) UseScopedHistory() bool {
mounts, err := r.detect()
return len(mounts.sources) > 0 || err != nil
}
func (r *LXCReader) resetCPU() {
r.mu.Lock()
r.sampled = false
r.mu.Unlock()
}
// LXCFS supplies container swap and uptime; sysinfo bypasses those mounts.
func (r *LXCReader) Read(now time.Time) (*LXCStats, error) {
mounts, err := r.detect()
if len(mounts.sources) == 0 && err == nil {
r.resetCPU()
return nil, nil
}
out := &LXCStats{Scope: "container"}
if err != nil {
r.resetCPU()
out.Scope = "unknown"
out.Unavailable = []string{"cpu", "swap", "uptime"}
return out, fmt.Errorf("cannot determine metric scope: %w", err)
}
proc, cg := r.roots()
if mounts.sources["/proc/meminfo"] {
mem, err := readLXCBytes(filepath.Join(proc, "meminfo"))
total, tok := mem["SwapTotal"]
free, fok := mem["SwapFree"]
if err == nil && tok && fok && free <= total {
out.SwapTotal = total
out.SwapUsed = total - free
} else {
out.Unavailable = append(out.Unavailable, "swap")
}
} else {
out.Unavailable = append(out.Unavailable, "swap")
}
if mounts.sources["/proc/uptime"] {
data, err := os.ReadFile(filepath.Join(proc, "uptime"))
fields := strings.Fields(string(data))
if err == nil && len(fields) > 0 {
value, e := strconv.ParseFloat(fields[0], 64)
if e == nil && !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0 && value < float64(math.MaxUint64) {
out.Uptime = uint64(value)
} else {
err = errors.New("invalid uptime")
}
} else {
err = errors.New("missing uptime")
}
if err != nil {
out.Unavailable = append(out.Unavailable, "uptime")
}
} else {
out.Unavailable = append(out.Unavailable, "uptime")
}
if !mounts.sources["/proc/cpuinfo"] || !mounts.cgroupRoot || !containerInitAtRoot(proc) {
r.resetCPU()
out.Unavailable = append(out.Unavailable, "cpu")
return out, nil
}
capacity, cpus, err := r.cpuCapacity(proc, cg)
out.CPUs = cpus
usage, usageErr := readUsage(filepath.Join(cg, "cpu.stat"))
r.mu.Lock()
defer r.mu.Unlock()
if err != nil || usageErr != nil {
r.sampled = false
out.Unavailable = append(out.Unavailable, "cpu")
} else {
elapsed := now.Sub(r.lastAt).Seconds()
if r.sampled && elapsed > 0 && usage >= r.lastCPU && capacity == r.lastCapacity {
out.CPU = math.Min(100, float64(usage-r.lastCPU)/1e6/elapsed/capacity*100)
} else {
out.Unavailable = append(out.Unavailable, "cpu")
}
r.lastCPU, r.lastAt, r.lastCapacity, r.sampled = usage, now, capacity, true
}
return out, nil
}
// PID 1 must belong to this namespace root, not a host-visible LXC subtree.
func containerInitAtRoot(proc string) bool {
data, err := os.ReadFile(filepath.Join(proc, "1/cgroup"))
if err != nil {
return false
}
switch strings.TrimSpace(string(data)) {
case "0::/", "0::/init.scope":
return true
default:
return false
}
}
func readLXCBytes(path string) (map[string]uint64, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
wanted := map[string]bool{"SwapTotal": true, "SwapFree": true}
values := map[string]uint64{}
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
key := strings.TrimSuffix(fields[0], ":")
if !wanted[key] {
continue
}
if len(fields) != 3 || fields[2] != "kB" {
return nil, errors.New("invalid meminfo unit")
}
if _, exists := values[key]; exists {
return nil, errors.New("duplicate meminfo field")
}
value, e := strconv.ParseUint(fields[1], 10, 64)
if e != nil || value > math.MaxUint64/1024 {
return nil, errors.New("invalid meminfo value")
}
values[key] = value * 1024
}
return values, nil
}
func (r *LXCReader) cpuCapacity(proc, cg string) (float64, int, error) {
data, err := os.ReadFile(filepath.Join(proc, "cpuinfo"))
if err != nil {
return 0, 0, err
}
ids := map[uint64]bool{}
for _, line := range strings.Split(string(data), "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok || strings.TrimSpace(key) != "processor" {
continue
}
id, e := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
if e != nil || ids[id] {
return 0, 0, errors.New("invalid CPU list")
}
ids[id] = true
}
cpus := len(ids)
if cpus == 0 {
return 0, 0, errors.New("empty CPU list")
}
capacity := float64(cpus)
data, err = os.ReadFile(filepath.Join(cg, "cpu.max"))
if err != nil {
return 0, cpus, err
}
fields := strings.Fields(string(data))
if len(fields) != 2 {
return 0, cpus, errors.New("invalid CPU quota")
}
period, e := strconv.ParseUint(fields[1], 10, 64)
if e != nil || period == 0 {
return 0, cpus, errors.New("invalid CPU period")
}
if fields[0] != "max" {
quota, e := strconv.ParseUint(fields[0], 10, 64)
if e != nil || quota == 0 {
return 0, cpus, errors.New("invalid CPU quota")
}
capacity = math.Min(capacity, float64(quota)/float64(period))
}
return capacity, cpus, nil
}
func readUsage(path string) (uint64, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
var usage uint64
found := false
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "usage_usec" {
continue
}
if len(fields) != 2 || found {
return 0, errors.New("invalid cgroup CPU usage")
}
usage, err = strconv.ParseUint(fields[1], 10, 64)
if err != nil {
return 0, err
}
found = true
}
if !found {
return 0, errors.New("missing cgroup CPU usage")
}
return usage, nil
}