Files
Andy Dixon 25dd8505a0 dockertop 1.0.0
htop-style view of Docker containers and the processes inside them.
2026-10-07 13:08:54 +01:00

147 lines
4.0 KiB
Go

package main
import (
"bytes"
"os"
"strconv"
"strings"
"time"
)
// userHZ is the kernel's clock tick for /proc/PID/stat times.
// ponytail: fixed at 100 on every mainstream Linux arch; read sysconf if a port needs otherwise.
const userHZ = 100
var pageSize = uint64(os.Getpagesize())
type Proc struct {
PID, PPID, UID int
Tree string // tree-view prefix, display only
User, Comm, Cmd string
State byte
Pri, Nice int
Threads int
Virt, RSS uint64 // bytes
Ticks uint64 // utime+stime
CPU float64
HasCPU bool
}
// readProcs reads /proc for the given host PIDs. prev maps PID to its Ticks
// at time then, for the CPU column.
func readProcs(pids []int, prev map[int]uint64, dt float64) []Proc {
out := make([]Proc, 0, len(pids))
for _, pid := range pids {
p, ok := readProc(pid)
if !ok {
continue // exited between docker top and now
}
if t, ok := prev[pid]; ok && dt > 0 && p.Ticks >= t {
p.CPU, p.HasCPU = float64(p.Ticks-t)/userHZ/dt*100, true
}
out = append(out, p)
}
return out
}
func readProc(pid int) (Proc, bool) {
dir := "/proc/" + strconv.Itoa(pid)
b, err := os.ReadFile(dir + "/stat")
if err != nil {
return Proc{}, false
}
p, ok := parseStat(b)
if !ok {
return Proc{}, false
}
p.PID = pid
// stat's rss is the kernel's approximate counter (0 for small processes on
// recent kernels); status has the exact VmRSS, and the UID.
for _, line := range strings.Split(string(readFile(dir+"/status")), "\n") {
k, v, _ := strings.Cut(line, ":")
f := strings.Fields(v)
if len(f) == 0 {
continue
}
switch k {
case "Uid":
p.UID, _ = strconv.Atoi(f[0])
case "VmRSS":
kb, _ := strconv.ParseUint(f[0], 10, 64)
p.RSS = kb * 1024
}
}
cmd := bytes.TrimRight(readFile(dir+"/cmdline"), "\x00")
p.Cmd = string(bytes.ReplaceAll(cmd, []byte{0}, []byte{' '}))
if p.Cmd == "" {
p.Cmd = "[" + p.Comm + "]"
}
return p, true
}
// parseStat parses /proc/PID/stat. comm is in parentheses and may itself
// contain spaces and parentheses, so fields are counted from the last ')'.
func parseStat(b []byte) (Proc, bool) {
s := string(b)
l, r := strings.IndexByte(s, '('), strings.LastIndexByte(s, ')')
if l < 0 || r < l {
return Proc{}, false
}
f := strings.Fields(s[r+1:])
if len(f) < 22 {
return Proc{}, false
}
n := func(i int) uint64 { v, _ := strconv.ParseUint(f[i], 10, 64); return v }
i := func(i int) int { v, _ := strconv.Atoi(f[i]); return v }
return Proc{
Comm: s[l+1 : r], State: f[0][0], PPID: i(1),
Ticks: n(11) + n(12), Pri: i(15), Nice: i(16), Threads: i(17),
Virt: n(20), RSS: n(21) * pageSize,
}, true
}
// isLocal says whether pid is in this host's /proc and belongs to container id,
// i.e. docker runs on this machine and we can read process details ourselves.
func isLocal(pid int, id string) bool {
return bytes.Contains(readFile("/proc/"+strconv.Itoa(pid)+"/cgroup"), []byte(id))
}
// containerUsers maps UIDs to names from the container's own /etc/passwd,
// reached through /proc/PID/root (needs root). Empty if unreadable.
func containerUsers(pid int) map[int]string {
m := map[int]string{}
for _, line := range strings.Split(string(readFile("/proc/"+strconv.Itoa(pid)+"/root/etc/passwd")), "\n") {
f := strings.Split(line, ":")
if len(f) > 2 {
if uid, err := strconv.Atoi(f[2]); err == nil {
m[uid] = f[0]
}
}
}
return m
}
func readFile(path string) []byte {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
return b
}
// cpuTime formats ticks as htop's TIME+: m:ss.cc, or h:mm:ss past an hour.
func cpuTime(ticks uint64) string {
d := time.Duration(ticks) * time.Second / userHZ
if d >= time.Hour {
return strconv.Itoa(int(d.Hours())) + "h" + pad2(int(d.Minutes())%60) + ":" + pad2(int(d.Seconds())%60)
}
return strconv.Itoa(int(d.Minutes())) + ":" + pad2(int(d.Seconds())%60) + "." + pad2(int(ticks%userHZ))
}
func pad2(n int) string {
if n < 10 {
return "0" + strconv.Itoa(n)
}
return strconv.Itoa(n)
}