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

500 lines
13 KiB
Go

package main
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// Docker is a minimal Engine API client: plain HTTP over the daemon socket.
type Docker struct {
c *http.Client
base string
Host string
}
func newDocker() (*Docker, error) {
host := os.Getenv("DOCKER_HOST")
if host == "" {
host = "unix:///var/run/docker.sock"
}
scheme, addr, _ := strings.Cut(host, "://")
tr := &http.Transport{MaxIdleConnsPerHost: 64}
d := &Docker{c: &http.Client{Transport: tr, Timeout: 60 * time.Second}, Host: host}
switch scheme {
case "unix":
d.base = "http://docker"
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
var dl net.Dialer
return dl.DialContext(ctx, "unix", addr)
}
case "tcp":
// ponytail: no TLS; use a unix socket or an ssh tunnel if the daemon needs it.
if os.Getenv("DOCKER_TLS_VERIFY") != "" {
return nil, errors.New("DOCKER_TLS_VERIFY is set but TLS is not supported; tunnel the socket instead")
}
d.base = "http://" + addr
default:
return nil, fmt.Errorf("unsupported DOCKER_HOST %q (unix:// or tcp:// only)", host)
}
return d, nil
}
// do sends a request and decodes a JSON reply into out (*[]byte takes the raw body).
func (d *Docker) do(method, path string, out any) error {
req, err := http.NewRequest(method, d.base+path, nil)
if err != nil {
return err
}
resp, err := d.c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 { // 304 (already started/stopped) is fine
var e struct{ Message string }
json.NewDecoder(resp.Body).Decode(&e)
if e.Message == "" {
e.Message = resp.Status
}
return errors.New(e.Message)
}
switch o := out.(type) {
case nil:
return nil
case *[]byte:
*o, err = io.ReadAll(resp.Body)
return err
}
return json.NewDecoder(resp.Body).Decode(out)
}
// ---- containers and their stats ----
type Container struct {
ID, Name, Image, Command string
State, Status string // "running", "Up 3 hours (healthy)"
Project string // compose project, "" if none
Created int64
Tree string // tree-view prefix, display only
Group bool // pseudo row: a compose project with its containers summed
HasStats bool // running or paused, stats read
HasRate bool // a previous sample exists, so CPU and rates are valid
CPU float64 // percent of one core
Mem, MemLimit uint64 // bytes
Pids, PidsLimit uint64
NetRx, NetTx float64 // bytes/s
BlkR, BlkW float64 // bytes/s
NetRxTot, NetTxTot uint64
BlkRTot, BlkWTot uint64
cpuNs uint64
at time.Time
Online int
}
func (c Container) MemPct() float64 {
if c.MemLimit == 0 {
return 0
}
return float64(c.Mem) * 100 / float64(c.MemLimit)
}
type Info struct {
Name, ServerVersion, OperatingSystem, CgroupDriver, CgroupVersion string
NCPU int
MemTotal uint64
Images int
}
type stats struct {
Read time.Time
CPU struct {
Usage struct {
Total uint64 `json:"total_usage"`
} `json:"cpu_usage"`
Online int `json:"online_cpus"`
} `json:"cpu_stats"`
Mem struct {
Usage, Limit uint64
Stats map[string]uint64
} `json:"memory_stats"`
Networks map[string]struct {
Rx uint64 `json:"rx_bytes"`
Tx uint64 `json:"tx_bytes"`
}
Pids struct{ Current, Limit uint64 } `json:"pids_stats"`
Blkio struct {
IO []struct {
Op string
Value uint64
} `json:"io_service_bytes_recursive"`
} `json:"blkio_stats"`
}
// collect lists every container and reads stats for the running ones in
// parallel. prev (the last result, by ID) is only read, to compute rates.
func (d *Docker) collect(prev map[string]Container) ([]Container, error) {
var list []struct {
ID string `json:"Id"`
Names []string
Image string
Command string
Created int64
Labels map[string]string
State string
Status string
}
if err := d.do("GET", "/containers/json?all=1", &list); err != nil {
return nil, err
}
cs := make([]Container, len(list))
var wg sync.WaitGroup
for i, l := range list {
name := l.ID[:12]
if len(l.Names) > 0 {
name = strings.TrimPrefix(l.Names[0], "/")
}
cs[i] = Container{ID: l.ID, Name: name, Image: l.Image, Command: l.Command, State: l.State, Status: l.Status,
Project: l.Labels["com.docker.compose.project"], Created: l.Created}
if l.State != "running" && l.State != "paused" {
continue
}
wg.Add(1)
go func(c *Container) {
defer wg.Done()
var s stats
if d.do("GET", "/containers/"+c.ID+"/stats?stream=false&one-shot=true", &s) == nil {
applyStats(c, s, prev[c.ID])
}
}(&cs[i])
}
wg.Wait()
return cs, nil
}
func applyStats(c *Container, s stats, p Container) {
c.HasStats = true
c.at = s.Read
if c.at.IsZero() {
c.at = time.Now()
}
c.cpuNs, c.Online = s.CPU.Usage.Total, s.CPU.Online
// Same as `docker stats`: page cache that can be dropped doesn't count.
inactive := s.Mem.Stats["inactive_file"] // cgroup v2
if v, ok := s.Mem.Stats["total_inactive_file"]; ok {
inactive = v // cgroup v1
}
c.Mem, c.MemLimit = s.Mem.Usage, s.Mem.Limit
if inactive < c.Mem {
c.Mem -= inactive
}
c.Pids, c.PidsLimit = s.Pids.Current, s.Pids.Limit
for _, n := range s.Networks {
c.NetRxTot += n.Rx
c.NetTxTot += n.Tx
}
for _, b := range s.Blkio.IO {
switch strings.ToLower(b.Op) {
case "read":
c.BlkRTot += b.Value
case "write":
c.BlkWTot += b.Value
}
}
if !p.HasStats || p.at.IsZero() {
return
}
dt := c.at.Sub(p.at).Seconds()
if dt <= 0 {
return
}
c.HasRate = true
c.CPU = rate(c.cpuNs, p.cpuNs, dt) / 1e7 // ns/s -> percent of a core
c.NetRx, c.NetTx = rate(c.NetRxTot, p.NetRxTot, dt), rate(c.NetTxTot, p.NetTxTot, dt)
c.BlkR, c.BlkW = rate(c.BlkRTot, p.BlkRTot, dt), rate(c.BlkWTot, p.BlkWTot, dt)
}
// rate is (cur-old)/dt, or 0 when the counter went backwards (container restarted).
func rate(cur, old uint64, dt float64) float64 {
if cur < old {
return 0
}
return float64(cur-old) / dt
}
func (d *Docker) info() (Info, error) {
var i Info
err := d.do("GET", "/info", &i)
return i, err
}
// ---- inspect ----
type Inspect struct {
ID string `json:"Id"`
Name string
Created time.Time
Path string
Args []string
Image string
State struct {
Status string
Pid int
ExitCode int
OOMKilled bool
StartedAt, FinishedAt time.Time
Error string
Health *struct {
Status string
FailingStreak int
Log []struct {
ExitCode int
Output string
}
}
}
RestartCount int
Config struct {
Image, User, WorkingDir, Hostname string
Tty bool
Env, Entrypoint, Cmd []string
Labels map[string]string
}
HostConfig struct {
NanoCpus, CpuQuota, CpuPeriod, Memory, MemorySwap int64
PidsLimit *int64
CpusetCpus, NetworkMode string
Privileged, ReadonlyRootfs bool
RestartPolicy struct{ Name string }
}
Mounts []struct {
Type, Source, Destination, Mode string
RW bool
}
NetworkSettings struct {
Ports map[string][]struct{ HostIp, HostPort string }
Networks map[string]struct{ IPAddress, Gateway, MacAddress string }
}
}
func (d *Docker) inspect(id string) (*Inspect, error) {
var i Inspect
if err := d.do("GET", "/containers/"+id+"/json", &i); err != nil {
return nil, err
}
return &i, nil
}
// CPULimit is the container's CPU allowance in percent of one core, 0 if unlimited.
func (i *Inspect) CPULimit() float64 {
h := i.HostConfig
switch {
case h.NanoCpus > 0:
return float64(h.NanoCpus) / 1e7
case h.CpuQuota > 0 && h.CpuPeriod > 0:
return float64(h.CpuQuota) * 100 / float64(h.CpuPeriod)
}
return 0
}
// details renders inspect output as the lines of the details view.
func (i *Inspect) details(c Container, showEnv bool) []string {
l := []string{}
add := func(f string, a ...any) { l = append(l, fmt.Sprintf(f, a...)) }
when := func(t time.Time) string {
if t.IsZero() || t.Year() < 2000 {
return "-"
}
return t.Local().Format("2006-01-02 15:04:05") + " (" + ago(time.Since(t)) + " ago)"
}
add("Name %s", strings.TrimPrefix(i.Name, "/"))
add("ID %s", i.ID)
add("Image %s (%s)", i.Config.Image, shortID(i.Image))
add("Command %s", strings.TrimSpace(i.Path+" "+strings.Join(i.Args, " ")))
if i.Config.User != "" || i.Config.WorkingDir != "" {
add("User %s workdir %s", or(i.Config.User, "root"), or(i.Config.WorkingDir, "/"))
}
add("Created %s", when(i.Created))
st := i.State
add("State %s pid %d restarts %d policy %s", st.Status, st.Pid, i.RestartCount, or(i.HostConfig.RestartPolicy.Name, "no"))
add("Started %s", when(st.StartedAt))
if st.Status != "running" {
add("Finished %s exit code %d OOM killed %t", when(st.FinishedAt), st.ExitCode, st.OOMKilled)
}
if st.Error != "" {
add("Error %s", st.Error)
}
if h := st.Health; h != nil {
add("Health %s failing streak %d", h.Status, h.FailingStreak)
if n := len(h.Log); n > 0 {
add(" last check: exit %d %s", h.Log[n-1].ExitCode, oneLine(h.Log[n-1].Output))
}
}
add("")
add("Limits")
hc := i.HostConfig
cpu := "unlimited"
if lim := i.CPULimit(); lim > 0 {
cpu = fmt.Sprintf("%.2f CPUs", lim/100)
}
if hc.CpusetCpus != "" {
cpu += " on cpus " + hc.CpusetCpus
}
mem := "unlimited"
if hc.Memory > 0 {
mem = human(uint64(hc.Memory))
}
pids := "unlimited"
if hc.PidsLimit != nil && *hc.PidsLimit > 0 {
pids = fmt.Sprint(*hc.PidsLimit)
}
add(" CPU %s memory %s pids %s privileged %t read-only root %t", cpu, mem, pids, hc.Privileged, hc.ReadonlyRootfs)
if c.HasStats {
add("Usage")
add(" CPU %.1f%% memory %s / %s pids %d", c.CPU, human(c.Mem), human(c.MemLimit), c.Pids)
add(" net rx %s tx %s block read %s write %s (since start)", human(c.NetRxTot), human(c.NetTxTot), human(c.BlkRTot), human(c.BlkWTot))
}
add("")
add("Network mode %s hostname %s", hc.NetworkMode, i.Config.Hostname)
for _, n := range sortedKeys(i.NetworkSettings.Networks) {
v := i.NetworkSettings.Networks[n]
add(" %-24s ip %-16s gw %-16s mac %s", n, or(v.IPAddress, "-"), or(v.Gateway, "-"), or(v.MacAddress, "-"))
}
for _, p := range sortedKeys(i.NetworkSettings.Ports) {
b := i.NetworkSettings.Ports[p]
if len(b) == 0 {
add(" port %-12s (not published)", p)
}
for _, h := range b {
add(" port %-12s -> %s:%s", p, or(h.HostIp, "*"), h.HostPort)
}
}
if len(i.Mounts) > 0 {
add("")
add("Mounts")
for _, m := range i.Mounts {
rw := "ro"
if m.RW {
rw = "rw"
}
add(" %-6s %s %s -> %s", m.Type, rw, m.Source, m.Destination)
}
}
if len(i.Config.Labels) > 0 {
add("")
add("Labels")
for _, k := range sortedKeys(i.Config.Labels) {
add(" %s = %s", k, i.Config.Labels[k])
}
}
add("")
if showEnv {
add("Environment (e to hide)")
for _, e := range i.Config.Env {
add(" %s", e)
}
} else {
add("Environment %d variables hidden, they may hold secrets (e to show)", len(i.Config.Env))
}
return l
}
// ---- processes, logs, actions ----
type Top struct {
Titles []string
Processes [][]string
}
func (d *Docker) top(id string) (*Top, error) {
var t Top
err := d.do("GET", "/containers/"+id+"/top", &t)
return &t, err
}
var ansi = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07`)
func (d *Docker) logs(id string, tty bool, tail int) ([]string, error) {
var b []byte
if err := d.do("GET", fmt.Sprintf("/containers/%s/logs?stdout=1&stderr=1&tail=%d", id, tail), &b); err != nil {
return nil, err
}
if !tty {
b = demux(b)
}
s := strings.ReplaceAll(ansi.ReplaceAllString(string(b), ""), "\r", "")
s = strings.ReplaceAll(s, "\t", " ")
return strings.Split(strings.TrimRight(s, "\n"), "\n"), nil
}
// demux strips the 8-byte stream headers docker puts on non-TTY log output.
func demux(b []byte) []byte {
var out bytes.Buffer
for len(b) >= 8 {
n := int(binary.BigEndian.Uint32(b[4:8]))
b = b[8:]
n = min(n, len(b))
out.Write(b[:n])
b = b[n:]
}
return out.Bytes()
}
// action POSTs a lifecycle call: start, stop, restart, pause, unpause, kill?signal=X.
func (d *Docker) action(id, what string) error {
return d.do("POST", "/containers/"+id+"/"+what, nil)
}
// ---- small helpers ----
func sortedKeys[V any](m map[string]V) []string {
k := make([]string, 0, len(m))
for s := range m {
k = append(k, s)
}
sort.Strings(k)
return k
}
func or(s, def string) string {
if s == "" {
return def
}
return s
}
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }
func shortID(s string) string {
s = strings.TrimPrefix(s, "sha256:")
if len(s) > 12 {
s = s[:12]
}
return s
}
func ago(d time.Duration) string {
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
}
return fmt.Sprintf("%dd%02dh", int(d.Hours())/24, int(d.Hours())%24)
}