From 0cdee293599f0fc7d14a90f8b2f163881f33bcf9 Mon Sep 17 00:00:00 2001 From: "a.gritsenko" Date: Sat, 3 Oct 2026 09:30:38 +0300 Subject: [PATCH] ZFS: dedup toggle per pool and dedup option for new datasets Pool cards show the dedup ratio and can set dedup=on|off on the pool root dataset (inherited by children); the new-dataset form gets a dedup option. Co-Authored-By: Claude Opus 5.5 --- internal/collect/zfs.go | 13 +++++++-- internal/collect/zfs_test.go | 13 +++++++++ internal/web/handlers.go | 49 +++++++++++++++++++++++++++++---- internal/web/server.go | 1 + internal/web/templates/zfs.html | 23 ++++++++++++++++ internal/web/web_test.go | 49 +++++++++++++++++++++++++++++---- internal/zfsadm/zfsadm.go | 36 +++++++++++++++++++++++- internal/zfsadm/zfsadm_test.go | 35 +++++++++++++++++++++++ packaging/README.md | 5 +++- 9 files changed, 209 insertions(+), 15 deletions(-) diff --git a/internal/collect/zfs.go b/internal/collect/zfs.go index fa3a6ee..6e133fb 100644 --- a/internal/collect/zfs.go +++ b/internal/collect/zfs.go @@ -35,6 +35,8 @@ type Pool struct { Status string `json:"status,omitempty"` Action string `json:"action,omitempty"` Vdevs []Vdev `json:"vdevs"` + // DedupRatio is the pool-wide dedup ratio (1.00 = nothing deduplicated). + DedupRatio float64 `json:"dedup_ratio"` } type Dataset struct { @@ -43,11 +45,12 @@ type Dataset struct { Avail uint64 `json:"avail"` Refer uint64 `json:"refer"` Mountpoint string `json:"mountpoint"` + Dedup string `json:"dedup"` // dedup property: off, on, verify, sha256, ... } // ReadPools combines zpool list and zpool status for every pool. func ReadPools() ([]Pool, error) { - out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag") + out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag,dedup") if err != nil { return nil, err } @@ -63,7 +66,7 @@ func ReadPools() ([]Pool, error) { } func ReadDatasets() ([]Dataset, error) { - out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint") + out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint,dedup") if err != nil { return nil, err } @@ -84,6 +87,9 @@ func parseZpoolList(b []byte) []Pool { p.Free, _ = strconv.ParseUint(f[3], 10, 64) p.CapPct = atoiPct(f[4]) p.FragPct = atoiPct(f[6]) + if len(f) > 7 { + p.DedupRatio, _ = strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(f[7]), "x"), 64) + } if p.CapPct == 0 && p.Size > 0 { p.CapPct = int(p.Alloc * 100 / p.Size) } @@ -220,6 +226,9 @@ func parseZfsList(b []byte) []Dataset { d.Used, _ = strconv.ParseUint(f[1], 10, 64) d.Avail, _ = strconv.ParseUint(f[2], 10, 64) d.Refer, _ = strconv.ParseUint(f[3], 10, 64) + if len(f) > 5 { + d.Dedup = f[5] + } out = append(out, d) } return out diff --git a/internal/collect/zfs_test.go b/internal/collect/zfs_test.go index db4af89..1b1b8ab 100644 --- a/internal/collect/zfs_test.go +++ b/internal/collect/zfs_test.go @@ -119,6 +119,15 @@ func TestParseZpoolList(t *testing.T) { if pools[1].CapPct != 90 || pools[1].FragPct != 0 { t.Errorf("pool1 = %+v", pools[1]) } + if pools[0].DedupRatio != 0 { + t.Errorf("dedup ratio without the column = %v", pools[0].DedupRatio) + } + + // with the dedup column; -p prints "1.35", without -p it is "1.35x" + pools = parseZpoolList([]byte("a\t1\t1\t0\t100\tONLINE\t0\t1.35\nb\t1\t1\t0\t100\tONLINE\t0\t2.00x\n")) + if len(pools) != 2 || pools[0].DedupRatio != 1.35 || pools[1].DedupRatio != 2 { + t.Errorf("dedup ratio = %+v", pools) + } } func TestParseZfsList(t *testing.T) { @@ -127,4 +136,8 @@ func TestParseZfsList(t *testing.T) { if len(ds) != 2 || ds[1].Name != "nas_pool_1_8_gb/appdata" || ds[1].Avail != 200 { t.Errorf("datasets = %+v", ds) } + ds = parseZfsList([]byte("tank\t1\t2\t3\t/tank\ton\n")) + if len(ds) != 1 || ds[0].Dedup != "on" || ds[0].Mountpoint != "/tank" { + t.Errorf("dedup column: %+v", ds) + } } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 0394081..9eacca0 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -124,7 +124,9 @@ type zfsVM struct { S sched.Status Th config.Thresholds Datasets []datasetRow - Form createForm // last submitted values (re-rendered after an error) + // PoolDedup maps a pool to the dedup property of its root dataset. + PoolDedup map[string]string + Form createForm // last submitted values (re-rendered after an error) } type datasetRow struct { @@ -134,15 +136,18 @@ type datasetRow struct { } type createForm struct { - Pool, Name, Owner, Compression, Quota string + Pool, Name, Owner, Compression, Dedup, Quota string } func (s *Server) zfsView(st sched.Status, form createForm) zfsVM { - vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form} + vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form, PoolDedup: map[string]string{}} if vm.Form.Owner == "" { vm.Form.Owner = defaultOwner } for _, d := range st.Datasets { + if !strings.Contains(d.Name, "/") { + vm.PoolDedup[d.Name] = d.Dedup + } row := datasetRow{Dataset: d, DomID: strings.NewReplacer("/", "-", ".", "_", ":", "_").Replace(d.Name)} row.CanDestroy = strings.Contains(d.Name, "/") && d.Refer < zfsadm.EmptyRefer for _, o := range st.Datasets { @@ -170,6 +175,7 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) { Name: strings.Trim(strings.TrimSpace(r.FormValue("name")), "/"), Owner: strings.TrimSpace(r.FormValue("owner")), Compression: r.FormValue("compression"), + Dedup: r.FormValue("dedup"), Quota: strings.ToUpper(strings.TrimSpace(r.FormValue("quota"))), } st := s.sch.Status() @@ -191,20 +197,20 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) { return } } - for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateSize(f.Quota)} { + for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateDedup(f.Dedup), zfsadm.ValidateSize(f.Quota)} { if err != nil { fail(err.Error()) return } } - err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Quota: f.Quota, Owner: f.Owner}) + err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Dedup: f.Dedup, Quota: f.Quota, Owner: f.Owner}) s.sch.RefreshStorage() if err != nil { log.Printf("zfs create %s: %v", full, err) fail(err.Error()) return } - log.Printf("dataset %s created (owner %s, compression %q, quota %q)", full, f.Owner, f.Compression, f.Quota) + log.Printf("dataset %s created (owner %s, compression %q, dedup %q, quota %q)", full, f.Owner, f.Compression, f.Dedup, f.Quota) http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+full+" created")+"#"+url.PathEscape(f.Pool), http.StatusSeeOther) } @@ -243,6 +249,37 @@ func (s *Server) zfsDestroy(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+name+" destroyed"), http.StatusSeeOther) } +// zfsDedup turns dedup on/off on a pool's root dataset (inherited by children). +func (s *Server) zfsDedup(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + pool := strings.TrimSpace(r.FormValue("pool")) + value := r.FormValue("dedup") + st := s.sch.Status() + fail := func(msg string) { + s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, createForm{}), Alerts: len(st.Alerts), Error: "Dedup not changed: " + msg}) + } + if !hasPool(st, pool) { + fail("unknown pool " + pool) + return + } + if value != "on" && value != "off" { + fail("dedup must be on or off") + return + } + err := s.admin.SetDedup(pool, value) + s.sch.RefreshStorage() + if err != nil { + log.Printf("zfs set dedup=%s %s: %v", value, pool, err) + fail(err.Error()) + return + } + log.Printf("dedup=%s set on pool %s", value, pool) + http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dedup "+value+" for "+pool)+"#"+url.PathEscape(pool), http.StatusSeeOther) +} + func hasPool(st sched.Status, name string) bool { for _, p := range st.Pools { if p.Name == name { diff --git a/internal/web/server.go b/internal/web/server.go index 347a186..2671e69 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -73,6 +73,7 @@ func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Sche s.mux.HandleFunc("GET /zfs", s.zfs) s.mux.HandleFunc("POST /zfs/create", s.zfsCreate) s.mux.HandleFunc("POST /zfs/destroy", s.zfsDestroy) + s.mux.HandleFunc("POST /zfs/dedup", s.zfsDedup) s.mux.HandleFunc("GET /alerts", s.alerts) s.mux.HandleFunc("POST /alerts/{id}/{action}", s.alertAction) s.mux.HandleFunc("GET /shares", s.sharesPage) diff --git a/internal/web/templates/zfs.html b/internal/web/templates/zfs.html index 57ace10..f75c3eb 100644 --- a/internal/web/templates/zfs.html +++ b/internal/web/templates/zfs.html @@ -13,6 +13,7 @@
fragmentation {{.FragPct}}%
last scrub {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}
errors {{if .Errors}}{{.Errors}}{{else}}—{{end}}
+
dedup {{with index $.Data.PoolDedup .Name}}{{.}}{{else}}—{{end}}{{if .DedupRatio}} (ratio {{printf "%.2f" .DedupRatio}}x){{end}}
{{if .Scan}}

scan: {{.Scan}}

{{end}} @@ -31,6 +32,21 @@ {{end}} + {{$dedup := index $.Data.PoolDedup .Name}} +
+ Deduplication +
+ + + +
+

Sets dedup on the pool root dataset; child datasets inherit it unless they override it. Only newly written data is deduplicated, and turning it off keeps the existing dedup table. The dedup table needs a lot of RAM (up to ~5 GB per 1 TB of unique data) — enable it only for data with many duplicate blocks.

+
New dataset in {{.Name}}
@@ -46,6 +62,13 @@ +

Creates {{.Name}}/<name> (parents are created too), mounts it at /{{.Name}}/<name> and chowns the mountpoint to the owner.

diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 005bfa9..9d21ac3 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -68,12 +68,12 @@ func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) { }, Pools: []collect.Pool{ {Name: "tank", Health: "DEGRADED", Size: 8 << 40, Alloc: 3 << 40, Free: 5 << 40, CapPct: 37, FragPct: 5, Scan: "scrub repaired 0B", LastScrub: &old, - Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors", + Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors", DedupRatio: 1.42, Vdevs: []collect.Vdev{{Name: "tank", State: "DEGRADED"}, {Name: "mirror-0", Depth: 1, State: "DEGRADED"}, {Name: "sda", Depth: 2, State: "ONLINE", Cksum: 2}, {Name: "123", Depth: 2, State: "UNAVAIL", Note: "was /dev/sdb1"}}}, {Name: "tmp_pool", Health: "ONLINE", Size: 1 << 40, Alloc: 900 << 30, Free: 124 << 30, CapPct: 88, Scrubbing: true, Scan: "scrub in progress"}, }, Datasets: []collect.Dataset{ - {Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank"}, + {Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank", Dedup: "on"}, {Name: "tank/empty", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/empty"}, {Name: "tank/data", Used: 3 << 40, Avail: 5 << 40, Refer: 3 << 40, Mountpoint: "/tank/data"}, {Name: "tank/parent", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent"}, @@ -245,7 +245,7 @@ func TestZfsPageControls(t *testing.T) { rec := httptest.NewRecorder() srv.ServeHTTP(rec, httptest.NewRequest("GET", "/zfs?msg=hello", nil)) body := rec.Body.String() - for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm"} { + for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm", `action="/zfs/dedup"`, "Dedup for tmp_pool", "ratio 1.42x"} { if !strings.Contains(body, want) { t.Errorf("zfs page missing %q", want) } @@ -274,6 +274,7 @@ func TestZfsCreate(t *testing.T) { {"pool": {"tank"}, "name": {"x"}, "quota": {"lots"}}, // bad quota {"pool": {"tank"}, "name": {"x"}, "owner": {"ansible"}}, // bad owner {"pool": {"tank"}, "name": {"x"}, "compression": {"gzip"}}, + {"pool": {"tank"}, "name": {"x"}, "dedup": {"verify"}}, } for _, form := range bad { f.calls = nil @@ -289,11 +290,11 @@ func TestZfsCreate(t *testing.T) { } } - rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "quota": {"100g"}}) + rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "dedup": {"on"}, "quota": {"100g"}}) if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "created") { t.Fatalf("status %d location %q body %s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) } - want := "zfs create -p -o compression=lz4 -o quota=100G tank/media/video" + want := "zfs create -p -o compression=lz4 -o dedup=on -o quota=100G tank/media/video" if len(f.calls) != 3 || f.calls[0] != want || f.calls[2] != "chown 1000:1000 /tank/media/video" { t.Errorf("calls = %v", f.calls) } @@ -307,6 +308,44 @@ func TestZfsCreate(t *testing.T) { } } +func TestZfsDedup(t *testing.T) { + srv, _, _ := newTestServer(t) + f := &fakeRunner{} + srv.admin = zfsadm.New(f.run) + + bad := []url.Values{ + {"pool": {"nope"}, "dedup": {"on"}}, // unknown pool + {"pool": {"tank/data"}, "dedup": {"on"}}, // not a pool + {"pool": {"tank"}, "dedup": {"verify"}}, // unsupported value + {"pool": {"tank"}}, // missing value + } + for _, form := range bad { + f.calls = nil + rec := postForm(srv, "/zfs/dedup", form) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Dedup not changed") { + t.Errorf("%v: status %d, no error shown", form, rec.Code) + } + if len(f.calls) != 0 { + t.Errorf("%v: commands ran: %v", form, f.calls) + } + } + + rec := postForm(srv, "/zfs/dedup", url.Values{"pool": {"tmp_pool"}, "dedup": {"on"}}) + if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "#tmp_pool") { + t.Fatalf("status %d location %q", rec.Code, rec.Header().Get("Location")) + } + if len(f.calls) != 1 || f.calls[0] != "zfs set dedup=on tmp_pool" { + t.Errorf("calls = %v", f.calls) + } + + f = &fakeRunner{fail: "zfs set"} + srv.admin = zfsadm.New(f.run) + rec = postForm(srv, "/zfs/dedup", url.Values{"pool": {"tank"}, "dedup": {"off"}}) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "cannot do that") { + t.Errorf("failure not reported: %d", rec.Code) + } +} + func TestZfsDestroy(t *testing.T) { srv, _, _ := newTestServer(t) f := &fakeRunner{} diff --git a/internal/zfsadm/zfsadm.go b/internal/zfsadm/zfsadm.go index 07c5f51..e3cb3d6 100644 --- a/internal/zfsadm/zfsadm.go +++ b/internal/zfsadm/zfsadm.go @@ -1,5 +1,5 @@ // Package zfsadm performs the few mutating ZFS operations the UI offers -// (create / destroy datasets). Commands go through a Runner so they can be +// (create / destroy datasets, toggle pool dedup). Commands go through a Runner so they can be // executed on the host via systemd-run: nasmon runs in a sandboxed mount // namespace (ProtectSystem=strict), where a plain "zfs create" would mount the // new dataset only inside the service and leave the host without it. @@ -59,6 +59,7 @@ func (a *Admin) ctx() (context.Context, context.CancelFunc) { // CreateOpts are the user-visible knobs of "zfs create". type CreateOpts struct { Compression string // "", lz4, zstd, off ("" = inherit) + Dedup string // "", on, off ("" = inherit) Quota string // "", or a size like 100G / 1.5T Owner string // uid:gid applied to the mountpoint; "" = leave root } @@ -71,6 +72,9 @@ func (a *Admin) Create(full string, o CreateOpts) error { if err := ValidateCompression(o.Compression); err != nil { return err } + if err := ValidateDedup(o.Dedup); err != nil { + return err + } if err := ValidateSize(o.Quota); err != nil { return err } @@ -81,6 +85,9 @@ func (a *Admin) Create(full string, o CreateOpts) error { if o.Compression != "" { args = append(args, "-o", "compression="+o.Compression) } + if o.Dedup != "" { + args = append(args, "-o", "dedup="+o.Dedup) + } if o.Quota != "" { args = append(args, "-o", "quota="+o.Quota) } @@ -106,6 +113,25 @@ func (a *Admin) Create(full string, o CreateOpts) error { return nil } +// SetDedup sets the dedup property (on/off) on the root dataset of pool, so it +// applies to every dataset that does not override it. Only newly written +// blocks are affected either way. +func (a *Admin) SetDedup(pool, value string) error { + if err := ValidateFullName(pool); err != nil { + return err + } + if strings.Contains(pool, "/") { + return errors.New("dedup is set on the pool root dataset only") + } + if value != "on" && value != "off" { + return errors.New("dedup must be on or off") + } + ctx, cancel := a.ctx() + defer cancel() + _, err := a.Run(ctx, "zfs", "set", "dedup="+value, pool) + return err +} + // EmptyRefer is the upper bound of "referenced" bytes for a dataset we still // consider empty (a fresh dataset refers ~96-128 KiB of metadata). const EmptyRefer = 1 << 20 @@ -206,6 +232,14 @@ func ValidateCompression(s string) error { return errors.New("compression must be inherit, lz4, zstd or off") } +func ValidateDedup(s string) error { + switch s { + case "", "on", "off": + return nil + } + return errors.New("dedup must be inherit, on or off") +} + func ValidateOwner(s string) error { if s == "" || reOwner.MatchString(s) { return nil diff --git a/internal/zfsadm/zfsadm_test.go b/internal/zfsadm/zfsadm_test.go index 5dc46e6..0aa655f 100644 --- a/internal/zfsadm/zfsadm_test.go +++ b/internal/zfsadm/zfsadm_test.go @@ -31,6 +31,9 @@ func TestValidators(t *testing.T) { if ValidateCompression("gzip") == nil || ValidateCompression("lz4") != nil || ValidateCompression("") != nil { t.Error("compression validation") } + if ValidateDedup("verify") == nil || ValidateDedup("on") != nil || ValidateDedup("") != nil { + t.Error("dedup validation") + } if ValidateOwner("ansible") == nil || ValidateOwner("1000:1000") != nil || ValidateOwner("") != nil { t.Error("owner validation") } @@ -74,6 +77,16 @@ func TestCreate(t *testing.T) { t.Errorf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n")) } + // dedup goes between compression and quota + f = &fake{} + a = New(f.run) + if err := a.Create("tank/d", CreateOpts{Compression: "zstd", Dedup: "on", Quota: "1T"}); err != nil { + t.Fatal(err) + } + if len(f.calls) != 1 || f.calls[0] != "zfs create -p -o compression=zstd -o dedup=on -o quota=1T tank/d" { + t.Errorf("calls = %v", f.calls) + } + // no owner, inherit everything f = &fake{} a = New(f.run) @@ -115,6 +128,28 @@ func TestCreate(t *testing.T) { } } +func TestSetDedup(t *testing.T) { + f := &fake{} + a := New(f.run) + for _, c := range [][2]string{{"tank/x", "on"}, {"tank", "verify"}, {"tank", ""}, {"-tank", "on"}, {"", "off"}} { + if err := a.SetDedup(c[0], c[1]); err == nil { + t.Errorf("%q=%q accepted", c[0], c[1]) + } + } + if len(f.calls) != 0 { + t.Errorf("rejected input ran commands: %v", f.calls) + } + if err := a.SetDedup("tank", "on"); err != nil { + t.Fatal(err) + } + if err := a.SetDedup("tank", "off"); err != nil { + t.Fatal(err) + } + if strings.Join(f.calls, "|") != "zfs set dedup=on tank|zfs set dedup=off tank" { + t.Errorf("calls = %v", f.calls) + } +} + func TestDestroyEmpty(t *testing.T) { ok := map[string]string{"zfs list -Hp -r -t filesystem": "tank/x\t98304", "zfs list -H -t snapshot": ""} f := &fake{reply: ok} diff --git a/packaging/README.md b/packaging/README.md index c0b10c5..4a6c559 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -14,8 +14,11 @@ periodic SMART/ZFS checks with e-mail alerts. The ZFS page (http://:8090/zfs) can create datasets in a pool — name (nested names like media/video are fine), owner uid:gid for the mountpoint, compression, -optional quota — and destroy datasets that are empty (no data, no children, no +dedup, optional quota — and destroy datasets that are empty (no data, no children, no snapshots; the check is repeated against the live system before "zfs destroy"). +Each pool card also shows the dedup ratio and can turn dedup on/off on the pool +root dataset (`zfs set dedup=on|off `, inherited by child datasets; only new +writes are affected, and the dedup table needs a lot of RAM). nasmon runs sandboxed (ProtectSystem=strict → own mount namespace), so these commands are executed on the host through transient units: