feat(swap): configurable per-container swap allowance

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 containers previously got no host swap at all
and `free` inside showed 0. Add incus.swap_ratio (default 0.5) so each
container gets limits.memory.swap = limits.memory x ratio, set at
create/reinstall and on quota changes.

- config: incus.swap_ratio field + registry entry (non-negative float,
  0 disables swap; ApplyImmediate)
- lx: Client carries swapRatio; SetMem keeps swap proportional,
  SetSwap refreshes only the swap key
- mgr: ApplySwapToAll refreshes every container from the DB memory
  quota (idempotent, no restart)
- cli: `vps config set incus.swap_ratio N` re-applies to all
  containers automatically; `vps swap-reapply` is the manual/repair
  path for containers created before swap support
- docs: configuration/architecture/README updated

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-08-20 21:01:44 +00:00
1 parent 8188bd2dbf
commit 7b31f2d4ec
11 files changed
+211 -12

No files matched your search

+3
View File
@@ -64,12 +64,15 @@ vps del <name>
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
+3
View File
@@ -64,12 +64,15 @@ vps del <name>
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`。
## 额外镜像
+12
View File
@@ -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
+5
View File
@@ -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)
+15 -1
View File
@@ -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
}
+13
View File
@@ -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",
+25
View File
@@ -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)
}
}
}
+47 -8
View File
@@ -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 "<used> / 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=<n>`. 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"
+21 -1
View File
@@ -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
+21
View File
@@ -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")
}
}
+46 -2
View File
@@ -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 <name>
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"
}
}