left4me: per-node system_cpus set; pin HT siblings on ovh.left4me

Replaces bundle-default system_core_count int with a per-node set of
CPU ids; reactor takes set complement for game cores. ovh.left4me sets
{0, 4} to keep both HT siblings of physical core 0 in system.slice
so games don't share L1/L2 with system work. systemd_units reactor
return inlined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
CroneKorkN 2026-05-11 00:20:28 +02:00
parent 1b3f3ecf97
commit c6caf2a1cf
Signed by: cronekorkn
SSH key fingerprint: SHA256:v0410ZKfuO1QHdgKBsdQNF64xmTxOF8osF1LIqwTcVw
3 changed files with 159 additions and 162 deletions

View file

@ -79,18 +79,24 @@ from defaults. None of these need to be declared per-node.
metadata pipeline. The same `left4me` wrapper accepts any other flask metadata pipeline. The same `left4me` wrapper accepts any other flask
subcommand: `sudo left4me seed-script-overlays <dir>`, subcommand: `sudo left4me seed-script-overlays <dir>`,
`sudo left4me routes`, `sudo left4me shell`, etc. `sudo left4me routes`, `sudo left4me shell`, etc.
- **CPU isolation is managed by this bundle**, driven by one knob: - **CPU isolation is managed by this bundle**, driven by one required
`left4me/system_core_count` (default `1`). The first N cores starting per-node knob: `left4me/system_cpus` — a set of int CPU ids that
at 0 are pinned to `system.slice` / `user.slice` / `l4d2-build.slice`; pins `system.slice` / `user.slice` / `l4d2-build.slice`. The
the rest (up to `vm/threads - 1`) are pinned to `l4d2-game.slice`. complement (`set(range(vm/threads)) - system_cpus`) pins
`l4d2-game.slice` and `l4d2-build.slice` carry `AllowedCPUs=` inline `l4d2-game.slice`. On HT hosts, list both SMT siblings of every
on their unit definitions; `system.slice` and `user.slice` are pinned physical core you want to reserve for system, otherwise games end
via drop-ins registered under `systemd/units` with the up sharing L1/L2 with system. Find pairings via
`'<parent>.d/<basename>.conf'` key convention (same shape nginx and `/sys/devices/system/cpu/cpu<n>/topology/thread_siblings_list`. On
autologin use), landing at the prod node (`ovh.left4me`, 4 physical / 8 threads, pairings
(0,4) (1,5) (2,6) (3,7)) the node sets `'system_cpus': {0, 4}` to
reserve physical core 0 entirely. `l4d2-game.slice` and
`l4d2-build.slice` carry `AllowedCPUs=` inline on their unit
definitions; `system.slice` and `user.slice` get drop-ins registered
under `systemd/units` with the `'<parent>.d/<basename>.conf'` key
convention (same shape nginx and autologin use), landing at
`/usr/local/lib/systemd/system/<slice>.d/99-left4me-cpuset.conf`. `/usr/local/lib/systemd/system/<slice>.d/99-left4me-cpuset.conf`.
The reactor raises if `vm/threads < 2` or if `system_core_count` The reactor raises if `system_cpus` includes CPUs outside
leaves no cores for games. `[0, vm/threads)` or leaves no cores for games.
- **Kernel feature requirement:** kernel-overlayfs (`CONFIG_OVERLAY_FS`). - **Kernel feature requirement:** kernel-overlayfs (`CONFIG_OVERLAY_FS`).
Standard on debian-13. Standard on debian-13.
- **Game ports** open by the web app on demand in the range 27015-27115 - **Game ports** open by the web app on demand in the range 27015-27115

View file

@ -18,11 +18,6 @@ defaults = {
# nftables_input reactor below. # nftables_input reactor below.
'port_range_start': 27000, 'port_range_start': 27000,
'port_range_end': 27999, 'port_range_end': 27999,
# Cgroup-v2 cpuset isolation. The first `system_core_count` cores
# (starting at 0) go to system / user / l4d2-build; the rest (up to
# vm/threads - 1) go to l4d2-game. Bundle refuses to apply on hosts
# with < 2 cores or when system_core_count leaves none for games.
'system_core_count': 1,
}, },
'apt': { 'apt': {
'packages': { 'packages': {
@ -130,159 +125,151 @@ def systemd_units(metadata):
workers = metadata.get('left4me/gunicorn_workers') workers = metadata.get('left4me/gunicorn_workers')
threads = metadata.get('left4me/gunicorn_threads') threads = metadata.get('left4me/gunicorn_threads')
# cgroup-v2 cpuset. First `system_core_count` cores → system/user/build; # cgroup-v2 cpuset. `system_cpus` (set of int CPU ids, declared per
# the rest → game. Refuse to apply if there's no useful split. # node) pins system/user/build; the complement pins l4d2-game. On HT
vm_threads = metadata.get('vm/threads', metadata.get('vm/cores', 1)) # hosts, list both siblings of a physical core so games don't share
if vm_threads < 2: # L1/L2 with system work — pairings via
# /sys/devices/system/cpu/cpu<n>/topology/thread_siblings_list.
vm_threads = metadata.get('vm/threads', metadata.get('vm/cores'))
all_cpus = set(range(vm_threads))
system_cpus = metadata.get('left4me/system_cpus')
if not system_cpus <= all_cpus:
raise Exception( raise Exception(
f'left4me cpu isolation needs at least 2 cores/threads, host has {vm_threads}' f'left4me/system_cpus={sorted(system_cpus)} on {vm_threads}-thread host '
f'includes CPUs outside [0, {vm_threads})'
) )
system_core_count = metadata.get('left4me/system_core_count') game_cpus = all_cpus - system_cpus
game_core_count = vm_threads - system_core_count if not game_cpus:
if system_core_count < 1 or game_core_count < 1:
raise Exception( raise Exception(
f'left4me/system_core_count={system_core_count} on {vm_threads}-thread host ' f'left4me/system_cpus={sorted(system_cpus)} on {vm_threads}-thread host '
f'leaves {game_core_count} cores for games; both must be >= 1' f'leaves no cores for games'
) )
system_cpus = '0' if system_core_count == 1 else f'0-{system_core_count - 1}' system_cpus_string = ','.join(str(t) for t in sorted(system_cpus))
game_cpus = ( game_cpus_string = ','.join(str(t) for t in sorted(game_cpus))
str(system_core_count) if game_core_count == 1
else f'{system_core_count}-{vm_threads - 1}'
)
web_service = { # Drop-in for upstream system.slice / user.slice (units we don't own).
'Unit': { # Same '<parent>.d/<basename>.conf' convention as nginx and autologin.
'Description': 'left4me web application', cpuset_dropin = {'Slice': {'AllowedCPUs': system_cpus_string}}
'After': 'network-online.target',
'Wants': 'network-online.target',
},
'Service': {
'Type': 'simple',
'User': 'left4me',
'Group': 'left4me',
'WorkingDirectory': '/opt/left4me/src',
'Environment': {
'HOME=/var/lib/left4me',
'PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
},
'EnvironmentFile': (
'/etc/left4me/host.env',
'/etc/left4me/web.env',
),
'ExecStart': (
'/opt/left4me/.venv/bin/gunicorn '
f'--workers {workers} --threads {threads} '
"--bind 127.0.0.1:8000 'l4d2web.app:create_app()'"
),
'Restart': 'on-failure',
'RestartSec': '3',
# NoNewPrivileges intentionally NOT set: workers sudo to the helpers.
'ProtectSystem': 'full',
'ReadWritePaths': '/var/lib/left4me',
'PrivateTmp': 'true',
},
'Install': {
'WantedBy': {'multi-user.target'},
},
}
server_template = {
'Unit': {
'Description': 'left4me server instance %i',
'After': 'network-online.target',
'Wants': 'network-online.target',
'StartLimitBurst': '5',
'StartLimitIntervalSec': '60s',
},
'Service': {
'Type': 'simple',
'User': 'left4me',
'Group': 'left4me',
'EnvironmentFile': (
'/etc/left4me/host.env',
'/var/lib/left4me/instances/%i/instance.env',
),
'WorkingDirectory': '-/var/lib/left4me/runtime/%i/merged/left4dead2',
'ExecStartPre': (
'+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- '
'/usr/local/libexec/left4me/left4me-overlay mount %i'
),
'ExecStart': (
'/var/lib/left4me/runtime/%i/merged/srcds_run '
'-game left4dead2 +hostport ${L4D2_PORT} $L4D2_ARGS'
),
'ExecStopPost': (
'+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- '
'/usr/local/libexec/left4me/left4me-overlay umount %i'
),
'Restart': 'on-failure',
'RestartSec': '5',
'Slice': 'l4d2-game.slice',
'Nice': '-5',
'IOSchedulingClass': 'best-effort',
'IOSchedulingPriority': '4',
'OOMScoreAdjust': '-200',
'MemoryHigh': '1.5G',
'MemoryMax': '2G',
'TasksMax': '256',
'LimitNOFILE': '65536',
'KillSignal': 'SIGINT',
'TimeoutStopSec': '15s',
'LogRateLimitIntervalSec': '0',
'NoNewPrivileges': 'true',
'PrivateTmp': 'true',
'PrivateDevices': 'true',
'ProtectHome': 'true',
'ProtectSystem': 'strict',
'ReadOnlyPaths': '/var/lib/left4me/installation /var/lib/left4me/overlays',
'ReadWritePaths': '/var/lib/left4me/runtime/%i',
'RestrictSUIDSGID': 'true',
'LockPersonality': 'true',
},
'Install': {
'WantedBy': {'multi-user.target'},
},
}
game_slice = {
'Unit': {
'Description': 'left4me game-server slice',
'Before': 'slices.target',
},
'Slice': {
'CPUWeight': '1000',
'IOWeight': '1000',
'AllowedCPUs': game_cpus,
},
}
build_slice = {
'Unit': {
'Description': 'left4me script-sandbox build slice',
'Before': 'slices.target',
},
'Slice': {
'CPUWeight': '10',
'IOWeight': '10',
'AllowedCPUs': system_cpus,
},
}
units = {
'left4me-web.service': web_service,
'left4me-server@.service': server_template,
'l4d2-game.slice': game_slice,
'l4d2-build.slice': build_slice,
}
# Drop-ins on the upstream system.slice / user.slice (units we don't
# own). Same '<parent>.d/<basename>.conf' convention as nginx and
# autologin use elsewhere in this repo.
cpuset_dropin = {'Slice': {'AllowedCPUs': system_cpus}}
units['system.slice.d/99-left4me-cpuset.conf'] = cpuset_dropin
units['user.slice.d/99-left4me-cpuset.conf'] = cpuset_dropin
return { return {
'systemd': { 'systemd': {
'units': units, 'units': {
'left4me-web.service': {
'Unit': {
'Description': 'left4me web application',
'After': 'network-online.target',
'Wants': 'network-online.target',
},
'Service': {
'Type': 'simple',
'User': 'left4me',
'Group': 'left4me',
'WorkingDirectory': '/opt/left4me/src',
'Environment': {
'HOME=/var/lib/left4me',
'PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
},
'EnvironmentFile': (
'/etc/left4me/host.env',
'/etc/left4me/web.env',
),
'ExecStart': (
'/opt/left4me/.venv/bin/gunicorn '
f'--workers {workers} --threads {threads} '
"--bind 127.0.0.1:8000 'l4d2web.app:create_app()'"
),
'Restart': 'on-failure',
'RestartSec': '3',
# NoNewPrivileges intentionally NOT set: workers sudo to the helpers.
'ProtectSystem': 'full',
'ReadWritePaths': '/var/lib/left4me',
'PrivateTmp': 'true',
},
'Install': {
'WantedBy': {'multi-user.target'},
},
},
'left4me-server@.service': {
'Unit': {
'Description': 'left4me server instance %i',
'After': 'network-online.target',
'Wants': 'network-online.target',
'StartLimitBurst': '5',
'StartLimitIntervalSec': '60s',
},
'Service': {
'Type': 'simple',
'User': 'left4me',
'Group': 'left4me',
'EnvironmentFile': (
'/etc/left4me/host.env',
'/var/lib/left4me/instances/%i/instance.env',
),
'WorkingDirectory': '-/var/lib/left4me/runtime/%i/merged/left4dead2',
'ExecStartPre': (
'+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- '
'/usr/local/libexec/left4me/left4me-overlay mount %i'
),
'ExecStart': (
'/var/lib/left4me/runtime/%i/merged/srcds_run '
'-game left4dead2 +hostport ${L4D2_PORT} $L4D2_ARGS'
),
'ExecStopPost': (
'+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- '
'/usr/local/libexec/left4me/left4me-overlay umount %i'
),
'Restart': 'on-failure',
'RestartSec': '5',
'Slice': 'l4d2-game.slice',
'Nice': '-5',
'IOSchedulingClass': 'best-effort',
'IOSchedulingPriority': '4',
'OOMScoreAdjust': '-200',
'MemoryHigh': '1.5G',
'MemoryMax': '2G',
'TasksMax': '256',
'LimitNOFILE': '65536',
'KillSignal': 'SIGINT',
'TimeoutStopSec': '15s',
'LogRateLimitIntervalSec': '0',
'NoNewPrivileges': 'true',
'PrivateTmp': 'true',
'PrivateDevices': 'true',
'ProtectHome': 'true',
'ProtectSystem': 'strict',
'ReadOnlyPaths': '/var/lib/left4me/installation /var/lib/left4me/overlays',
'ReadWritePaths': '/var/lib/left4me/runtime/%i',
'RestrictSUIDSGID': 'true',
'LockPersonality': 'true',
},
'Install': {
'WantedBy': {'multi-user.target'},
},
},
'l4d2-game.slice': {
'Unit': {
'Description': 'left4me game-server slice',
'Before': 'slices.target',
},
'Slice': {
'CPUWeight': '1000',
'IOWeight': '1000',
'AllowedCPUs': game_cpus_string,
},
},
'l4d2-build.slice': {
'Unit': {
'Description': 'left4me script-sandbox build slice',
'Before': 'slices.target',
},
'Slice': {
'CPUWeight': '10',
'IOWeight': '10',
'AllowedCPUs': system_cpus_string,
},
},
'system.slice.d/99-left4me-cpuset.conf': cpuset_dropin,
'user.slice.d/99-left4me-cpuset.conf': cpuset_dropin,
},
}, },
} }

View file

@ -43,6 +43,10 @@
}, },
'left4me': { 'left4me': {
'domain': 'left4.me', 'domain': 'left4.me',
# Both HT siblings of physical core 0 (cpu0+cpu4 per
# /sys/devices/system/cpu/cpu0/topology/thread_siblings_list).
# Keeps system work off the physical cores running game ticks.
'system_cpus': {0, 4},
}, },
}, },
} }