Skip to content

Commit d6f8bd7

Browse files
authored
feat(cli): add coder stat command (#8005)
1 parent c3aef93 commit d6f8bd7

26 files changed

+1801
-70
lines changed

cli/clistat/cgroup.go

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
package clistat
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"strconv"
7+
"strings"
8+
9+
"github.com/spf13/afero"
10+
"golang.org/x/xerrors"
11+
"tailscale.com/types/ptr"
12+
)
13+
14+
// Paths for CGroupV1.
15+
// Ref: https://www.kernel.org/doc/Documentation/cgroup-v1/cpuacct.txt
16+
const (
17+
// CPU usage of all tasks in cgroup in nanoseconds.
18+
cgroupV1CPUAcctUsage = "/sys/fs/cgroup/cpu/cpuacct.usage"
19+
// Alternate path
20+
cgroupV1CPUAcctUsageAlt = "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage"
21+
// CFS quota and period for cgroup in MICROseconds
22+
cgroupV1CFSQuotaUs = "/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us"
23+
cgroupV1CFSPeriodUs = "/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us"
24+
// Maximum memory usable by cgroup in bytes
25+
cgroupV1MemoryMaxUsageBytes = "/sys/fs/cgroup/memory/memory.max_usage_in_bytes"
26+
// Current memory usage of cgroup in bytes
27+
cgroupV1MemoryUsageBytes = "/sys/fs/cgroup/memory/memory.usage_in_bytes"
28+
// Other memory stats - we are interested in total_inactive_file
29+
cgroupV1MemoryStat = "/sys/fs/cgroup/memory/memory.stat"
30+
)
31+
32+
// Paths for CGroupV2.
33+
// Ref: https://docs.kernel.org/admin-guide/cgroup-v2.html
34+
const (
35+
// Contains quota and period in microseconds separated by a space.
36+
cgroupV2CPUMax = "/sys/fs/cgroup/cpu.max"
37+
// Contains current CPU usage under usage_usec
38+
cgroupV2CPUStat = "/sys/fs/cgroup/cpu.stat"
39+
// Contains current cgroup memory usage in bytes.
40+
cgroupV2MemoryUsageBytes = "/sys/fs/cgroup/memory.current"
41+
// Contains max cgroup memory usage in bytes.
42+
cgroupV2MemoryMaxBytes = "/sys/fs/cgroup/memory.max"
43+
// Other memory stats - we are interested in total_inactive_file
44+
cgroupV2MemoryStat = "/sys/fs/cgroup/memory.stat"
45+
)
46+
47+
// ContainerCPU returns the CPU usage of the container cgroup.
48+
// This is calculated as difference of two samples of the
49+
// CPU usage of the container cgroup.
50+
// The total is read from the relevant path in /sys/fs/cgroup.
51+
// If there is no limit set, the total is assumed to be the
52+
// number of host cores multiplied by the CFS period.
53+
// If the system is not containerized, this always returns nil.
54+
func (s *Statter) ContainerCPU() (*Result, error) {
55+
// Firstly, check if we are containerized.
56+
if ok, err := IsContainerized(s.fs); err != nil || !ok {
57+
return nil, nil //nolint: nilnil
58+
}
59+
60+
total, err := s.cGroupCPUTotal()
61+
if err != nil {
62+
return nil, xerrors.Errorf("get total cpu: %w", err)
63+
}
64+
65+
used1, err := s.cGroupCPUUsed()
66+
if err != nil {
67+
return nil, xerrors.Errorf("get cgroup CPU usage: %w", err)
68+
}
69+
70+
// The measurements in /sys/fs/cgroup are counters.
71+
// We need to wait for a bit to get a difference.
72+
// Note that someone could reset the counter in the meantime.
73+
// We can't do anything about that.
74+
s.wait(s.sampleInterval)
75+
76+
used2, err := s.cGroupCPUUsed()
77+
if err != nil {
78+
return nil, xerrors.Errorf("get cgroup CPU usage: %w", err)
79+
}
80+
81+
if used2 < used1 {
82+
// Someone reset the counter. Best we can do is count from zero.
83+
used1 = 0
84+
}
85+
86+
r := &Result{
87+
Unit: "cores",
88+
Used: used2 - used1,
89+
Total: ptr.To(total),
90+
}
91+
return r, nil
92+
}
93+
94+
func (s *Statter) cGroupCPUTotal() (used float64, err error) {
95+
if s.isCGroupV2() {
96+
return s.cGroupV2CPUTotal()
97+
}
98+
99+
// Fall back to CGroupv1
100+
return s.cGroupV1CPUTotal()
101+
}
102+
103+
func (s *Statter) cGroupCPUUsed() (used float64, err error) {
104+
if s.isCGroupV2() {
105+
return s.cGroupV2CPUUsed()
106+
}
107+
108+
return s.cGroupV1CPUUsed()
109+
}
110+
111+
func (s *Statter) isCGroupV2() bool {
112+
// Check for the presence of /sys/fs/cgroup/cpu.max
113+
_, err := s.fs.Stat(cgroupV2CPUMax)
114+
return err == nil
115+
}
116+
117+
func (s *Statter) cGroupV2CPUUsed() (used float64, err error) {
118+
usageUs, err := readInt64Prefix(s.fs, cgroupV2CPUStat, "usage_usec")
119+
if err != nil {
120+
return 0, xerrors.Errorf("get cgroupv2 cpu used: %w", err)
121+
}
122+
periodUs, err := readInt64SepIdx(s.fs, cgroupV2CPUMax, " ", 1)
123+
if err != nil {
124+
return 0, xerrors.Errorf("get cpu period: %w", err)
125+
}
126+
127+
return float64(usageUs) / float64(periodUs), nil
128+
}
129+
130+
func (s *Statter) cGroupV2CPUTotal() (total float64, err error) {
131+
var quotaUs, periodUs int64
132+
periodUs, err = readInt64SepIdx(s.fs, cgroupV2CPUMax, " ", 1)
133+
if err != nil {
134+
return 0, xerrors.Errorf("get cpu period: %w", err)
135+
}
136+
137+
quotaUs, err = readInt64SepIdx(s.fs, cgroupV2CPUMax, " ", 0)
138+
if err != nil {
139+
// Fall back to number of cores
140+
quotaUs = int64(s.nproc) * periodUs
141+
}
142+
143+
return float64(quotaUs) / float64(periodUs), nil
144+
}
145+
146+
func (s *Statter) cGroupV1CPUTotal() (float64, error) {
147+
periodUs, err := readInt64(s.fs, cgroupV1CFSPeriodUs)
148+
if err != nil {
149+
return 0, xerrors.Errorf("read cpu period: %w", err)
150+
}
151+
152+
quotaUs, err := readInt64(s.fs, cgroupV1CFSQuotaUs)
153+
if err != nil {
154+
return 0, xerrors.Errorf("read cpu quota: %w", err)
155+
}
156+
157+
if quotaUs < 0 {
158+
// Fall back to the number of cores
159+
quotaUs = int64(s.nproc) * periodUs
160+
}
161+
162+
return float64(quotaUs) / float64(periodUs), nil
163+
}
164+
165+
func (s *Statter) cGroupV1CPUUsed() (float64, error) {
166+
usageNs, err := readInt64(s.fs, cgroupV1CPUAcctUsage)
167+
if err != nil {
168+
// try alternate path
169+
usageNs, err = readInt64(s.fs, cgroupV1CPUAcctUsageAlt)
170+
if err != nil {
171+
return 0, xerrors.Errorf("read cpu used: %w", err)
172+
}
173+
}
174+
175+
// usage is in ns, convert to us
176+
usageNs /= 1000
177+
periodUs, err := readInt64(s.fs, cgroupV1CFSPeriodUs)
178+
if err != nil {
179+
return 0, xerrors.Errorf("get cpu period: %w", err)
180+
}
181+
182+
return float64(usageNs) / float64(periodUs), nil
183+
}
184+
185+
// ContainerMemory returns the memory usage of the container cgroup.
186+
// If the system is not containerized, this always returns nil.
187+
func (s *Statter) ContainerMemory() (*Result, error) {
188+
if ok, err := IsContainerized(s.fs); err != nil || !ok {
189+
return nil, nil //nolint:nilnil
190+
}
191+
192+
if s.isCGroupV2() {
193+
return s.cGroupV2Memory()
194+
}
195+
196+
// Fall back to CGroupv1
197+
return s.cGroupV1Memory()
198+
}
199+
200+
func (s *Statter) cGroupV2Memory() (*Result, error) {
201+
maxUsageBytes, err := readInt64(s.fs, cgroupV2MemoryMaxBytes)
202+
if err != nil {
203+
return nil, xerrors.Errorf("read memory total: %w", err)
204+
}
205+
206+
currUsageBytes, err := readInt64(s.fs, cgroupV2MemoryUsageBytes)
207+
if err != nil {
208+
return nil, xerrors.Errorf("read memory usage: %w", err)
209+
}
210+
211+
inactiveFileBytes, err := readInt64Prefix(s.fs, cgroupV2MemoryStat, "inactive_file")
212+
if err != nil {
213+
return nil, xerrors.Errorf("read memory stats: %w", err)
214+
}
215+
216+
return &Result{
217+
Total: ptr.To(float64(maxUsageBytes)),
218+
Used: float64(currUsageBytes - inactiveFileBytes),
219+
Unit: "B",
220+
}, nil
221+
}
222+
223+
func (s *Statter) cGroupV1Memory() (*Result, error) {
224+
maxUsageBytes, err := readInt64(s.fs, cgroupV1MemoryMaxUsageBytes)
225+
if err != nil {
226+
return nil, xerrors.Errorf("read memory total: %w", err)
227+
}
228+
229+
// need a space after total_rss so we don't hit something else
230+
usageBytes, err := readInt64(s.fs, cgroupV1MemoryUsageBytes)
231+
if err != nil {
232+
return nil, xerrors.Errorf("read memory usage: %w", err)
233+
}
234+
235+
totalInactiveFileBytes, err := readInt64Prefix(s.fs, cgroupV1MemoryStat, "total_inactive_file")
236+
if err != nil {
237+
return nil, xerrors.Errorf("read memory stats: %w", err)
238+
}
239+
240+
// Total memory used is usage - total_inactive_file
241+
return &Result{
242+
Total: ptr.To(float64(maxUsageBytes)),
243+
Used: float64(usageBytes - totalInactiveFileBytes),
244+
Unit: "B",
245+
}, nil
246+
}
247+
248+
// read an int64 value from path
249+
func readInt64(fs afero.Fs, path string) (int64, error) {
250+
data, err := afero.ReadFile(fs, path)
251+
if err != nil {
252+
return 0, xerrors.Errorf("read %s: %w", path, err)
253+
}
254+
255+
val, err := strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64)
256+
if err != nil {
257+
return 0, xerrors.Errorf("parse %s: %w", path, err)
258+
}
259+
260+
return val, nil
261+
}
262+
263+
// read an int64 value from path at field idx separated by sep
264+
func readInt64SepIdx(fs afero.Fs, path, sep string, idx int) (int64, error) {
265+
data, err := afero.ReadFile(fs, path)
266+
if err != nil {
267+
return 0, xerrors.Errorf("read %s: %w", path, err)
268+
}
269+
270+
parts := strings.Split(string(data), sep)
271+
if len(parts) < idx {
272+
return 0, xerrors.Errorf("expected line %q to have at least %d parts", string(data), idx+1)
273+
}
274+
275+
val, err := strconv.ParseInt(strings.TrimSpace(parts[idx]), 10, 64)
276+
if err != nil {
277+
return 0, xerrors.Errorf("parse %s: %w", path, err)
278+
}
279+
280+
return val, nil
281+
}
282+
283+
// read the first int64 value from path prefixed with prefix
284+
func readInt64Prefix(fs afero.Fs, path, prefix string) (int64, error) {
285+
data, err := afero.ReadFile(fs, path)
286+
if err != nil {
287+
return 0, xerrors.Errorf("read %s: %w", path, err)
288+
}
289+
290+
scn := bufio.NewScanner(bytes.NewReader(data))
291+
for scn.Scan() {
292+
line := scn.Text()
293+
if !strings.HasPrefix(line, prefix) {
294+
continue
295+
}
296+
297+
parts := strings.Fields(line)
298+
if len(parts) != 2 {
299+
return 0, xerrors.Errorf("parse %s: expected two fields but got %s", path, line)
300+
}
301+
302+
val, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
303+
if err != nil {
304+
return 0, xerrors.Errorf("parse %s: %w", path, err)
305+
}
306+
307+
return val, nil
308+
}
309+
310+
return 0, xerrors.Errorf("parse %s: did not find line with prefix %s", path, prefix)
311+
}

cli/clistat/container.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package clistat
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"os"
7+
8+
"github.com/spf13/afero"
9+
"golang.org/x/xerrors"
10+
)
11+
12+
const (
13+
procMounts = "/proc/mounts"
14+
procOneCgroup = "/proc/1/cgroup"
15+
)
16+
17+
// IsContainerized returns whether the host is containerized.
18+
// This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31
19+
// with modifications to support Sysbox containers.
20+
// On non-Linux platforms, it always returns false.
21+
func IsContainerized(fs afero.Fs) (ok bool, err error) {
22+
cgData, err := afero.ReadFile(fs, procOneCgroup)
23+
if err != nil {
24+
if os.IsNotExist(err) {
25+
return false, nil
26+
}
27+
return false, xerrors.Errorf("read file %s: %w", procOneCgroup, err)
28+
}
29+
30+
scn := bufio.NewScanner(bytes.NewReader(cgData))
31+
for scn.Scan() {
32+
line := scn.Bytes()
33+
if bytes.Contains(line, []byte("docker")) ||
34+
bytes.Contains(line, []byte(".slice")) ||
35+
bytes.Contains(line, []byte("lxc")) ||
36+
bytes.Contains(line, []byte("kubepods")) {
37+
return true, nil
38+
}
39+
}
40+
41+
// Last-ditch effort to detect Sysbox containers.
42+
// Check if we have anything mounted as type sysboxfs in /proc/mounts
43+
mountsData, err := afero.ReadFile(fs, procMounts)
44+
if err != nil {
45+
if os.IsNotExist(err) {
46+
return false, nil
47+
}
48+
return false, xerrors.Errorf("read file %s: %w", procMounts, err)
49+
}
50+
51+
scn = bufio.NewScanner(bytes.NewReader(mountsData))
52+
for scn.Scan() {
53+
line := scn.Bytes()
54+
if bytes.Contains(line, []byte("sysboxfs")) {
55+
return true, nil
56+
}
57+
}
58+
59+
// If we get here, we are _probably_ not running in a container.
60+
return false, nil
61+
}

0 commit comments

Comments
 (0)