feat(instance): метрики всей машины вторым рядом дашборда

Дашборд показывал только контейнер (лимиты профиля), поэтому не было видно,
сколько ресурсов у хоста всего.

- `sysinfo.NewHostSampler` и `ReadHostMemory` всегда читают `/proc/stat` и
  `/proc/meminfo`, игнорируя cgroup;
- в ответе метрик появились `host_cpu` и `host_memory` (`source: host`);
- недоступные источники попадают в проверку `metrics`, а не ломают ручку.
This commit is contained in:
2026-09-20 22:10:09 +03:00
parent 3cfab89965
commit 5668d779df
5 changed files with 118 additions and 6 deletions
+21 -3
View File
@@ -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 {
+47
View File
@@ -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")