// 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) } // 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) }