diff --git a/internal/server/api_metrics.go b/internal/server/api_metrics.go index ecf2c08..ab1284e 100644 --- a/internal/server/api_metrics.go +++ b/internal/server/api_metrics.go @@ -67,6 +67,8 @@ type metricsCheck struct { type metricsPayload struct { CPU metricsCPU `json:"cpu"` Memory metricsMemory `json:"memory"` + HostCPU metricsCPU `json:"host_cpu"` + HostMemory metricsMemory `json:"host_memory"` Disk metricsDisk `json:"disk"` Database metricsDatabase `json:"database"` Checks []metricsCheck `json:"checks"` @@ -145,6 +147,25 @@ func (s *Server) collectMetrics(ctx context.Context) metricsPayload { } } + // Второй ряд дашборда — вся машина: видно, сколько ресурсов у хоста всего. + if s.hostCPUSampler == nil { + problems = append(problems, "host cpu: sampler is not configured") + } else if percent, cores, source, err := s.hostCPUSampler.CPUPercent(); err != nil { + problems = append(problems, "host cpu: "+err.Error()) + } else { + payload.HostCPU = metricsCPU{Percent: round1(percent), Cores: cores, Source: source} + } + if memory, err := sysinfo.ReadHostMemory(s.sysinfoPaths); err != nil { + problems = append(problems, "host memory: "+err.Error()) + } else { + payload.HostMemory = metricsMemory{ + UsedBytes: memory.UsedBytes, + TotalBytes: memory.TotalBytes, + Percent: round1(memory.Percent()), + Source: memory.Source, + } + } + if disk, err := sysinfo.ReadDisk(s.cfg.DataDir); err != nil { problems = append(problems, "disk: "+err.Error()) } else { diff --git a/internal/server/api_metrics_test.go b/internal/server/api_metrics_test.go index 4ba4c95..0eaa7ab 100644 --- a/internal/server/api_metrics_test.go +++ b/internal/server/api_metrics_test.go @@ -24,6 +24,17 @@ type metricsBody struct { Percent float64 `json:"percent"` Source string `json:"source"` } `json:"memory"` + HostCPU struct { + Percent float64 `json:"percent"` + Cores float64 `json:"cores"` + Source string `json:"source"` + } `json:"host_cpu"` + HostMemory struct { + UsedBytes int64 `json:"used_bytes"` + TotalBytes int64 `json:"total_bytes"` + Percent float64 `json:"percent"` + Source string `json:"source"` + } `json:"host_memory"` Disk struct { UsedBytes int64 `json:"used_bytes"` TotalBytes int64 `json:"total_bytes"` @@ -105,6 +116,18 @@ func TestInstanceMetricsPayload(t *testing.T) { } else if payload.Memory.UsedBytes <= 0 || payload.Memory.Percent <= 0 || payload.Memory.Percent > 100 { t.Fatalf("память = %+v", payload.Memory) } + + // Второй ряд дашборда — вся машина. + if payload.HostCPU.Source == "" { + t.Log("источники процессора хоста недоступны (не Linux) — проверка пропущена") + } else if payload.HostCPU.Source != "host" || payload.HostCPU.Cores <= 0 { + t.Fatalf("процессор хоста = %+v", payload.HostCPU) + } + if payload.HostMemory.TotalBytes == 0 { + t.Log("память хоста недоступна (не Linux) — проверка пропущена") + } else if payload.HostMemory.Source != "host" || payload.HostMemory.Percent <= 0 { + t.Fatalf("память хоста = %+v", payload.HostMemory) + } if payload.Disk.TotalBytes <= 0 || payload.Disk.UsedBytes <= 0 || payload.Disk.FreeBytes <= 0 { t.Fatalf("диск = %+v", payload.Disk) } diff --git a/internal/server/server.go b/internal/server/server.go index e46b280..5bf9dec 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -67,9 +67,11 @@ type Server struct { voice *voice.TokenIssuer // voiceAdmin — RoomService для модерации на стороне SFU. voiceAdmin *voice.AdminClient - // sysinfoPaths и cpuSampler — источники живых метрик дашборда инстанса. - sysinfoPaths sysinfo.Paths - cpuSampler *sysinfo.Sampler + // sysinfoPaths, cpuSampler и hostCPUSampler — источники живых метрик + // дашборда инстанса: контейнер и вся машина. + sysinfoPaths sysinfo.Paths + cpuSampler *sysinfo.Sampler + hostCPUSampler *sysinfo.Sampler // metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду). metricsLimiter *httpx.RateLimiter // health — кэш тяжёлых проверок инстанса. @@ -116,6 +118,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL), } s.cpuSampler = sysinfo.NewSampler(s.sysinfoPaths) + s.hostCPUSampler = sysinfo.NewHostSampler(s.sysinfoPaths) switch { case deps.Permissions != nil: s.perms = deps.Permissions diff --git a/internal/sysinfo/sysinfo.go b/internal/sysinfo/sysinfo.go index 70daac0..258d1f6 100644 --- a/internal/sysinfo/sysinfo.go +++ b/internal/sysinfo/sysinfo.go @@ -57,16 +57,26 @@ type CPUSample struct { // дают нагрузку за последнюю секунду, редкие вызовы — среднее за паузу. type Sampler struct { paths Paths + // host заставляет считать нагрузку всей машины (/proc/stat), даже когда + // у контейнера есть лимиты: дашборд показывает оба ряда. + host bool mu sync.Mutex last *CPUSample } -// NewSampler создаёт сэмплер процессорного времени. +// 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) { @@ -109,8 +119,10 @@ func (s *Sampler) CPUPercent() (percent float64, cores float64, source string, e // 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 + if !s.host { + if sample, cores, ok := s.readCgroupCPU(); ok { + return sample, cores, "cgroup", nil + } } sample, cores, err := s.readProcStat() if err != nil { @@ -234,6 +246,12 @@ func ReadMemory(paths Paths) (Memory, error) { 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 { diff --git a/internal/sysinfo/sysinfo_test.go b/internal/sysinfo/sysinfo_test.go index ed8eec8..8e54e14 100644 --- a/internal/sysinfo/sysinfo_test.go +++ b/internal/sysinfo/sysinfo_test.go @@ -87,6 +87,53 @@ func TestCPUPercentFallsBackToProcStat(t *testing.T) { } } +func TestHostSamplerIgnoresCgroup(t *testing.T) { + dir := t.TempDir() + // cgroup показывает нагрузку контейнера, /proc/stat — всей машины. + writeFile(t, dir, "cpu.max", "200000 100000\n") + writeFile(t, dir, "cpu.stat", "usage_usec 1000000\n") + statPath := writeFile(t, dir, "stat", "cpu 100 0 100 800 0 0 0 0 0 0\n") + + now := time.Unix(1_700_000_000, 0) + paths := sysinfo.Paths{ + ProcStat: statPath, + MemInfo: filepath.Join(dir, "meminfo"), + CgroupDir: dir, + WallClock: func() time.Time { return now }, + } + + host := sysinfo.NewHostSampler(paths) + if _, cores, source, err := host.CPUPercent(); err != nil || source != "host" || cores < 1 { + t.Fatalf("хост-сэмплер: источник=%s ядер=%v err=%v", source, cores, err) + } + container := sysinfo.NewSampler(paths) + if _, cores, source, err := container.CPUPercent(); err != nil || source != "cgroup" || cores != 2 { + t.Fatalf("контейнерный сэмплер: источник=%s ядер=%v err=%v", source, cores, err) + } +} + +func TestReadHostMemoryIgnoresCgroupLimit(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.ReadHostMemory(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" || memory.TotalBytes != 3479592*1024 { + t.Fatalf("память хоста = %+v", memory) + } + if memory.UsedBytes != (3479592-1000000)*1024 { + t.Fatalf("занято = %d", memory.UsedBytes) + } +} + func TestReadMemoryPrefersCgroupLimit(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "memory.current", "15728640\n")