feat(instance): метрики и healthcheck инстанса для дашборда
Раздел «Инстанс» получает живые показатели сервера (AGENT.md 7.19). - `internal/sysinfo`: загрузка процессора по cgroup v2 (`cpu.stat`/`cpu.max`) с откатом на `/proc/stat`, память по лимиту контейнера либо `/proc/meminfo`, место на разделе данных и размер базы вместе с журналом WAL; - `GET /api/v1/instance/metrics` (только администратор инстанса): метрики, состояние (`ok|warn|failed`), проверки app/database/disk/storage/voice/ gateway, версия и время работы; недоступные источники не ломают ответ, а попадают в проверку `metrics`; - тяжёлые проверки (БД, SFU, запись) кэшируются на 5 секунд: ручку можно опрашивать раз в секунду; - `voice.IssueRoomList` и `AdminClient.Ping` проверяют доступность RoomService и валидность ключей LiveKit (та же связка, что ломала модерацию); - у метрик свой лимит частоты, они исключены из общего лимита API (`RateLimiter.MiddlewareExcept`), иначе открытый раздел съедал бы половину бюджета запросов; - тесты: `internal/sysinfo` (cgroup, /proc, память, диск, размеры файлов) и `internal/server/api_metrics_test.go` (доступ, состав метрик, кэш, 429).
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user