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:
2026-09-20 21:26:17 +03:00
parent 8ae8ca1a1d
commit 4c3736b5fb
8 changed files with 1128 additions and 19 deletions
+378
View File
@@ -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)
}
+177
View File
@@ -0,0 +1,177 @@
package sysinfo_test
import (
"os"
"path/filepath"
"testing"
"time"
"glchat/internal/sysinfo"
)
// Тесты работают на временных файлах: значения cgroup и /proc подменяются,
// поэтому проверки не зависят от окружения и работают в контейнере CI.
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("запись %s: %v", name, err)
}
return path
}
func TestCPUPercentUsesCgroupQuota(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "cpu.max", "200000 100000\n") // 2 ядра
writeFile(t, dir, "cpu.stat", "usage_usec 1000000\nuser_usec 900000\nsystem_usec 100000\n")
now := time.Unix(1_700_000_000, 0)
paths := sysinfo.Paths{
ProcStat: filepath.Join(dir, "stat"),
MemInfo: filepath.Join(dir, "meminfo"),
CgroupDir: dir,
WallClock: func() time.Time { return now },
}
sampler := sysinfo.NewSampler(paths)
// Первый вызов только запоминает снимок.
if percent, _, source, err := sampler.CPUPercent(); err != nil || percent != 0 || source != "cgroup" {
t.Fatalf("первый замер = %v, %s, %v", percent, source, err)
}
// За секунду израсходована половина квоты (1 с из 2 ядро-секунд) → 50 %.
now = now.Add(time.Second)
writeFile(t, dir, "cpu.stat", "usage_usec 2000000\n")
percent, cores, source, err := sampler.CPUPercent()
if err != nil {
t.Fatalf("второй замер: %v", err)
}
if cores != 2 || source != "cgroup" {
t.Fatalf("ядер=%v источник=%s", cores, source)
}
if percent < 49 || percent > 51 {
t.Fatalf("загрузка = %.2f %%, ожидалось ~50 %%", percent)
}
}
func TestCPUPercentFallsBackToProcStat(t *testing.T) {
dir := t.TempDir()
statPath := writeFile(t, dir, "stat", "cpu 100 0 100 800 0 0 0 0 0 0\n")
writeFile(t, dir, "meminfo", "MemTotal: 1000 kB\nMemAvailable: 400 kB\n")
now := time.Unix(1_700_000_000, 0)
sampler := sysinfo.NewSampler(sysinfo.Paths{
ProcStat: statPath,
MemInfo: filepath.Join(dir, "meminfo"),
CgroupDir: filepath.Join(dir, "нет-cgroup"),
WallClock: func() time.Time { return now },
})
if _, _, source, err := sampler.CPUPercent(); err != nil || source != "host" {
t.Fatalf("источник = %s, %v", source, err)
}
// За интервал прибавилось 150 тиков занятости и 150 простоя → 50 %.
now = now.Add(time.Second)
writeFile(t, dir, "stat", "cpu 175 0 175 950 0 0 0 0 0 0\n")
percent, cores, source, err := sampler.CPUPercent()
if err != nil {
t.Fatalf("второй замер: %v", err)
}
if source != "host" || cores <= 0 {
t.Fatalf("источник=%s ядер=%v", source, cores)
}
if percent < 49 || percent > 51 {
t.Fatalf("загрузка = %.2f %%, ожидалось ~50 %%", percent)
}
}
func TestReadMemoryPrefersCgroupLimit(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "memory.current", "15728640\n")
writeFile(t, dir, "memory.max", "805306368\n")
writeFile(t, dir, "meminfo", "MemTotal: 3479592 kB\nMemAvailable: 1000000 kB\n")
memory, err := sysinfo.ReadMemory(sysinfo.Paths{
MemInfo: filepath.Join(dir, "meminfo"),
CgroupDir: dir,
ProcStat: filepath.Join(dir, "stat"),
})
if err != nil {
t.Fatalf("память: %v", err)
}
if memory.Source != "cgroup" || memory.TotalBytes != 805306368 || memory.UsedBytes != 15728640 {
t.Fatalf("память = %+v", memory)
}
if percent := memory.Percent(); percent < 1.9 || percent > 2.0 {
t.Fatalf("заполнение = %.2f %%", percent)
}
}
func TestReadMemoryFallsBackToMemInfo(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "meminfo", "MemTotal: 1000 kB\nMemAvailable: 250 kB\n")
memory, err := sysinfo.ReadMemory(sysinfo.Paths{
MemInfo: filepath.Join(dir, "meminfo"),
CgroupDir: filepath.Join(dir, "нет-cgroup"),
ProcStat: filepath.Join(dir, "stat"),
})
if err != nil {
t.Fatalf("память: %v", err)
}
if memory.Source != "host" || memory.TotalBytes != 1024_000 || memory.UsedBytes != 768_000 {
t.Fatalf("память = %+v", memory)
}
}
func TestReadMemoryUnlimitedCgroupMax(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "memory.current", "1000\n")
writeFile(t, dir, "memory.max", "max\n")
writeFile(t, dir, "meminfo", "MemTotal: 2000 kB\nMemAvailable: 500 kB\n")
memory, err := sysinfo.ReadMemory(sysinfo.Paths{
MemInfo: filepath.Join(dir, "meminfo"),
CgroupDir: dir,
ProcStat: filepath.Join(dir, "stat"),
})
if err != nil {
t.Fatalf("память: %v", err)
}
if memory.Source != "host" {
t.Fatalf("при memory.max=max ожидался хостовый режим, получено %+v", memory)
}
}
func TestReadDiskAndFileBytes(t *testing.T) {
dir := t.TempDir()
disk, err := sysinfo.ReadDisk(dir)
if err != nil {
t.Fatalf("диск: %v", err)
}
if disk.TotalBytes <= 0 || disk.UsedBytes <= 0 || disk.FreeBytes <= 0 {
t.Fatalf("диск = %+v", disk)
}
if disk.UsedBytes+disk.FreeBytes > disk.TotalBytes {
t.Fatalf("занято+свободно больше общего: %+v", disk)
}
writeFile(t, dir, "glchat.db", "0123456789")
writeFile(t, dir, "glchat.db-wal", "01234")
if got := sysinfo.FileBytes(filepath.Join(dir, "glchat.db"), filepath.Join(dir, "glchat.db-wal"), filepath.Join(dir, "нет.db")); got != 15 {
t.Fatalf("размер файлов = %d, ожидалось 15", got)
}
}
func TestCPUPercentWithoutProcStat(t *testing.T) {
sampler := sysinfo.NewSampler(sysinfo.Paths{
ProcStat: filepath.Join(t.TempDir(), "нет"),
MemInfo: filepath.Join(t.TempDir(), "нет"),
CgroupDir: filepath.Join(t.TempDir(), "нет"),
})
if _, _, _, err := sampler.CPUPercent(); err == nil {
t.Fatal("без источников ожидалась ошибка")
}
}