The safety review of the first version found it safe for the pool's data and asked for these fixes before it goes on the home server: - zfs_snapshot: no recursive. Every answer gives the dataset's free space and the pool's capacity, and says that the snapshot holds every block deleted or overwritten until it is destroyed by hand. - A command that runs out of time is ended with its whole process group. An act that timed out is answered from the list, or as "outcome unknown", never as "NOT DONE". - The watch runs as the operator account. - The event follower reads ZFS's queue from its start, with a cursor per boot, so the pool's import and any downtime reach the journal once. It writes "dropped N events", kills zpool before waiting for it, and exits non-zero after five failed follows in a row. - Past error counts are a warning, and counts that rise are unhealthy. - The guard test catches .Run(, exec.Command(, exec.CommandContext( and process starts. - The fixtures and the parser follow OpenZFS 2.4.4's own printers: human scan sizes under -p, no iostat section lines, error scrubs, per-vdev rebuild resilvers, paused scrubs. The operator also asked the module to own the scrub. A timer and a service of the module's own run zpool scrub -w -- <pool> from the scrub-pool, scrub-cadence, scrub-day and scrub-time settings. In the same declaration, the distribution's weekly and monthly timers for that pool are held stopped and disabled, so exactly one schedule scrubs it. zfs_scrub shows the schedule and the next run, with any resilver's progress. zfs_scrub_start, zfs_scrub_pause and zfs_scrub_stop act with why. The health check warns of a late or failed scrub and of a running resilver, and is unhealthy for a resilver that finished with errors.
216 lines
6.9 KiB
Go
216 lines
6.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// IOArgv samples every pool's I/O for some seconds: -y leaves out the report since boot, so the one report
|
|
// is the window alone.
|
|
func IOArgv(seconds int) []string {
|
|
return []string{"iostat", "-y", "-Hpv", strconv.Itoa(seconds), "1"}
|
|
}
|
|
|
|
// IORow is one line of the sample: a pool, a vdev or a device.
|
|
type IORow struct {
|
|
Name string `json:"name"`
|
|
Pool string `json:"pool"`
|
|
// Kind is pool, vdev or device, and Role data, log, cache, special or dedup, from the pool's layout:
|
|
// iostat -H prints no section lines and no indentation.
|
|
Kind string `json:"kind"`
|
|
Role string `json:"role,omitempty"`
|
|
// Allocated and Free are a top-level vdev's; a vdev without capacity of its own has neither.
|
|
Allocated *uint64 `json:"allocated_bytes,omitempty"`
|
|
Free *uint64 `json:"free_bytes,omitempty"`
|
|
ReadOps float64 `json:"read_ops_per_s"`
|
|
WriteOps float64 `json:"write_ops_per_s"`
|
|
ReadBytes float64 `json:"read_bytes_per_s"`
|
|
WriteBytes float64 `json:"write_bytes_per_s"`
|
|
}
|
|
|
|
// ParseIO reads `zpool iostat -Hpv`: name, allocated, free, read and write operations, read and write
|
|
// bandwidth, tab-separated. Under -H there are no section lines (a blank line stands before each class)
|
|
// and under -p a vdev without capacity of its own prints 0 for both (zpool_main.c, print_iostat_default).
|
|
// Lines of a pool follow the pool's own line; its layout names each line's kind and role.
|
|
func ParseIO(out string, layout []PoolStatus) ([]IORow, error) {
|
|
var rows []IORow
|
|
pool := ""
|
|
for _, line := range lines(out) {
|
|
f := strings.Split(line, "\t")
|
|
if len(f) != 7 {
|
|
f = strings.Fields(line)
|
|
}
|
|
if len(f) != 7 {
|
|
return nil, fmt.Errorf("zpool iostat answered a line with %d columns, not 7: %q", len(f), line)
|
|
}
|
|
r := IORow{Name: strings.TrimSpace(f[0]), Kind: "device"}
|
|
if statusOf(layout, r.Name) != nil {
|
|
pool = r.Name
|
|
r.Kind = ""
|
|
}
|
|
r.Pool = pool
|
|
if r.Kind == "" {
|
|
r.Kind = "pool"
|
|
} else if p := statusOf(layout, pool); p != nil {
|
|
walk(p.Root, func(d *Device, _ int) {
|
|
if d.Name == r.Name && d.Kind != "pool" && d.Kind != "section" {
|
|
r.Kind, r.Role = d.Kind, d.Role
|
|
}
|
|
}, 0)
|
|
}
|
|
if !(f[1] == "0" && f[2] == "0") {
|
|
r.Allocated, r.Free = optionalNumber(f[1]), optionalNumber(f[2])
|
|
}
|
|
vals := []*float64{&r.ReadOps, &r.WriteOps, &r.ReadBytes, &r.WriteBytes}
|
|
for i, v := range vals {
|
|
s := f[3+i]
|
|
if s == "-" {
|
|
continue
|
|
}
|
|
x, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zpool iostat: %q of %s is not a number", s, r.Name)
|
|
}
|
|
*v = x
|
|
}
|
|
rows = append(rows, r)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func optionalNumber(s string) *uint64 {
|
|
if s == "-" {
|
|
return nil
|
|
}
|
|
v, err := strconv.ParseUint(s, 10, 64)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|
|
|
|
// IOAnswer is what zfs_io answers.
|
|
type IOAnswer struct {
|
|
Applicable bool `json:"applicable"`
|
|
Seconds int `json:"window_s"`
|
|
Rows []IORow `json:"rows"`
|
|
Said []string `json:"said"`
|
|
}
|
|
|
|
// IO samples every pool's operations and bandwidth for a few seconds.
|
|
func (m *Machine) IO(ctx context.Context, seconds int) (any, error) {
|
|
st, err := m.Status(ctx)
|
|
if err != nil {
|
|
return notHere(err)
|
|
}
|
|
sctx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second+CommandTimeout)
|
|
defer cancel()
|
|
out, err := m.ask(sctx, "zpool", IOArgv(seconds)...)
|
|
if err != nil {
|
|
return notHere(err)
|
|
}
|
|
rows, err := ParseIO(out, st)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a := IOAnswer{Applicable: true, Seconds: seconds, Rows: rows, Said: []string{}}
|
|
for _, r := range rows {
|
|
kind := r.Kind
|
|
if r.Role != "" && r.Role != "data" {
|
|
kind += ", " + r.Role
|
|
}
|
|
a.Said = append(a.Said, fmt.Sprintf("%s %s (%s): read %.0f ops/s %s/s, write %.0f ops/s %s/s", r.Pool, r.Name, kind,
|
|
r.ReadOps, bytesSaid(uint64(r.ReadBytes)), r.WriteOps, bytesSaid(uint64(r.WriteBytes))))
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// ArcStats is where the kernel module keeps the ARC's counters.
|
|
const ArcStats = "/proc/spl/kstat/zfs/arcstats"
|
|
|
|
// ParseArc reads arcstats: two header lines, then `name type value`.
|
|
func ParseArc(text string) map[string]uint64 {
|
|
out := map[string]uint64{}
|
|
for i, line := range strings.Split(text, "\n") {
|
|
f := strings.Fields(line)
|
|
if i < 2 || len(f) != 3 {
|
|
continue
|
|
}
|
|
if v, err := strconv.ParseUint(f[2], 10, 64); err == nil {
|
|
out[f[0]] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ArcAnswer is what zfs_arc answers.
|
|
type ArcAnswer struct {
|
|
Applicable bool `json:"applicable"`
|
|
Size uint64 `json:"size_bytes"`
|
|
Target uint64 `json:"target_bytes"`
|
|
Min uint64 `json:"min_bytes"`
|
|
Max uint64 `json:"max_bytes"`
|
|
Hits uint64 `json:"hits"`
|
|
Misses uint64 `json:"misses"`
|
|
HitRatio float64 `json:"hit_ratio_pct"`
|
|
// DemandHitRatio counts only what was asked for, not what ZFS read ahead.
|
|
DemandHitRatio *float64 `json:"demand_hit_ratio_pct,omitempty"`
|
|
L2 *L2Arc `json:"l2arc,omitempty"`
|
|
Said []string `json:"said"`
|
|
}
|
|
|
|
// L2Arc is the cache device's share of the ARC, where the pool has one.
|
|
type L2Arc struct {
|
|
Size uint64 `json:"size_bytes"`
|
|
Allocated uint64 `json:"allocated_bytes"`
|
|
Hits uint64 `json:"hits"`
|
|
Misses uint64 `json:"misses"`
|
|
HitRatio float64 `json:"hit_ratio_pct"`
|
|
}
|
|
|
|
// Arc is the ARC's size and hit ratio since the machine started.
|
|
func (m *Machine) Arc() (any, error) {
|
|
raw, err := m.ReadFile(ArcStats)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return NotApplicable{Applicable: false, Said: "there is no " + ArcStats + ": ZFS's kernel module is not loaded on this machine, so this module does not apply here"}, nil
|
|
}
|
|
return nil, fmt.Errorf("%s could not be read: %v", ArcStats, err)
|
|
}
|
|
s := ParseArc(string(raw))
|
|
if _, ok := s["size"]; !ok {
|
|
return nil, fmt.Errorf("%s has no size line", ArcStats)
|
|
}
|
|
a := ArcAnswer{Applicable: true, Size: s["size"], Target: s["c"], Min: s["c_min"], Max: s["c_max"], Hits: s["hits"], Misses: s["misses"]}
|
|
a.HitRatio = pct(a.Hits, a.Hits+a.Misses)
|
|
dh := s["demand_data_hits"] + s["demand_metadata_hits"]
|
|
dm := s["demand_data_misses"] + s["demand_metadata_misses"]
|
|
if dh+dm > 0 {
|
|
r := pct(dh, dh+dm)
|
|
a.DemandHitRatio = &r
|
|
}
|
|
a.Said = []string{fmt.Sprintf("ARC: %s of a %s target (min %s, max %s); %.1f %% of %d reads since the machine started were hits",
|
|
bytesSaid(a.Size), bytesSaid(a.Target), bytesSaid(a.Min), bytesSaid(a.Max), a.HitRatio, a.Hits+a.Misses)}
|
|
if s["l2_size"] > 0 || s["l2_hits"]+s["l2_misses"] > 0 {
|
|
l := &L2Arc{Size: s["l2_size"], Allocated: s["l2_asize"], Hits: s["l2_hits"], Misses: s["l2_misses"]}
|
|
l.HitRatio = pct(l.Hits, l.Hits+l.Misses)
|
|
a.L2 = l
|
|
a.Said = append(a.Said, fmt.Sprintf("L2ARC: %s cached (%s on the device), %.1f %% of %d reads were hits",
|
|
bytesSaid(l.Size), bytesSaid(l.Allocated), l.HitRatio, l.Hits+l.Misses))
|
|
} else {
|
|
a.Said = append(a.Said, "no L2ARC (no cache device)")
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func pct(part, whole uint64) float64 {
|
|
if whole == 0 {
|
|
return 0
|
|
}
|
|
return round1(float64(part) * 100 / float64(whole))
|
|
}
|