diff --git a/README.en.md b/README.en.md index 43a5aa3..bd2afb9 100644 --- a/README.en.md +++ b/README.en.md @@ -64,12 +64,15 @@ vps del vps panel-url vps admin-passwd vps config list|set|help +vps swap-reapply ``` The default allocation is 1 CPU core, 1 GB of RAM, and 10 GB of disk. CPU values may be whole cores or a fraction from `0.1` to `0.9`; disk capacity can only be increased. Generated passwords are shown once. Bandwidth quotas are monthly GiB totals for upload and download combined. Containers over quota are rate-limited to 1 Mbps in both directions without a restart. +Container swap is controlled by `incus.swap_ratio` (default 0.5 — a 1 GiB memory container may use up to 512 MiB of host swap). Setting the value applies it to all existing containers immediately (no restart); `vps swap-reapply` is available for a manual refresh when needed. + Users can define an init script in the panel. It runs as root inside the container after reinstall, with output written to `/var/log/vpsmgr-init.log`. ## Additional Images diff --git a/README.md b/README.md index 38e5443..9b6c987 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,15 @@ vps del vps panel-url vps admin-passwd vps config list|set|help +vps swap-reapply ``` 默认配额为 1 核、1 GB 内存和 10 GB 磁盘。CPU 支持整数核心,或 `0.1` 到 `0.9` 的小数核心;磁盘只能扩容。密码只显示一次。 月度流量配额单位为 GiB,统计上传和下载总量。超额后容器双向限速为 1 Mbps,无需重启。 +容器 swap 由 `incus.swap_ratio` 控制(默认 0.5,即 1 GiB 内存容器最多用 512 MiB 宿主 swap)。设置该配置后立即应用到所有容器(无需重启);`vps swap-reapply` 可在需要时手动重刷。 + 用户可以在面板设置初始化脚本。重装后脚本会在容器内以 root 运行,日志位于 `/var/log/vpsmgr-init.log`。 ## 额外镜像 diff --git a/docs/architecture.md b/docs/architecture.md index 33064ea..7569535 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,6 +115,18 @@ The panel daemon runs as the dedicated unprivileged `vps` system user - Quotas: CPU (whole cores ≥ 1, or a fraction 0.1..0.9 of one core), memory (MiB), disk (GiB). Disk maps onto the ZFS quota and can only grow, never shrink. +- **Container swap**: Incus 7 on cgroup v2 writes `memory.swap.max=0` for every + container unless `limits.memory.swap` carries an explicit byte amount + (`true`/`false` both end up as 0), so without an explicit value containers + cannot use the host's swap at all and `free` inside shows 0. vpsmgr sets + `limits.memory.swap = limits.memory × incus.swap_ratio` (default 0.5) at + create/reinstall and on quota changes. The allowance is a **per-container + cap**, not a reservation — the host swap pool is shared, so the sum of all + container allowances may over-subscribe a small host swap (e.g. zram); + lxcfs clamps the visible `SwapTotal` to the host's actual swap, so a + container never sees more swap than physically exists. Setting + `incus.swap_ratio` re-applies the allowance to every existing container + immediately (no restart); `vps swap-reapply` does the same on demand. - **Bandwidth quota**: each user can carry a monthly bandwidth quota in GiB (`users.bandwidth_quota_gb`, 0 = unlimited; counts upload + download of the current month). The 60s bandwidth sampler enforces it: over-quota containers diff --git a/docs/configuration.md b/docs/configuration.md index b0befde..1223d7b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,6 +40,7 @@ same table; `vps config list` shows the live values with this annotation. | `incus.pool` | **fixed at install** | — | storage pool | | `incus.bridge` | **fixed at install** | — | managed bridge | | `incus.socket` | operator | restart panel | Incus daemon Unix socket | +| `incus.swap_ratio` | operator | **applied immediately** | swap granted to each container as a multiple of its memory limit (`limits.memory.swap = limits.memory × ratio`); `0` disables container swap. Setting it re-applies the allowance to **all existing containers** (no restart) | | `snapshots.limit` | operator | restart panel | max snapshots a user may keep per container (`0` = default `1`) | ### How "fixed at install" is enforced @@ -103,6 +104,10 @@ incus: pool: vpsmgr bridge: incusbr0 socket: "/var/lib/incus/unix.socket" # Incus daemon Unix socket (REST API) + swap_ratio: 0.5 # swap per container as a multiple of its + # memory limit (0 = no swap; 0.5 = a 1 GiB + # container may use 512 MiB host swap); + # applied to all containers immediately on set ``` ## Port scheme (fixed) diff --git a/src/internal/cfg/cfg.go b/src/internal/cfg/cfg.go index fdab686..39c39ed 100644 --- a/src/internal/cfg/cfg.go +++ b/src/internal/cfg/cfg.go @@ -36,6 +36,15 @@ const ( // talks to it directly (no `incus` process spawn per call). DefaultSocket = "/var/lib/incus/unix.socket" + // DefaultSwapRatio is the swap granted to each container as a multiple + // of its memory limit (limits.memory.swap = limits.memory * ratio). + // Incus 7 on cgroup v2 sets memory.swap.max to 0 unless limits.memory.swap + // is given a byte amount, so without this every container gets zero swap. + // 0.5 means a 1 GiB container may use up to 512 MiB of host swap — the + // host swap pool is shared by all containers, so a ratio < 1 avoids + // over-subscribing it (see docs/configuration.md). + DefaultSwapRatio = 0.5 + // Panel listen port: a FRESH install picks a random free port in // PanelPortMin..PanelPortMax (written to panel.listen). DefaultListen // (8443) is only a code-level fallback, never the fresh-install default. @@ -171,6 +180,11 @@ type IncusCfg struct { // default matches the Debian package install // (`/var/lib/incus/unix.socket`). Socket string `yaml:"socket,omitempty"` + // SwapRatio is the swap allowed to each new container as a multiple of + // its memory limit (limits.memory.swap = limits.memory * SwapRatio). + // 0 disables container swap entirely; see DefaultSwapRatio. + // Deliberately NOT omitempty: 0 must round-trip through the config. + SwapRatio float64 `yaml:"swap_ratio"` } // SnapshotsCfg controls per-container user snapshots. @@ -186,7 +200,7 @@ func Default() *Config { c := &Config{} c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3} c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true} - c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket} + c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio} c.Snapshots = SnapshotsCfg{Limit: 1} return c } diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go index df76eae..55a3567 100644 --- a/src/internal/cfg/registry.go +++ b/src/internal/cfg/registry.go @@ -332,6 +332,19 @@ var Fields = []Field{ "/var/lib/incus/unix.socket", getStr(func(c *Config) string { return c.Incus.Socket }), nonEmpty("incus.socket", func(c *Config, v string) { c.Incus.Socket = v })}, + {"incus.swap_ratio", KindOperator, ApplyImmediate, + "swap granted to each new container as a multiple of its memory limit (0 = no swap; 0.5 = 1 GiB container gets 512 MiB swap) — applied to ALL containers immediately on set", + "0.5", + getStr(func(c *Config) string { return strconv.FormatFloat(c.Incus.SwapRatio, 'f', -1, 64) }), + func(c *Config, v string) error { + v = strings.TrimSpace(v) + r, err := strconv.ParseFloat(v, 64) + if err != nil || r < 0 { + return fmt.Errorf("incus.swap_ratio must be a non-negative number (0 disables container swap)") + } + c.Incus.SwapRatio = r + return nil + }}, {"snapshots.limit", KindOperator, ApplyRestart, "max snapshots a user may keep per container (0 = default 1)", "1", diff --git a/src/internal/cfg/registry_test.go b/src/internal/cfg/registry_test.go index 2fef201..ef971db 100644 --- a/src/internal/cfg/registry_test.go +++ b/src/internal/cfg/registry_test.go @@ -286,3 +286,28 @@ func TestNonEmptyOperators(t *testing.T) { } } } + +func TestSwapRatioValidator(t *testing.T) { + c := Default() + if got := c.Incus.SwapRatio; got != DefaultSwapRatio { + t.Fatalf("default swap ratio = %v, want %v", got, DefaultSwapRatio) + } + if err := FieldFor("incus.swap_ratio").Assign(c, "0.75"); err != nil { + t.Errorf("valid 0.75 rejected: %v", err) + } + if c.Incus.SwapRatio != 0.75 { + t.Errorf("after set 0.75, ratio = %v", c.Incus.SwapRatio) + } + // 0 must round-trip (disables container swap), not be treated as unset. + if err := FieldFor("incus.swap_ratio").Assign(c, "0"); err != nil { + t.Errorf("valid 0 rejected: %v", err) + } + if c.Incus.SwapRatio != 0 { + t.Errorf("after set 0, ratio = %v", c.Incus.SwapRatio) + } + for _, bad := range []string{"-1", "abc", ""} { + if err := FieldFor("incus.swap_ratio").Assign(c, bad); err == nil { + t.Errorf("invalid %q accepted", bad) + } + } +} diff --git a/src/internal/lx/lx.go b/src/internal/lx/lx.go index 327a6e1..a7535aa 100644 --- a/src/internal/lx/lx.go +++ b/src/internal/lx/lx.go @@ -30,6 +30,13 @@ type Client struct { socket string http *http.Client + // swapRatio is the per-container swap allowance as a multiple of the + // memory limit (limits.memory.swap = limits.memory * swapRatio). + // Incus 7 on cgroup v2 writes memory.swap.max=0 unless limits.memory.swap + // carries a byte amount, so 0 disables container swap entirely and any + // positive value grants that much host swap on top of limits.memory. + swapRatio float64 + // devLocks serialize device-map read-modify-write PATCHes per instance. // SetDisk, EnsureEth0Options and EnsureNicRateLimit each read the full // devices map and PATCH it back as a whole; without a per-instance lock @@ -41,8 +48,10 @@ type Client struct { } // New creates a client for the Incus Unix socket at path. The connection is -// lazy: nothing dials the socket until the first request. -func New(socket string) *Client { +// lazy: nothing dials the socket until the first request. swapRatio is the +// per-container swap allowance as a multiple of the memory limit (0 disables +// container swap; see cfg.DefaultSwapRatio). +func New(socket string, swapRatio float64) *Client { t := &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", socket) @@ -51,10 +60,11 @@ func New(socket string) *Client { // Per-request cap guards against a hung daemon. The operation wait uses // timeout=30 server-side, so 60s covers it with margin. return &Client{ - base: "http://unix", - socket: socket, - http: &http.Client{Transport: t, Timeout: 60 * time.Second}, - devLocks: map[string]*sync.Mutex{}, + base: "http://unix", + socket: socket, + http: &http.Client{Transport: t, Timeout: 60 * time.Second}, + swapRatio: swapRatio, + devLocks: map[string]*sync.Mutex{}, } } @@ -469,6 +479,18 @@ func (c *Client) Restart(name string) error { // now — no config knob; the admin panel renders it as " / 4096". const DefaultProcessesLimit = "4096" +// swapSizeBytes converts a memory limit in MiB and a swap ratio (multiple of +// the memory limit) into the limits.memory.swap value: the swap allowance in +// MiB, or "" when the ratio is <= 0 (no swap). Incus 7 on cgroup v2 writes +// memory.swap.max=0 unless limits.memory.swap carries a byte amount, so we +// must set an explicit byte value to enable swap at all. +func swapSizeBytes(memMB int, ratio float64) string { + if ratio <= 0 { + return "" + } + return fmt.Sprintf("%.0fMiB", float64(memMB)*ratio) +} + // cpuLimitConfig maps a CPU quota in tenths of a core onto Incus config keys. // Whole cores set `limits.cpu=`. Fractional quotas (0.1..0.9) pin the // container to a single core and add a time allowance @@ -503,9 +525,25 @@ func (c *Client) SetAutostart(name string, on bool) error { return c.patch("/1.0/instances/"+url.PathEscape(name), body, nil) } -// SetMem live-updates the memory limit. +// SetMem live-updates the memory limit and keeps the swap allowance +// proportional (limits.memory.swap = limits.memory * c.swapRatio). func (c *Client) SetMem(name string, mb int) error { - body := map[string]map[string]string{"config": {"limits.memory": strconv.Itoa(mb) + "MiB"}} + body := map[string]map[string]string{"config": { + "limits.memory": strconv.Itoa(mb) + "MiB", + "limits.memory.swap": swapSizeBytes(mb, c.swapRatio), + }} + return c.patch("/1.0/instances/"+url.PathEscape(name), body, nil) +} + +// SetSwap applies only the swap allowance for an existing container from its +// current memory limit (limits.memory.swap = limits.memory * c.swapRatio), +// leaving the memory limit untouched. Used to refresh containers created +// before a swap_ratio change or before swap support existed. The swap key is +// removed entirely when the ratio is <= 0 (PATCH merges; empty deletes). +func (c *Client) SetSwap(name string, memMB int) error { + body := map[string]map[string]string{"config": { + "limits.memory.swap": swapSizeBytes(memMB, c.swapRatio), + }} return c.patch("/1.0/instances/"+url.PathEscape(name), body, nil) } @@ -876,6 +914,7 @@ func (c *Client) Launch(pool, bridge, name, image, ip, ipv6, block, poolIPv6, ex } config := cpuLimitConfig(cpu) config["limits.memory"] = strconv.Itoa(memMB) + "MiB" + config["limits.memory.swap"] = swapSizeBytes(memMB, c.swapRatio) config["limits.processes"] = DefaultProcessesLimit config["boot.autostart"] = "true" config["security.nesting"] = "true" diff --git a/src/internal/mgr/mgr.go b/src/internal/mgr/mgr.go index bfbf0cf..7021941 100644 --- a/src/internal/mgr/mgr.go +++ b/src/internal/mgr/mgr.go @@ -67,7 +67,7 @@ type Manager struct { } func New(c *cfg.Config, d *db.DB) *Manager { - m := &Manager{cfg: c, db: d, lx: lx.New(c.Incus.Socket), fw: fw.New(c), tfx: tfx.New(c)} + m := &Manager{cfg: c, db: d, lx: lx.New(c.Incus.Socket, c.Incus.SwapRatio), fw: fw.New(c), tfx: tfx.New(c)} m.RefreshHostStats() return m } @@ -1306,6 +1306,26 @@ func (m *Manager) HardenAll() error { return firstErr } +// ApplySwapToAll refreshes the swap allowance (limits.memory.swap) of every +// existing container from its current memory limit and the configured +// incus.swap_ratio. Memory limits are untouched. Idempotent — safe to run +// after every swap_ratio change, and the way containers created before swap +// support get a swap allowance at all. Containers not present in Incus are +// skipped; the first error encountered is returned (others are still tried). +func (m *Manager) ApplySwapToAll() error { + users, err := m.db.ListUsers() + if err != nil { + return err + } + var firstErr error + for _, u := range users { + if err := m.lx.SetSwap(u.Name, u.MemMB); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + // EnsureBlockRoutes adds the deterministic /112 block (ipv6.routes) to every // existing container's eth0, so an upgrade to the /112 scheme routes each // container's whole block. Idempotent; a container without IPv6 (or not in diff --git a/src/internal/mgr/quota_test.go b/src/internal/mgr/quota_test.go index d0c1449..9321400 100644 --- a/src/internal/mgr/quota_test.go +++ b/src/internal/mgr/quota_test.go @@ -71,3 +71,24 @@ func TestUpdateQuotasValidatesBeforeApplying(t *testing.T) { t.Errorf("quotas changed despite validation failure: cpu=%d mem=%d disk=%d", u.CPU, u.MemMB, u.DiskGB) } } + +// TestApplySwapToAllPropagatesError ensures swap-reapply actually attempts an +// Incus call for every DB user, so an unreachable daemon surfaces as an error +// (and a silent no-op cannot be mistaken for success). +func TestApplySwapToAllPropagatesError(t *testing.T) { + c := cfg.Default() + c.Incus.Socket = "/nonexistent/vpsmgr-test.sock" + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer d.Close() + if _, err := d.CreateUser("carol", "h", "10.115.0.4", 3, 30003, 10200, 10, 1024, 10); err != nil { + t.Fatal(err) + } + m := New(c, d) + + if err := m.ApplySwapToAll(); err == nil { + t.Fatal("expected an error: Incus is unreachable") + } +} diff --git a/src/main.go b/src/main.go index 7a78b36..c821d2a 100644 --- a/src/main.go +++ b/src/main.go @@ -142,6 +142,10 @@ func main() { // Re-attach IPv6 routes/proxy_ndp for all existing containers. // Run by the vps-ipv6.service boot unit and `vps install`. err = cmdIPv6Reapply() + case "swap-reapply": + // Refresh limits.memory.swap on every existing container from the + // configured incus.swap_ratio (idempotent). + err = cmdSwapReapply() case "ip6": // Root helper behind the sudoers whitelist: validates every argument // before running the pinned ip -6 operations (see main_ip6.go). @@ -173,6 +177,7 @@ usage: vps del vps panel-url print panel address vps config list|set|help inspect/change config.yaml (per-field validated edits) + vps swap-reapply re-apply incus.swap_ratio to all containers (auto after config set) vps version system: vps install | serve | ipv6-reapply @@ -425,6 +430,25 @@ func cmdIPv6Reapply() error { return m.EnsureRoutedIPv6() } +// cmdSwapReapply refreshes limits.memory.swap on every existing container from +// the configured incus.swap_ratio (memory limits untouched). Setting +// incus.swap_ratio already does this automatically; this command is the manual +// / repair path (e.g. after containers were created before swap support, or to +// fix a container whose swap key drifted). Idempotent. +func cmdSwapReapply() error { + c, err := cfg.Load() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + d, err := db.Open(c.Panel.DB) + if err != nil { + return err + } + defer d.Close() + m := mgr.New(c, d) + return m.ApplySwapToAll() +} + func writeUnit(name, content string) error { p := filepath.Join("/etc/systemd/system", name) tmp := p + ".tmp" @@ -862,6 +886,23 @@ func configSet(args []string) error { case cfg.ApplyNextAdd: fmt.Printf("%s saved. Applies on the next vps add / reinstall.\n", key) case cfg.ApplyImmediate: + if key == "incus.swap_ratio" { + // The panel daemon reads the config at startup, but swap-reapply + // needs the live config (the ratio just saved) plus the DB. It + // refreshes limits.memory.swap on every container from the new + // ratio — no restart needed (live update). + d, err := db.Open(c.Panel.DB) + if err != nil { + return fmt.Errorf("config saved, but swap refresh failed: %w", err) + } + defer d.Close() + m := mgr.New(c, d) + if err := m.ApplySwapToAll(); err != nil { + return fmt.Errorf("config saved, but applying it to containers failed: %w", err) + } + fmt.Printf("%s updated and applied to all containers.\n", key) + return nil + } if err := applyV4State(c); err != nil { return err } @@ -928,9 +969,12 @@ func confirmApply(c *cfg.Config, f *cfg.Field, key string) (bool, error) { case cfg.ApplyInstall: what = "re-runs `vps install`, regenerating the firewall / routing / container wiring" case cfg.ApplyImmediate: - if key == "net.v4_forward" { + switch key { + case "net.v4_forward": what = "toggles container IPv4 inbound immediately — SSH / port / domain reachability of every container changes" - } else { + case "incus.swap_ratio": + what = "re-applies the swap allowance of every existing container (no restart)" + default: what = "applies immediately" } }