Merge pull request #1327 from kernelkit/container-limits

Container limits
This commit is contained in:
Mattias Walström
2025-12-25 21:55:43 +01:00
committed by GitHub
13 changed files with 337 additions and 46 deletions
@@ -5,4 +5,5 @@
# 'podman load', must not have a timeout.
sysv log:prio:local1,tag:%i kill:30 pid:!/run/container:%i.pid \
pre:0,/usr/sbin/container cleanup:0,/usr/sbin/container \
cgroup.system,delegate \
[2345] <!> :%i container -n %i -- container %i
+22 -2
View File
@@ -411,10 +411,20 @@ create()
logging="--log-driver syslog"
fi
# Build resource limit arguments
resource=""
if [ -n "$memory" ]; then
resource="$resource --memory=$memory"
fi
if [ -n "$cpu_limit" ]; then
resource="$resource --cpu-quota=$cpu_limit"
fi
# When we get here we've already fetched, or pulled, the image
args="$args --read-only --replace --quiet --cgroup-parent=containers $caps"
args="$args --read-only --replace --quiet $caps"
args="$args --cgroups=enabled --cgroupns=host --cgroup-parent=system/container@$name"
args="$args --restart=$restart --systemd=false --tz=local $privileged"
args="$args $vol $mount $hostname $entrypoint $env $port $logging"
args="$args $vol $mount $hostname $entrypoint $env $port $logging $resource"
pidfile=/run/container:${name}.pid
[ -n "$quiet" ] || log "---------------------------------------"
@@ -716,6 +726,8 @@ options:
--log-path PATH Path for k8s-file log pipe
-m, --mount HOST:DEST Bind mount a read-only file inside a container
--manual Do not start container automatically after creation
--memory BYTES Memory limit in bytes (supports K/M/G suffix)
--cpu-limit LIMIT CPU limit in millicores (1000m = 100% of 1 core)
-n, --name NAME Alternative way of supplying name to start/stop/restart
--privileged Give container extended privileges
-p, --publish PORT Publish ports when creating container
@@ -836,6 +848,14 @@ while [ "$1" != "" ]; do
--manual)
manual=true
;;
--memory)
shift
memory="$1"
;;
--cpu-limit)
shift
cpu_limit="$1"
;;
-n | --name)
shift
name="$1"
@@ -1,35 +0,0 @@
From 927acdbd7e19b1a9a0065ebdb88b663ec6626b88 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg <troglobit@gmail.com>
Date: Tue, 14 Oct 2025 11:49:57 +0200
Subject: [PATCH] Increase MAX_ID_LEN to support longer service identifiers
Organization: Wires
Allow service IDs up to 64 characters to support SHA-256 hashes,
UUIDs, and other long unique identifiers. This increases memory
usage by ~98 bytes per service instance, which is negligible for
typical deployments.
The IDENT column width in initctl output adapts dynamically based
on actual ID lengths in use, so short IDs remain unaffected.
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
---
src/svc.h | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/svc.h b/src/svc.h
index 74cc3ca..1a7e6f2 100644
--- a/src/svc.h
+++ b/src/svc.h
@@ -94,7 +94,7 @@ typedef enum {
SVC_NOTIFY_S6,
} svc_notify_t;
-#define MAX_ID_LEN 16
+#define MAX_ID_LEN 65
#define MAX_ARG_LEN 64
#define MAX_CMD_LEN 256
#define MAX_IDENT_LEN (MAX_ARG_LEN + MAX_ID_LEN + 1)
--
2.43.0
+1 -1
View File
@@ -1,5 +1,5 @@
# From https://github.com/troglobit/finit/releases/
sha256 7c128119129324050ff7e5b56d0f33fa152fe254d035c0d0c6f72dc75d6786f3 finit-4.14.tar.gz
sha256 7ccbcead4e3e6734c81a8c5445f4a27738f19a4ab367d702513a201db9b618c7 finit-4.15-rc1.tar.gz
# Locally calculated
sha256 868cb6c5414933a48db11186042cfe65c87480d326734bc6cf0e4b19b4a2e52a LICENSE
+1 -1
View File
@@ -4,7 +4,7 @@
#
################################################################################
FINIT_VERSION = 4.14
FINIT_VERSION = 4.15-rc1
FINIT_SITE = https://github.com/troglobit/finit/releases/download/$(FINIT_VERSION)
FINIT_LICENSE = MIT
FINIT_LICENSE_FILES = LICENSE
+19
View File
@@ -294,6 +294,25 @@ static int add(const char *name, struct lyd_node *cif)
fprintf(fp, " --checksum sha512:%s", string);
}
/* Add resource limits for Podman to enforce via cgroups */
node = lydx_get_descendant(lyd_child(cif), "resource-limit", NULL);
if (node) {
struct lyd_node *mem_node, *cpu_node;
/* Memory limit in KiB, Podman accepts with 'k' suffix */
mem_node = lydx_get_descendant(lyd_child(node), "memory", NULL);
if (mem_node)
fprintf(fp, " --memory %sk", lyd_get_value(mem_node));
/* CPU limit in millicores, convert to quota (microseconds per 100ms) */
cpu_node = lydx_get_descendant(lyd_child(node), "cpu", NULL);
if (cpu_node) {
uint32_t millicores = strtoul(lyd_get_value(cpu_node), NULL, 10);
uint32_t quota = millicores * 100; /* 1000m → 100000µs, 2000m → 200000µs */
fprintf(fp, " --cpu-limit %u", quota);
}
}
fprintf(fp, " create %s %s", name, image);
if ((string = lydx_get_cattr(cif, "command")))
@@ -22,6 +22,13 @@ module infix-containers {
prefix infix-sys;
}
revision 2025-12-09 {
description "Add resource management:
- Add resource-limit container with memory and cpu configuration.
- Add resource-usage operational data for live resource usage statistics.";
reference "internal";
}
revision 2025-10-12 {
description "Two major changes:
- Add dedicated 'ident' type for container and volume names.
@@ -341,6 +348,86 @@ module infix-containers {
}
}
container resource-limit {
description "Resource limits for the container.";
leaf memory {
description "Maximum memory limit in kibibytes, default: unlimited.";
type uint64;
units "KiB";
}
leaf cpu {
description "CPU limit in millicores, default: unlimited.
Millicores represent thousandths of a CPU core:
500 = 0.5 cores (50% of one core)
1000 = 1.0 cores (one full core)
2000 = 2.0 cores (two full cores)
3500 = 3.5 cores
This is converted to cgroup cpu.quota internally.";
type uint32;
units "millicores";
}
}
container resource-usage {
description "Runtime container resource usage statistics.";
config false;
leaf memory {
description "Used memory in kibibytes.";
type uint64;
units "KiB";
}
leaf cpu {
description "CPU usage percentage.";
type decimal64 {
fraction-digits 2;
}
units "percent";
}
container block-io {
description "Block I/O statistics";
leaf read {
description "Block I/O read in kibibytes.";
type uint64;
units "KiB";
}
leaf write {
description "Block I/O write in kibibytes.";
type uint64;
units "KiB";
}
}
container net-io {
description "Network I/O statistics";
leaf received {
description "Network I/O received in kibibytes.";
type uint64;
units "KiB";
}
leaf sent {
description "Network I/O sent in kibibytes.";
type uint64;
units "KiB";
}
}
leaf pids {
description "Number of processes/threads.";
type uint32;
}
}
list mount {
description "Files, content, and directories to mount inside container.";
key name;
+1 -1
View File
@@ -1,5 +1,5 @@
# -*- sh -*-
MODULES=(
"infix-interfaces -e containers"
"infix-containers@2025-10-12.yang"
"infix-containers@2025-12-09.yang"
)
+176
View File
@@ -1,3 +1,11 @@
"""Operational data provider for infix-containers YANG model.
Collects container status, network info, resource limits from cgroups,
and runtime statistics via podman commands.
"""
import os
import re
from .common import LOG
from .host import HOST
@@ -46,6 +54,165 @@ def network(ps, inspect):
return net
def parse_size_kib(size_str):
"""Parse size string like '1.5MB' or '512kB' to KiB (kibibytes)."""
if not size_str:
return 0
size_str = size_str.strip().upper()
# Extract numeric part and unit
match = re.match(r'([0-9.]+)\s*([KMGT]?I?B)?', size_str)
if not match:
return 0
value = float(match.group(1))
unit = match.group(2) if match.group(2) else 'B'
# Convert to KiB (kibibytes)
multipliers = {
'B': 1/1024,
'KB': 1000/1024, 'KIB': 1,
'MB': (1000**2)/1024, 'MIB': 1024,
'GB': (1000**3)/1024, 'GIB': 1024**2,
'TB': (1000**4)/1024, 'TIB': 1024**3,
}
return int(value * multipliers.get(unit, 1))
def parse_cgroup_memory(mem_str):
"""Parse cgroup memory.max value (bytes) to KiB."""
if not mem_str or mem_str == "max":
return 0
try:
mem_bytes = int(mem_str)
return mem_bytes // 1024
except ValueError:
return 0
def parse_cgroup_cpu(cpu_str):
"""Parse cgroup cpu.max value to millicores."""
if not cpu_str:
return 0
parts = cpu_str.split()
if len(parts) != 2 or parts[0] == "max":
return 0
try:
quota = int(parts[0])
period = int(parts[1])
# Convert to millicores: (quota/period) * 1000
return (quota * 1000) // period
except ValueError:
return 0
def read_cgroup_limits(inspect):
"""Read resource limits from cgroup files for a container."""
if not inspect or not isinstance(inspect, dict):
return None
cgroup_path = inspect.get("State", {}).get("CgroupPath")
if not cgroup_path:
return None
cgroup_base = f"/sys/fs/cgroup{cgroup_path}"
mem_val = 0
cpu_val = 0
try:
# Read memory limit (in bytes, convert to KiB)
mem_max_path = os.path.join(cgroup_base, "memory.max")
if os.path.exists(mem_max_path):
with open(mem_max_path, 'r') as f:
mem_str = f.read().strip()
mem_val = parse_cgroup_memory(mem_str)
# Read CPU limit (quota and period in microseconds, convert to millicores)
cpu_max_path = os.path.join(cgroup_base, "cpu.max")
if os.path.exists(cpu_max_path):
with open(cpu_max_path, 'r') as f:
cpu_str = f.read().strip()
cpu_val = parse_cgroup_cpu(cpu_str)
except Exception as e:
LOG.error(f"failed reading cgroup limits: {e}")
return None
if mem_val > 0 or cpu_val > 0:
result = {}
if mem_val > 0:
result["memory"] = f"{mem_val}"
if cpu_val > 0:
result["cpu"] = cpu_val
return result
return None
def resource_stats(name):
"""Get resource usage stats for a running container using podman stats."""
cmd = ['podman', 'stats', '--no-stream', '--format', 'json', '--no-reset', name]
try:
stats = HOST.run_json(cmd, default=[])
if not stats or len(stats) == 0:
return None
stat = stats[0]
rusage = {}
# Memory usage - parse used memory, convert to KiB
# Encode as string for uint64 compatibility
mem_usage_str = stat.get("mem_usage", "")
if "/" in mem_usage_str:
mem_used_str = mem_usage_str.split("/")[0].strip()
mem_used_kib = parse_size_kib(mem_used_str)
rusage["memory"] = f"{mem_used_kib}"
# CPU percentage - format as decimal64 with 2 fractional digits
cpu_perc = stat.get("cpu_percent", "0%").rstrip("%")
try:
rusage["cpu"] = "{:.2f}".format(float(cpu_perc))
except (ValueError, TypeError):
pass
block_io = stat.get("block_io", "0B / 0B")
if "/" in block_io:
block_read_str, block_write_str = block_io.split("/")
block_read_kib = parse_size_kib(block_read_str.strip())
block_write_kib = parse_size_kib(block_write_str.strip())
rusage["block-io"] = {}
if block_read_kib > 0:
rusage["block-io"]["read"] = f"{block_read_kib}"
if block_write_kib > 0:
rusage["block-io"]["write"] = f"{block_write_kib}"
net_io = stat.get("net_io", "0B / 0B")
if "/" in net_io:
net_rx_str, net_tx_str = net_io.split("/")
net_rx_kib = parse_size_kib(net_rx_str.strip())
net_tx_kib = parse_size_kib(net_tx_str.strip())
rusage["net-io"] = {}
if net_rx_kib > 0:
rusage["net-io"]["received"] = f"{net_rx_kib}"
if net_tx_kib > 0:
rusage["net-io"]["sent"] = f"{net_tx_kib}"
pids = stat.get("pids", "0")
try:
rusage["pids"] = int(pids)
except (ValueError, TypeError):
pass
return rusage if rusage else None
except Exception as e:
LOG.error(f"failed getting stats for {name}: {e}")
return None
def container(ps):
out = {
"name": ps["Names"][0],
@@ -70,6 +237,15 @@ def container(ps):
if net:
out["network"] = net
limits = read_cgroup_limits(inspect)
if limits:
out["resource-limit"] = limits
if out["running"]:
rusage = resource_stats(out["name"])
if rusage:
out["resource-usage"] = rusage
return out
+1
View File
@@ -21,6 +21,7 @@ image::topology.svg[Container basic topology, align=center, scaledwidth=75%]
. Create container 'web' from bundled OCI image
. Verify container 'web' has started
. Verify container 'web' is reachable on http://container-host.local:91
. Verify resource constraints and usage are available
. Stop container 'web'
. Verify container 'web' is stopped
. Restart container 'web'
+24 -4
View File
@@ -11,10 +11,10 @@ import infamy
from infamy.util import until, curl
def _verify(server):
def _verify(server, silent=False):
# TODO: Should really use mDNS here....
url = f"http://[{server}]:91/index.html"
response = curl(url)
response = curl(url, silent=silent)
return response is not None and "It works" in response
@@ -46,6 +46,10 @@ with infamy.Test() as test:
"command": "/usr/sbin/httpd -f -v -p 91",
"network": {
"host": True
},
"resource-limit": {
"memory": 512, # 512 KiB
"cpu": 50000 # 50% of one CPU
}
}
]
@@ -57,7 +61,23 @@ with infamy.Test() as test:
until(lambda: c.running(NAME), attempts=60)
with test.step("Verify container 'web' is reachable on http://container-host.local:91"):
until(lambda: _verify(addr), attempts=10)
until(lambda: _verify(addr, silent=True), attempts=10)
with test.step("Verify resource constraints and usage are available"):
data = target.get_data("/infix-containers:containers")
containers = data.get("containers", {}).get("container", [])
web = next((c for c in containers if c["name"] == NAME), None)
limits = web.get("resource-limit", {})
assert limits.get("memory") == 512, "Memory limit not set correctly"
assert limits.get("cpu") == 50000, "CPU limit not set correctly"
rusage = web.get("resource-usage", {})
assert rusage is not None, "Resource usage data not available"
mem_used = rusage.get("memory")
assert mem_used is not None, "Memory usage not reported"
print(f"Container using {mem_used} KiB memory")
with test.step("Stop container 'web'"):
c = infamy.Container(target)
@@ -71,5 +91,5 @@ with infamy.Test() as test:
with test.step("Verify container 'web' is reachable on http://container-host.local:91"):
# Wait for it to restart and respond, or fail
until(lambda: _verify(addr), attempts=60)
until(lambda: _verify(addr, silent=True), attempts=60)
test.succeed()
+4 -2
View File
@@ -99,12 +99,13 @@ def warn(msg):
RST = "\033[0m"
print(f"{YELLOW}warn - {msg}{RST}")
def curl(url, timeout=10):
def curl(url, timeout=10, silent=False):
"""Fetch a URL and return its response body as a UTF-8 string.
Args:
url (str): The full URL to fetch.
timeout (int): Request timeout in seconds.
silent (bool): If True, suppress warning on failure (for retry scenarios).
Returns:
str | None: Response body as text, or None if the request failed.
@@ -115,5 +116,6 @@ def curl(url, timeout=10):
with urllib.request.urlopen(url, timeout=timeout) as response:
return response.read().decode('utf-8', errors='replace')
except (urllib.error.URLError, ConnectionResetError, UnicodeEncodeError) as e:
print(f"[WARN] curl: failed to fetch {url}: {e}")
if not silent:
print(f"[WARN] curl: failed to fetch {url}: {e}")
return ""