179 lines
5.0 KiB
Go
179 lines
5.0 KiB
Go
package main
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
type IOConsumer struct {
|
||
Kind string `json:"kind"`
|
||
Name string `json:"name"`
|
||
VMID int `json:"vmid,omitempty"`
|
||
ReadBytesPerSec float64 `json:"readBytesPerSec"`
|
||
WriteBytesPerSec float64 `json:"writeBytesPerSec"`
|
||
SharePercent float64 `json:"sharePercent"`
|
||
}
|
||
type processIOSnapshot struct {
|
||
read, write uint64
|
||
name, cmdline, cgroup string
|
||
}
|
||
type ioAttributionCollector struct {
|
||
mu sync.Mutex
|
||
previous map[int]processIOSnapshot
|
||
updatedAt time.Time
|
||
cached []IOConsumer
|
||
}
|
||
|
||
var qemuIDPattern = regexp.MustCompile(`(?:^|\s)-id\s+(\d+)`)
|
||
var lxcIDPattern = regexp.MustCompile(`(?:lxc(?:\.payload)?[./-]|pve-container@)(\d+)`)
|
||
|
||
func (c *ioAttributionCollector) collect(guests GuestsMetrics) []IOConsumer {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
now := time.Now()
|
||
current := readProcessIO()
|
||
elapsed := now.Sub(c.updatedAt).Seconds()
|
||
if c.previous == nil || elapsed <= 0 {
|
||
c.previous = current
|
||
c.updatedAt = now
|
||
return nil
|
||
}
|
||
names := map[int]string{}
|
||
for _, g := range guests.Guests {
|
||
names[g.VMID] = g.Name
|
||
}
|
||
grouped := map[string]*IOConsumer{}
|
||
var total float64
|
||
for pid, snapshot := range current {
|
||
old, ok := c.previous[pid]
|
||
if !ok || snapshot.read < old.read || snapshot.write < old.write {
|
||
continue
|
||
}
|
||
read := float64(snapshot.read-old.read) / elapsed
|
||
write := float64(snapshot.write-old.write) / elapsed
|
||
if read+write < 1024 {
|
||
continue
|
||
}
|
||
kind, name, vmid := classifyIOProcess(snapshot, names)
|
||
key := kind + ":" + strconv.Itoa(vmid) + ":" + name
|
||
consumer := grouped[key]
|
||
if consumer == nil {
|
||
consumer = &IOConsumer{Kind: kind, Name: name, VMID: vmid}
|
||
grouped[key] = consumer
|
||
}
|
||
consumer.ReadBytesPerSec += read
|
||
consumer.WriteBytesPerSec += write
|
||
total += read + write
|
||
}
|
||
var result []IOConsumer
|
||
for _, v := range grouped {
|
||
if total > 0 {
|
||
v.SharePercent = (v.ReadBytesPerSec + v.WriteBytesPerSec) / total * 100
|
||
}
|
||
result = append(result, *v)
|
||
}
|
||
sort.Slice(result, func(i, j int) bool {
|
||
return result[i].ReadBytesPerSec+result[i].WriteBytesPerSec > result[j].ReadBytesPerSec+result[j].WriteBytesPerSec
|
||
})
|
||
if len(result) > 8 {
|
||
result = result[:8]
|
||
}
|
||
c.previous = current
|
||
c.updatedAt = now
|
||
c.cached = result
|
||
return result
|
||
}
|
||
|
||
func classifyIOProcess(p processIOSnapshot, names map[int]string) (kind, name string, vmid int) {
|
||
if match := qemuIDPattern.FindStringSubmatch(p.cmdline); len(match) > 1 {
|
||
vmid, _ = strconv.Atoi(match[1])
|
||
return "VM", firstNonEmpty(names[vmid], "VM "+match[1]), vmid
|
||
}
|
||
if match := lxcIDPattern.FindStringSubmatch(p.cgroup); len(match) > 1 {
|
||
vmid, _ = strconv.Atoi(match[1])
|
||
return "LXC", firstNonEmpty(names[vmid], "LXC "+match[1]), vmid
|
||
}
|
||
return "Процесс", firstNonEmpty(p.name, "PID"), 0
|
||
}
|
||
|
||
func readProcessIO() map[int]processIOSnapshot {
|
||
result := map[int]processIOSnapshot{}
|
||
entries, _ := filepath.Glob("/proc/[0-9]*/io")
|
||
for _, path := range entries {
|
||
pid, _ := strconv.Atoi(filepath.Base(filepath.Dir(path)))
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
var item processIOSnapshot
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
key, value, ok := strings.Cut(line, ":")
|
||
if !ok {
|
||
continue
|
||
}
|
||
n, _ := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
||
if key == "read_bytes" {
|
||
item.read = n
|
||
} else if key == "write_bytes" {
|
||
item.write = n
|
||
}
|
||
}
|
||
comm, _ := os.ReadFile(filepath.Join(filepath.Dir(path), "comm"))
|
||
cmd, _ := os.ReadFile(filepath.Join(filepath.Dir(path), "cmdline"))
|
||
cg, _ := os.ReadFile(filepath.Join(filepath.Dir(path), "cgroup"))
|
||
item.name = strings.TrimSpace(string(comm))
|
||
item.cmdline = strings.ReplaceAll(string(cmd), "\x00", " ")
|
||
item.cgroup = string(cg)
|
||
result[pid] = item
|
||
}
|
||
return result
|
||
}
|
||
|
||
func attributeDiskIO(metrics *dashboardMetrics, consumers []IOConsumer) {
|
||
active := activeIOOperation(metrics.Activity.Tasks)
|
||
for i := range metrics.Disks {
|
||
disk := &metrics.Disks[i]
|
||
disk.TopConsumers = append([]IOConsumer(nil), consumers...)
|
||
if active != "" {
|
||
disk.IOCause = active
|
||
disk.IOCauseDetail = "Активная задача Proxmox совпадает по времени с дисковой нагрузкой"
|
||
disk.IOConfidence = 75
|
||
} else if len(consumers) > 0 {
|
||
top := consumers[0]
|
||
disk.IOCause = top.Kind + " · " + top.Name
|
||
disk.IOCauseDetail = "Крупнейший потребитель host I/O: " + strconv.FormatFloat(top.SharePercent, 'f', 1, 64) + "%"
|
||
disk.IOConfidence = 55
|
||
}
|
||
}
|
||
}
|
||
|
||
func activeIOOperation(tasks []ProxmoxTask) string {
|
||
for _, task := range tasks {
|
||
if task.EndTime != 0 && task.Status != "" {
|
||
continue
|
||
}
|
||
label := ""
|
||
switch task.Type {
|
||
case "vzdump":
|
||
label = "Backup"
|
||
case "qmigrate":
|
||
label = "Миграция"
|
||
case "qmsnapshot", "vzsnapshot":
|
||
label = "Snapshot"
|
||
}
|
||
if label != "" {
|
||
if task.ID != "" {
|
||
label += " VM/LXC " + task.ID
|
||
}
|
||
return label
|
||
}
|
||
}
|
||
return ""
|
||
}
|