Files
glchat/internal/sysinfo/sysinfo.go
T
grendervill 5668d779df feat(instance): метрики всей машины вторым рядом дашборда
Дашборд показывал только контейнер (лимиты профиля), поэтому не было видно,
сколько ресурсов у хоста всего.

- `sysinfo.NewHostSampler` и `ReadHostMemory` всегда читают `/proc/stat` и
  `/proc/meminfo`, игнорируя cgroup;
- в ответе метрик появились `host_cpu` и `host_memory` (`source: host`);
- недоступные источники попадают в проверку `metrics`, а не ломают ручку.
2026-09-20 22:10:09 +03:00

393 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package sysinfo читает нагрузку хоста и контейнера для дашборда инстанса:
// процессор, память, диск и размеры файлов. Значения берутся из cgroup v2
// (контейнер ограничен профилем установки), а если лимитов нет — из /proc.
package sysinfo
import (
"bufio"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// userHZ — тиков в секунду для /proc/stat: в Linux это всегда 100, независимо
// от CONFIG_HZ ядра.
const userHZ = 100
// Paths — пути к источникам данных. Вынесены в структуру, чтобы тесты
// подменяли их временными файлами, а не зависели от окружения.
type Paths struct {
ProcStat string
MemInfo string
CgroupDir string
WallClock func() time.Time
}
// DefaultPaths возвращает источники по умолчанию для Linux-контейнера.
func DefaultPaths() Paths {
return Paths{
ProcStat: "/proc/stat",
MemInfo: "/proc/meminfo",
CgroupDir: "/sys/fs/cgroup",
WallClock: time.Now,
}
}
// CPUSample — снимок процессорного времени.
type CPUSample struct {
// UsageMicros — накопленное использованное время.
UsageMicros float64
// TotalMicros — накопленное доступное время (режим хоста); в режиме
// cgroup не используется, там ёмкость считается по квоте и времени.
TotalMicros float64
// QuotaPerSecond — доступное время в микросекундах за секунду (cgroup).
QuotaPerSecond float64
At time.Time
}
// Sampler считает загрузку процессора между вызовами: вызовы раз в секунду
// дают нагрузку за последнюю секунду, редкие вызовы — среднее за паузу.
type Sampler struct {
paths Paths
// host заставляет считать нагрузку всей машины (/proc/stat), даже когда
// у контейнера есть лимиты: дашборд показывает оба ряда.
host bool
mu sync.Mutex
last *CPUSample
}
// NewSampler создаёт сэмплер процессорного времени: cgroup контейнера, а при
// отсутствии лимитов — вся машина.
func NewSampler(paths Paths) *Sampler {
return &Sampler{paths: paths}
}
// NewHostSampler создаёт сэмплер нагрузки всей машины: нужен для второго ряда
// дашборда, когда контейнер ограничен профилем.
func NewHostSampler(paths Paths) *Sampler {
return &Sampler{paths: paths, host: true}
}
// CPUPercent возвращает загрузку в процентах от доступного времени и число
// ядер. Первый вызов без предыдущего снимка возвращает 0: дельты ещё нет.
func (s *Sampler) CPUPercent() (percent float64, cores float64, source string, err error) {
sample, cores, source, err := s.readCPU()
if err != nil {
return 0, cores, source, err
}
s.mu.Lock()
previous := s.last
s.last = &sample
s.mu.Unlock()
if previous == nil {
return 0, cores, source, nil
}
used := sample.UsageMicros - previous.UsageMicros
if used < 0 {
used = 0
}
elapsed := sample.At.Sub(previous.At).Seconds()
if elapsed <= 0 {
return 0, cores, source, nil
}
var capacity float64
if sample.QuotaPerSecond > 0 {
// Контейнер с лимитом: доступное время = квота × прошедшее время.
capacity = sample.QuotaPerSecond * elapsed
} else {
// Вся машина: доступное время = прирост суммарных тиков всех ядер.
capacity = sample.TotalMicros - previous.TotalMicros
}
if capacity <= 0 {
return 0, cores, source, nil
}
return used / capacity * 100, cores, source, nil
}
// readCPU предпочитает cgroup v2: он показывает нагрузку контейнера и его
// лимит, а /proc/stat — всю машину целиком.
func (s *Sampler) readCPU() (CPUSample, float64, string, error) {
if !s.host {
if sample, cores, ok := s.readCgroupCPU(); ok {
return sample, cores, "cgroup", nil
}
}
sample, cores, err := s.readProcStat()
if err != nil {
return CPUSample{}, 0, "", err
}
return sample, cores, "host", nil
}
// readCgroupCPU читает cpu.stat и cpu.max (cgroup v2). Квота задаётся парой
// «quota period»; значение «max» означает отсутствие лимита.
func (s *Sampler) readCgroupCPU() (CPUSample, float64, bool) {
usage, err := readMicros(filepath.Join(s.paths.CgroupDir, "cpu.stat"), "usage_usec")
if err != nil {
return CPUSample{}, 0, false
}
cores, ok := s.cpuQuota()
if !ok {
return CPUSample{}, 0, false
}
return CPUSample{
UsageMicros: usage,
QuotaPerSecond: cores * 1e6,
At: s.now(),
}, cores, true
}
// cpuQuota возвращает число доступных ядер по cpu.max.
func (s *Sampler) cpuQuota() (float64, bool) {
raw, err := os.ReadFile(filepath.Join(s.paths.CgroupDir, "cpu.max"))
if err != nil {
return 0, false
}
fields := strings.Fields(string(raw))
if len(fields) != 2 || fields[0] == "max" {
return 0, false
}
quota, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, false
}
period, err := strconv.ParseFloat(fields[1], 64)
if err != nil || period <= 0 {
return 0, false
}
cores := quota / period
if cores <= 0 {
return 0, false
}
return cores, true
}
// readProcStat считает загрузку всей машины по первой строке /proc/stat.
func (s *Sampler) readProcStat() (CPUSample, float64, error) {
file, err := os.Open(s.paths.ProcStat)
if err != nil {
return CPUSample{}, 0, fmt.Errorf("sysinfo.cpu: %w", err)
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 5 || fields[0] != "cpu" {
continue
}
var total, idle float64
for index, field := range fields[1:] {
value, err := strconv.ParseFloat(field, 64)
if err != nil {
return CPUSample{}, 0, fmt.Errorf("sysinfo.cpu: %w", err)
}
total += value
// idle и iowait — время, когда процессор не работал.
if index == 3 || index == 4 {
idle += value
}
}
if total <= 0 {
return CPUSample{}, 0, errors.New("sysinfo.cpu: пустой счётчик")
}
return CPUSample{
UsageMicros: ticksToMicros(total - idle),
TotalMicros: ticksToMicros(total),
At: s.now(),
}, float64(runtime.NumCPU()), nil
}
if err := scanner.Err(); err != nil {
return CPUSample{}, 0, fmt.Errorf("sysinfo.cpu: %w", err)
}
return CPUSample{}, 0, errors.New("sysinfo.cpu: строка cpu не найдена")
}
func (s *Sampler) now() time.Time {
if s.paths.WallClock == nil {
return time.Now()
}
return s.paths.WallClock()
}
// Memory — занятая и доступная память в байтах.
type Memory struct {
UsedBytes int64
TotalBytes int64
Source string
}
// Percent возвращает заполнение памяти в процентах.
func (m Memory) Percent() float64 {
if m.TotalBytes <= 0 {
return 0
}
return float64(m.UsedBytes) / float64(m.TotalBytes) * 100
}
// ReadMemory отдаёт лимит и потребление контейнера (cgroup v2), а при его
// отсутствии — память всей машины по /proc/meminfo.
func ReadMemory(paths Paths) (Memory, error) {
if memory, ok := readCgroupMemory(paths.CgroupDir); ok {
return memory, nil
}
return readMemInfo(paths.MemInfo)
}
// ReadHostMemory всегда читает память всей машины (/proc/meminfo): второй ряд
// дашборда показывает хост даже при лимитах контейнера.
func ReadHostMemory(paths Paths) (Memory, error) {
return readMemInfo(paths.MemInfo)
}
func readCgroupMemory(dir string) (Memory, bool) {
current, err := readInt(filepath.Join(dir, "memory.current"))
if err != nil {
return Memory{}, false
}
total, err := readInt(filepath.Join(dir, "memory.max"))
if err != nil || total <= 0 {
// memory.max = "max" или файла нет: лимит не выставлен.
return Memory{}, false
}
return Memory{UsedBytes: current, TotalBytes: total, Source: "cgroup"}, true
}
func readMemInfo(path string) (Memory, error) {
file, err := os.Open(path) //nolint:gosec // путь задаёт сам инстанс (по умолчанию /proc/meminfo)
if err != nil {
return Memory{}, fmt.Errorf("sysinfo.memory: %w", err)
}
defer func() { _ = file.Close() }()
var total, available int64
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 {
continue
}
value, err := strconv.ParseInt(fields[1], 10, 64)
if err != nil {
continue
}
switch strings.TrimSuffix(fields[0], ":") {
case "MemTotal":
total = value * 1024
case "MemAvailable":
available = value * 1024
}
}
if err := scanner.Err(); err != nil {
return Memory{}, fmt.Errorf("sysinfo.memory: %w", err)
}
if total <= 0 {
return Memory{}, errors.New("sysinfo.memory: MemTotal не найден")
}
return Memory{UsedBytes: total - available, TotalBytes: total, Source: "host"}, nil
}
// Disk — занятое и общее место раздела, на котором лежат данные инстанса.
type Disk struct {
UsedBytes int64
TotalBytes int64
FreeBytes int64
}
// ReadDisk считает место по пути данных (в контейнере это примонтированный
// каталог хоста).
func ReadDisk(path string) (Disk, error) {
var stats syscall.Statfs_t
if err := syscall.Statfs(path, &stats); err != nil {
return Disk{}, fmt.Errorf("sysinfo.disk: %w", err)
}
// Bsize в Linux — int64, в Darwin — uint32: приведение безопасно в обоих случаях.
blockSize := int64(stats.Bsize)
total := saturatingMul(toInt64(stats.Blocks), blockSize)
free := saturatingMul(toInt64(stats.Bavail), blockSize)
return Disk{UsedBytes: total - free, TotalBytes: total, FreeBytes: free}, nil
}
// FileBytes суммирует размеры существующих файлов: отсутствующие считаются
// нулём, поэтому WAL и SHM можно передавать всегда.
func FileBytes(paths ...string) int64 {
var total int64
for _, path := range paths {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
continue
}
total += info.Size()
}
return total
}
// toInt64 переводит беззнаковый размер в int64 с насыщением: значения statfs
// для реальных разделов далеки от предела, но переполнение недопустимо.
func toInt64(value uint64) int64 {
if value > math.MaxInt64 {
return math.MaxInt64
}
return int64(value)
}
// saturatingMul умножает с насыщением: размер раздела не должен переполняться.
func saturatingMul(a, b int64) int64 {
if a <= 0 || b <= 0 {
return 0
}
if a > math.MaxInt64/b {
return math.MaxInt64
}
return a * b
}
func ticksToMicros(ticks float64) float64 {
return ticks / userHZ * 1e6
}
func readInt(path string) (int64, error) {
raw, err := os.ReadFile(path) //nolint:gosec // путь задаёт сам инстанс (cgroup/proc или временный каталог в тестах)
if err != nil {
return 0, err
}
return strconv.ParseInt(strings.TrimSpace(string(raw)), 10, 64)
}
func readMicros(path, key string) (float64, error) {
file, err := os.Open(path) //nolint:gosec // путь задаёт сам инстанс (cgroup/proc)
if err != nil {
return 0, err
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) != 2 || fields[0] != key {
continue
}
value, err := strconv.ParseFloat(fields[1], 64)
if err != nil {
return 0, err
}
return value, nil
}
if err := scanner.Err(); err != nil {
return 0, err
}
return 0, fmt.Errorf("sysinfo: %s не найден в %s", key, path)
}