feat(runners): add host-share CPU limits
This commit is contained in:
@@ -21,6 +21,9 @@ HOSTS = {
|
||||
DEFAULT_MEM_FRACTION = 0.75
|
||||
DEFAULT_JOB_MEM_FRACTION = 0.9
|
||||
DEFAULT_HOST_MEM_SHARE = 0.25
|
||||
DEFAULT_HOST_CPU_SHARE = 0.25
|
||||
DEFAULT_CPU_FRACTION = 1.0
|
||||
DEFAULT_JOB_CPU_FRACTION = 0.9
|
||||
MIN_RUNNER_MEM_MIB = 1024
|
||||
MIN_JOB_MEM_MIB = 128
|
||||
|
||||
@@ -66,6 +69,48 @@ def parse_host_mem_share(argv):
|
||||
raise SystemExit("--host-mem-share must be > 0 and <= 1")
|
||||
return share
|
||||
|
||||
|
||||
def parse_host_cpu_share(argv):
|
||||
share = DEFAULT_HOST_CPU_SHARE
|
||||
for arg in argv[1:]:
|
||||
if arg.startswith("--host-cpu-share="):
|
||||
_, value = arg.split("=", 1)
|
||||
try:
|
||||
share = float(value)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"Invalid --host-cpu-share value: {value}") from exc
|
||||
if share <= 0 or share > 1:
|
||||
raise SystemExit("--host-cpu-share must be > 0 and <= 1")
|
||||
return share
|
||||
|
||||
|
||||
def parse_cpu_fraction(argv):
|
||||
fraction = DEFAULT_CPU_FRACTION
|
||||
for arg in argv[1:]:
|
||||
if arg.startswith("--cpu-fraction="):
|
||||
_, value = arg.split("=", 1)
|
||||
try:
|
||||
fraction = float(value)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"Invalid --cpu-fraction value: {value}") from exc
|
||||
if fraction <= 0 or fraction > 1:
|
||||
raise SystemExit("--cpu-fraction must be > 0 and <= 1")
|
||||
return fraction
|
||||
|
||||
|
||||
def parse_job_cpu_fraction(argv):
|
||||
fraction = DEFAULT_JOB_CPU_FRACTION
|
||||
for arg in argv[1:]:
|
||||
if arg.startswith("--job-cpu-fraction="):
|
||||
_, value = arg.split("=", 1)
|
||||
try:
|
||||
fraction = float(value)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"Invalid --job-cpu-fraction value: {value}") from exc
|
||||
if fraction <= 0 or fraction > 1:
|
||||
raise SystemExit("--job-cpu-fraction must be > 0 and <= 1")
|
||||
return fraction
|
||||
|
||||
def ssh(host, cmd):
|
||||
proc = subprocess.run(
|
||||
[
|
||||
@@ -176,7 +221,7 @@ def remove_service_key(compose_text, service_names, key):
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def upsert_service_deploy_memory(compose_text, service_names, value):
|
||||
def upsert_service_deploy_limits(compose_text, service_names, memory_value, cpu_value):
|
||||
lines = compose_text.splitlines()
|
||||
i = 0
|
||||
|
||||
@@ -193,24 +238,41 @@ def upsert_service_deploy_memory(compose_text, service_names, value):
|
||||
memory_indent = " " * (service_indent + 8)
|
||||
|
||||
memory_idx = None
|
||||
cpus_idx = None
|
||||
for idx in range(block_start, block_end):
|
||||
if lines[idx].strip().startswith("memory:"):
|
||||
memory_idx = idx
|
||||
break
|
||||
if lines[idx].strip().startswith("cpus:"):
|
||||
cpus_idx = idx
|
||||
|
||||
if memory_idx is not None:
|
||||
lines[memory_idx] = f"{memory_indent}memory: {value}"
|
||||
if cpus_idx is not None:
|
||||
lines[cpus_idx] = f"{memory_indent}cpus: '{cpu_value}'"
|
||||
|
||||
if memory_idx is not None and cpus_idx is not None:
|
||||
i = block_end
|
||||
continue
|
||||
|
||||
insert_lines = [
|
||||
f"{deploy_indent}deploy:",
|
||||
f"{resources_indent}resources:",
|
||||
f"{limits_indent}limits:",
|
||||
f"{memory_indent}memory: {value}",
|
||||
]
|
||||
lines = lines[:block_start] + insert_lines + lines[block_start:]
|
||||
i = block_end + len(insert_lines)
|
||||
if memory_idx is None and cpus_idx is None:
|
||||
insert_lines = [
|
||||
f"{deploy_indent}deploy:",
|
||||
f"{resources_indent}resources:",
|
||||
f"{limits_indent}limits:",
|
||||
f"{memory_indent}memory: {memory_value}",
|
||||
f"{memory_indent}cpus: '{cpu_value}'",
|
||||
]
|
||||
lines = lines[:block_start] + insert_lines + lines[block_start:]
|
||||
i = block_end + len(insert_lines)
|
||||
continue
|
||||
|
||||
missing_lines = []
|
||||
if memory_idx is None:
|
||||
missing_lines.append(f"{memory_indent}memory: {memory_value}")
|
||||
if cpus_idx is None:
|
||||
missing_lines.append(f"{memory_indent}cpus: '{cpu_value}'")
|
||||
lines = lines[:block_start] + missing_lines + lines[block_start:]
|
||||
i = block_end + len(missing_lines)
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@@ -308,7 +370,7 @@ def find_service_memory_value(compose_text, service_names):
|
||||
return None
|
||||
|
||||
|
||||
def build_compose(compose_text, runner_mem_limit, job_mem_limit):
|
||||
def build_compose(compose_text, runner_mem_limit, runner_cpu_limit, job_mem_limit, job_cpu_limit):
|
||||
updated = compose_text
|
||||
|
||||
volume_line = " - ./daemon.json:/etc/docker/daemon.json:ro"
|
||||
@@ -323,7 +385,7 @@ def build_compose(compose_text, runner_mem_limit, job_mem_limit):
|
||||
updated = upsert_compose_env_var(
|
||||
updated,
|
||||
"GITEA_RUNNER_JOB_CONTAINER_OPTIONS",
|
||||
f"--memory={job_mem_limit} --memory-swap={job_mem_limit}",
|
||||
f"--memory={job_mem_limit} --memory-swap={job_mem_limit} --cpus={job_cpu_limit}",
|
||||
force_pull_line,
|
||||
)
|
||||
elif add_cfg_env not in updated:
|
||||
@@ -331,7 +393,7 @@ def build_compose(compose_text, runner_mem_limit, job_mem_limit):
|
||||
|
||||
runner_services = {"act_runner_1", "act_runner_2", "runner1", "runner2"}
|
||||
updated = remove_service_key(updated, runner_services, "mem_limit")
|
||||
updated = upsert_service_deploy_memory(updated, runner_services, runner_mem_limit)
|
||||
updated = upsert_service_deploy_limits(updated, runner_services, runner_mem_limit, runner_cpu_limit)
|
||||
|
||||
return updated
|
||||
|
||||
@@ -343,6 +405,20 @@ def host_total_mem_mib(host):
|
||||
return int(int(mem_kib.strip()) / 1024.0)
|
||||
|
||||
|
||||
def host_total_cpus(host):
|
||||
rc, cpu_count = ssh(host, "nproc")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Could not read CPU count on {host}")
|
||||
value = cpu_count.strip()
|
||||
try:
|
||||
cpus = float(value)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Invalid CPU count on {host}: {value}") from exc
|
||||
if cpus <= 0:
|
||||
raise RuntimeError(f"Non-positive CPU count on {host}: {value}")
|
||||
return cpus
|
||||
|
||||
|
||||
def host_mem_limit(host, host_mem_share, fraction):
|
||||
total_mib = host_total_mem_mib(host)
|
||||
host_share_mib = int(total_mib * host_mem_share)
|
||||
@@ -356,13 +432,32 @@ def host_mem_limit(host, host_mem_share, fraction):
|
||||
)
|
||||
|
||||
|
||||
def host_cpu_limit(host, host_cpu_share, fraction):
|
||||
total_cpus = host_total_cpus(host)
|
||||
host_share_cpus = total_cpus * host_cpu_share
|
||||
target_cpus = host_share_cpus * fraction
|
||||
if target_cpus <= 0.01:
|
||||
target_cpus = 0.01
|
||||
return (
|
||||
f"{target_cpus:.2f}",
|
||||
target_cpus,
|
||||
f"host-total({total_cpus:.0f}cpu) * host-share({host_cpu_share:.2f}) -> {host_share_cpus:.2f}cpu * cpu-fraction({fraction:.2f})",
|
||||
)
|
||||
|
||||
|
||||
mem_fraction = parse_mem_fraction(sys.argv)
|
||||
job_mem_fraction = parse_job_mem_fraction(sys.argv)
|
||||
host_mem_share = parse_host_mem_share(sys.argv)
|
||||
host_cpu_share = parse_host_cpu_share(sys.argv)
|
||||
cpu_fraction = parse_cpu_fraction(sys.argv)
|
||||
job_cpu_fraction = parse_job_cpu_fraction(sys.argv)
|
||||
|
||||
print(f"Host mem share: {host_mem_share:.2f}")
|
||||
print(f"Runner mem fraction: {mem_fraction:.2f}")
|
||||
print(f"Job mem fraction: {job_mem_fraction:.2f}")
|
||||
print(f"Host cpu share: {host_cpu_share:.2f}")
|
||||
print(f"Runner cpu fraction: {cpu_fraction:.2f}")
|
||||
print(f"Job cpu fraction: {job_cpu_fraction:.2f}")
|
||||
print("Fetching canonical runner config template")
|
||||
rc, template = ssh(TEMPLATE_HOST, f"cat {TEMPLATE_PATH}")
|
||||
if rc != 0 or not template.strip():
|
||||
@@ -390,12 +485,19 @@ with tempfile.TemporaryDirectory() as tmpdir:
|
||||
host_mem_share,
|
||||
mem_fraction,
|
||||
)
|
||||
runner_cpu_limit, runner_cpu_value, cpu_source = host_cpu_limit(
|
||||
host,
|
||||
host_cpu_share,
|
||||
cpu_fraction,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(str(exc))
|
||||
continue
|
||||
|
||||
print(f"runner_mem_baseline={baseline_source}")
|
||||
print(f"runner_mem_limit={runner_mem_limit} ({runner_mem_mib} MiB)")
|
||||
print(f"runner_cpu_baseline={cpu_source}")
|
||||
print(f"runner_cpu_limit={runner_cpu_limit} cpu")
|
||||
|
||||
job_mem_mib = int(runner_mem_mib * job_mem_fraction)
|
||||
if job_mem_mib < MIN_JOB_MEM_MIB:
|
||||
@@ -405,7 +507,21 @@ with tempfile.TemporaryDirectory() as tmpdir:
|
||||
job_mem_limit = f"{job_mem_mib}m"
|
||||
print(f"job_mem_limit={job_mem_limit} ({job_mem_mib} MiB)")
|
||||
|
||||
new_compose = build_compose(compose, runner_mem_limit, job_mem_limit)
|
||||
job_cpu_value = runner_cpu_value * job_cpu_fraction
|
||||
if job_cpu_value <= 0.01:
|
||||
job_cpu_value = 0.01
|
||||
if job_cpu_value >= runner_cpu_value and runner_cpu_value > 0.02:
|
||||
job_cpu_value = runner_cpu_value - 0.01
|
||||
job_cpu_limit = f"{job_cpu_value:.2f}"
|
||||
print(f"job_cpu_limit={job_cpu_limit} cpu")
|
||||
|
||||
new_compose = build_compose(
|
||||
compose,
|
||||
runner_mem_limit,
|
||||
runner_cpu_limit,
|
||||
job_mem_limit,
|
||||
job_cpu_limit,
|
||||
)
|
||||
|
||||
rc, _ = ssh(host, f"mkdir -p {root}/backups")
|
||||
rc, _ = ssh(host, f"cp -f {root}/compose.yml {root}/backups/compose.yml.{now}")
|
||||
@@ -435,7 +551,7 @@ with tempfile.TemporaryDirectory() as tmpdir:
|
||||
print(out.strip())
|
||||
rc, out = ssh(host, f"grep -n 'GITEA_RUNNER_JOB_CONTAINER_OPTIONS=' {root}/compose.yml")
|
||||
print(out.strip())
|
||||
rc, out = ssh(host, f"grep -n 'memory:' {root}/compose.yml")
|
||||
rc, out = ssh(host, f"grep -nE 'memory:|cpus:' {root}/compose.yml")
|
||||
print(out.strip())
|
||||
|
||||
rc, out = ssh(host, f"docker compose -f {root}/compose.yml up -d act_runner_1")
|
||||
|
||||
Reference in New Issue
Block a user