379 lines
12 KiB
Go
379 lines
12 KiB
Go
|
|
// 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
|
|||
|
|
|
|||
|
|
mu sync.Mutex
|
|||
|
|
last *CPUSample
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewSampler создаёт сэмплер процессорного времени.
|
|||
|
|
func NewSampler(paths Paths) *Sampler {
|
|||
|
|
return &Sampler{paths: paths}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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 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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
blockSize := toInt64From32(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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// toInt64From32 переводит 32-битное беззнаковое значение (размер блока statfs).
|
|||
|
|
func toInt64From32(value uint32) int64 {
|
|||
|
|
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)
|
|||
|
|
}
|