dockertop 1.0.0
htop-style view of Docker containers and the processes inside them.
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user