feat(instance): метрики всей машины вторым рядом дашборда
Дашборд показывал только контейнер (лимиты профиля), поэтому не было видно, сколько ресурсов у хоста всего. - `sysinfo.NewHostSampler` и `ReadHostMemory` всегда читают `/proc/stat` и `/proc/meminfo`, игнорируя cgroup; - в ответе метрик появились `host_cpu` и `host_memory` (`source: host`); - недоступные источники попадают в проверку `metrics`, а не ломают ручку.
This commit is contained in:
@@ -67,6 +67,8 @@ type metricsCheck struct {
|
|||||||
type metricsPayload struct {
|
type metricsPayload struct {
|
||||||
CPU metricsCPU `json:"cpu"`
|
CPU metricsCPU `json:"cpu"`
|
||||||
Memory metricsMemory `json:"memory"`
|
Memory metricsMemory `json:"memory"`
|
||||||
|
HostCPU metricsCPU `json:"host_cpu"`
|
||||||
|
HostMemory metricsMemory `json:"host_memory"`
|
||||||
Disk metricsDisk `json:"disk"`
|
Disk metricsDisk `json:"disk"`
|
||||||
Database metricsDatabase `json:"database"`
|
Database metricsDatabase `json:"database"`
|
||||||
Checks []metricsCheck `json:"checks"`
|
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 {
|
if disk, err := sysinfo.ReadDisk(s.cfg.DataDir); err != nil {
|
||||||
problems = append(problems, "disk: "+err.Error())
|
problems = append(problems, "disk: "+err.Error())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ type metricsBody struct {
|
|||||||
Percent float64 `json:"percent"`
|
Percent float64 `json:"percent"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
} `json:"memory"`
|
} `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 {
|
Disk struct {
|
||||||
UsedBytes int64 `json:"used_bytes"`
|
UsedBytes int64 `json:"used_bytes"`
|
||||||
TotalBytes int64 `json:"total_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 {
|
} else if payload.Memory.UsedBytes <= 0 || payload.Memory.Percent <= 0 || payload.Memory.Percent > 100 {
|
||||||
t.Fatalf("память = %+v", payload.Memory)
|
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 {
|
if payload.Disk.TotalBytes <= 0 || payload.Disk.UsedBytes <= 0 || payload.Disk.FreeBytes <= 0 {
|
||||||
t.Fatalf("диск = %+v", payload.Disk)
|
t.Fatalf("диск = %+v", payload.Disk)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ type Server struct {
|
|||||||
voice *voice.TokenIssuer
|
voice *voice.TokenIssuer
|
||||||
// voiceAdmin — RoomService для модерации на стороне SFU.
|
// voiceAdmin — RoomService для модерации на стороне SFU.
|
||||||
voiceAdmin *voice.AdminClient
|
voiceAdmin *voice.AdminClient
|
||||||
// sysinfoPaths и cpuSampler — источники живых метрик дашборда инстанса.
|
// sysinfoPaths, cpuSampler и hostCPUSampler — источники живых метрик
|
||||||
|
// дашборда инстанса: контейнер и вся машина.
|
||||||
sysinfoPaths sysinfo.Paths
|
sysinfoPaths sysinfo.Paths
|
||||||
cpuSampler *sysinfo.Sampler
|
cpuSampler *sysinfo.Sampler
|
||||||
|
hostCPUSampler *sysinfo.Sampler
|
||||||
// metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду).
|
// metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду).
|
||||||
metricsLimiter *httpx.RateLimiter
|
metricsLimiter *httpx.RateLimiter
|
||||||
// health — кэш тяжёлых проверок инстанса.
|
// 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),
|
voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL),
|
||||||
}
|
}
|
||||||
s.cpuSampler = sysinfo.NewSampler(s.sysinfoPaths)
|
s.cpuSampler = sysinfo.NewSampler(s.sysinfoPaths)
|
||||||
|
s.hostCPUSampler = sysinfo.NewHostSampler(s.sysinfoPaths)
|
||||||
switch {
|
switch {
|
||||||
case deps.Permissions != nil:
|
case deps.Permissions != nil:
|
||||||
s.perms = deps.Permissions
|
s.perms = deps.Permissions
|
||||||
|
|||||||
@@ -57,16 +57,26 @@ type CPUSample struct {
|
|||||||
// дают нагрузку за последнюю секунду, редкие вызовы — среднее за паузу.
|
// дают нагрузку за последнюю секунду, редкие вызовы — среднее за паузу.
|
||||||
type Sampler struct {
|
type Sampler struct {
|
||||||
paths Paths
|
paths Paths
|
||||||
|
// host заставляет считать нагрузку всей машины (/proc/stat), даже когда
|
||||||
|
// у контейнера есть лимиты: дашборд показывает оба ряда.
|
||||||
|
host bool
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
last *CPUSample
|
last *CPUSample
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSampler создаёт сэмплер процессорного времени.
|
// NewSampler создаёт сэмплер процессорного времени: cgroup контейнера, а при
|
||||||
|
// отсутствии лимитов — вся машина.
|
||||||
func NewSampler(paths Paths) *Sampler {
|
func NewSampler(paths Paths) *Sampler {
|
||||||
return &Sampler{paths: paths}
|
return &Sampler{paths: paths}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewHostSampler создаёт сэмплер нагрузки всей машины: нужен для второго ряда
|
||||||
|
// дашборда, когда контейнер ограничен профилем.
|
||||||
|
func NewHostSampler(paths Paths) *Sampler {
|
||||||
|
return &Sampler{paths: paths, host: true}
|
||||||
|
}
|
||||||
|
|
||||||
// CPUPercent возвращает загрузку в процентах от доступного времени и число
|
// CPUPercent возвращает загрузку в процентах от доступного времени и число
|
||||||
// ядер. Первый вызов без предыдущего снимка возвращает 0: дельты ещё нет.
|
// ядер. Первый вызов без предыдущего снимка возвращает 0: дельты ещё нет.
|
||||||
func (s *Sampler) CPUPercent() (percent float64, cores float64, source string, err error) {
|
func (s *Sampler) CPUPercent() (percent float64, cores float64, source string, err error) {
|
||||||
@@ -109,9 +119,11 @@ func (s *Sampler) CPUPercent() (percent float64, cores float64, source string, e
|
|||||||
// readCPU предпочитает cgroup v2: он показывает нагрузку контейнера и его
|
// readCPU предпочитает cgroup v2: он показывает нагрузку контейнера и его
|
||||||
// лимит, а /proc/stat — всю машину целиком.
|
// лимит, а /proc/stat — всю машину целиком.
|
||||||
func (s *Sampler) readCPU() (CPUSample, float64, string, error) {
|
func (s *Sampler) readCPU() (CPUSample, float64, string, error) {
|
||||||
|
if !s.host {
|
||||||
if sample, cores, ok := s.readCgroupCPU(); ok {
|
if sample, cores, ok := s.readCgroupCPU(); ok {
|
||||||
return sample, cores, "cgroup", nil
|
return sample, cores, "cgroup", nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
sample, cores, err := s.readProcStat()
|
sample, cores, err := s.readProcStat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CPUSample{}, 0, "", err
|
return CPUSample{}, 0, "", err
|
||||||
@@ -234,6 +246,12 @@ func ReadMemory(paths Paths) (Memory, error) {
|
|||||||
return readMemInfo(paths.MemInfo)
|
return readMemInfo(paths.MemInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadHostMemory всегда читает память всей машины (/proc/meminfo): второй ряд
|
||||||
|
// дашборда показывает хост даже при лимитах контейнера.
|
||||||
|
func ReadHostMemory(paths Paths) (Memory, error) {
|
||||||
|
return readMemInfo(paths.MemInfo)
|
||||||
|
}
|
||||||
|
|
||||||
func readCgroupMemory(dir string) (Memory, bool) {
|
func readCgroupMemory(dir string) (Memory, bool) {
|
||||||
current, err := readInt(filepath.Join(dir, "memory.current"))
|
current, err := readInt(filepath.Join(dir, "memory.current"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestReadMemoryPrefersCgroupLimit(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
writeFile(t, dir, "memory.current", "15728640\n")
|
writeFile(t, dir, "memory.current", "15728640\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user