Skip to content

Commit a9a498b

Browse files
committed
feat(arc): measure the runner ceiling from each node's free capacity
A profile's sizing can now set measured: true in place of uniform node figures. At install time the role reads every node the runner pods may use (the eligibility label and the profile's node_selector, and no deliberately added NoSchedule or NoExecute taint) and every pod still holding requests. For each node it subtracts those requests and a configurable reserve_cpu and reserve_memory_gib from the node's allocatable CPU and memory, and counts how many runner pods of the profile's size fit. The sum, scaled by the safety margin, becomes the release's maxRunners, or the autoscaler's ceiling on the autoscaled profile. Pods in any scale set's namespace and finished pods are left out, so running runners never lower the ceiling they are sized by. A node in a passing condition, such as not ready or cordoned, still counts, since the ceiling lasts until the next run. A profile's own max_runners still wins, and uniform sizing is unchanged.
1 parent 7f64f21 commit a9a498b

10 files changed

Lines changed: 467 additions & 31 deletions

File tree

‎plugins/filter/arc.py‎

Lines changed: 234 additions & 23 deletions
Large diffs are not rendered by default.

‎roles/github_runner_arc/README.md‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ It runs in two ways. Inside `playbooks/site.yml`, against a k3s server host that
66

77
## Validation
88

9-
`tasks/validate.yml` checks every input that needs neither secrets nor a cluster: each org's fields, profile names (derived or overridden) that would collide or make invalid Kubernetes names, two of an org's profiles sharing a release name, a `max_runners` that is not a whole number, the controller's release name and namespace, the same org configured on two hosts, more than one autoscaled profile, values files that do not exist, the autoscaler's pool settings, sizing inputs, and the controller, probe and node label settings. Both playbooks run it in a play of its own before anything changes; the role runs it itself when included some other way. It also bootstraps the heartbeat gist when `github_runner_arc_heartbeat_bootstrap_gist` is on and no host in the inventory has one: it creates the gist with the control node's `gh` session and stops, printing the id to record.
9+
`tasks/validate.yml` checks every input that needs neither secrets nor a cluster (a measured sizing's node figures are read later, when the role installs the profile): each org's fields, profile names (derived or overridden) that would collide or make invalid Kubernetes names, two of an org's profiles sharing a release name, a `max_runners` that is not a whole number, the controller's release name and namespace, the same org configured on two hosts, more than one autoscaled profile, values files that do not exist, the autoscaler's pool settings, sizing inputs, and the controller, probe and node label settings. Both playbooks run it in a play of its own before anything changes; the role runs it itself when included some other way. It also bootstraps the heartbeat gist when `github_runner_arc_heartbeat_bootstrap_gist` is on and no host in the inventory has one: it creates the gist with the control node's `gh` session and stops, printing the id to record.
1010

1111
Before touching the cluster the role then checks the secrets it is about to write (each org's App key looks like a PEM key, the pull credential and heartbeat token are set) and proves the pull credential can read every image it installs from `github_runner_arc_image_pull_registry`, by getting a pull-scoped registry token and fetching each image's manifest. With an App-sourced pull Secret (see below) it mints each org's token and proves it the same way, before installing anything.
1212

@@ -21,7 +21,7 @@ Before touching the cluster the role then checks the secrets it is about to writ
2121
- `runs_on_label`: the extra `scaleSetLabels` entry the runners carry. Default `<org>-runners`, which pools every profile of the org under one `runs-on` label; set a different one to keep a profile out of that pool.
2222
- `scale_set_labels`: the whole `scaleSetLabels` list, in place of `runs_on_label`. An empty list sets no `scaleSetLabels`, so jobs target the scale set by its release name.
2323
- `autoscale`: `true` on at most one profile in the whole inventory makes it the scale set the autoscaler manages.
24-
- `sizing`: derive a runner ceiling from node capacity (see Sizing). On an ordinary profile without `max_runners` it sets `maxRunners`; on the autoscaled profile it sets the autoscaler's ceiling, leaving `maxRunners` as the floor.
24+
- `sizing`: derive a runner ceiling from node capacity, given as uniform figures or measured from each eligible node (see Sizing). On an ordinary profile without `max_runners` it sets `maxRunners`; on the autoscaled profile it sets the autoscaler's ceiling, leaving `maxRunners` as the floor.
2525
- `github_runner_arc_values_dir`: the control-node directory relative `values_file` paths are read from.
2626
- `github_runner_arc_kubeconfig_path`: the kubeconfig on the host the role runs against. Defaults to `github_runner_cluster`'s kubeconfig; empty means `KUBECONFIG` or `~/.kube/config`.
2727
- `github_runner_arc_controller_chart_version`, `github_runner_arc_scaleset_chart_version`: chart pins; unset installs the latest chart.
@@ -57,6 +57,22 @@ sizing:
5757
safety_margin: 0.8 # optional, default 1
5858
```
5959
60+
That assumes the nodes are alike. Where their free capacity differs a lot, `measured: true` works it out from the cluster instead, each time the role runs. It reads every node the runner pods may be placed on: every node carrying the eligibility label and the profile's `node_selector`, with no `NoSchedule` or `NoExecute` taint other than those Kubernetes adds for a passing condition. The ceiling lasts until the next run, so a node that is not ready, unreachable or cordoned when it is measured still counts, as it will once it is back. For each one it takes the node's allocatable CPU and memory, subtracts what the pods already on it request and the per-node reserve, and divides what is left by one runner pod's requests; the tighter of the two gives the runners that fit there. The sum over the nodes, scaled by the safety margin and rounded down, is the ceiling. Pods in any scale set's namespace are left out, so running runners never lower the ceiling they are sized by, as are pods that have finished. The reserve covers what requests do not show, such as a workload that uses more than it requests. The kubeconfig needs cluster-wide read access to nodes and pods. The role prints each node's figures when it runs.
61+
62+
```yaml
63+
sizing:
64+
measured: true
65+
pod_cpu_request: 1
66+
pod_memory_request_gib: 2
67+
sidecar_cpu_request: 0.25 # optional, default 0
68+
sidecar_memory_request_gib: 0.5 # optional, default 0
69+
reserve_cpu: 0.5 # optional, per node, default 0
70+
reserve_memory_gib: 1 # optional, per node, default 0
71+
safety_margin: 0.8 # optional, default 1
72+
```
73+
74+
A profile's own `max_runners` still takes precedence, and on the autoscaled profile the measured figure is the autoscaler's ceiling. Measured sizing uses the `exadev.github_runner.arc_measured_max_runners` filter.
75+
6076
The filter behind it is `exadev.github_runner.arc_max_runners`.
6177

6278
## Image pull Secret from the GitHub App

‎roles/github_runner_arc/defaults/main.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
# ARC (Actions Runner Controller) in an existing Kubernetes cluster: the controller, each org's runner scale sets, and the fleet-health platform (heartbeat and autoscaler). Each part is gated on this host's own github_runner_arc_orgs or github_runner_arc_heartbeat_gist_id, so any host that can reach the cluster's API can be the one Ansible installs from: a k3s server host brought up by github_runner_cluster, or localhost with a kubeconfig (playbooks/arc.yml). On a host github_runner_cluster made an agent, this role installs nothing.
33

4-
# Orgs whose runner scale sets this host installs. Each entry: name, app_id, image, private_key (the App's PEM private key, from any source Ansible reads) or private_key_op_reference (an op:// reference the 1Password adapter resolves into private_key), optionally installation_id (resolved from the App when empty), optionally app_secret_name (the App Secret's name in each of the org's namespaces, default <org>-github-app), and scale_set_profiles, a list of {suffix, namespace?, release_name?, values_file?, max_runners?, node_selector?, runs_on_label?, scale_set_labels?, autoscale?, sizing?}. Each profile becomes one namespace (namespace, default arc-runners-<org><suffix>), two Secrets in it, and one Helm release (release_name, default <org>-runners<suffix>); setting both overrides lets the role take over scale sets an existing install created under its own names. See the README for every profile key.
4+
# Orgs whose runner scale sets this host installs. Each entry: name, app_id, image, private_key (the App's PEM private key, from any source Ansible reads) or private_key_op_reference (an op:// reference the 1Password adapter resolves into private_key), optionally installation_id (resolved from the App when empty), optionally app_secret_name (the App Secret's name in each of the org's namespaces, default <org>-github-app), and scale_set_profiles, a list of {suffix, namespace?, release_name?, values_file?, max_runners?, node_selector?, runs_on_label?, scale_set_labels?, autoscale?, sizing?}. sizing takes uniform node figures, or measured: true to read each eligible node's free capacity from the cluster at install time. Each profile becomes one namespace (namespace, default arc-runners-<org><suffix>), two Secrets in it, and one Helm release (release_name, default <org>-runners<suffix>); setting both overrides lets the role take over scale sets an existing install created under its own names. See the README for every profile key.
55
github_runner_arc_orgs: []
66

77
# Directory on the control node that a relative scale_set_profiles[].values_file is read from. Values files are Jinja templates rendered on the control node, so the target host needs no checkout. A profile with no values_file uses the role's own templates/runner-scale-set-values.yaml.j2.

‎roles/github_runner_arc/meta/argument_specs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ argument_specs:
1313
- Each entry's scale_set_profiles is a non-empty list of profiles, each with suffix and optionally namespace (default arc-runners-<org><suffix>), release_name (default <org>-runners<suffix>), values_file, max_runners, min_runners, container_mode, resources, node_selector, runs_on_label, scale_set_labels, autoscale and sizing.
1414
- max_runners, when set, is the release's maxRunners and takes precedence over its values file and its sizing.
1515
- scale_set_labels replaces runs_on_label with the whole list of scaleSetLabels; an empty list sets none, so jobs target the release name.
16+
- sizing derives a runner ceiling from uniform node figures, or with measured true from each eligible node's allocatable CPU and memory less its current requests and reserve_cpu and reserve_memory_gib.
1617
github_runner_arc_values_dir:
1718
type: str
1819
default: ""

‎roles/github_runner_arc/tasks/install_platform.yml‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,22 @@
328328
if github_runner_arc_controller_namespace != 'actions-runner-controller' else []
329329
}}
330330
331+
- name: "Platform: measure the autoscaled profile's ceiling from the eligible nodes"
332+
ansible.builtin.include_tasks: measure_sizing.yml
333+
vars:
334+
github_runner_arc_measure: "{{ github_runner_arc_fleet.autoscaled }}"
335+
when: github_runner_arc_fleet.autoscaled is not none and github_runner_arc_fleet.autoscaled.sizing is not none and github_runner_arc_fleet.autoscaled.sizing.measured
336+
337+
- name: "Platform: fail when the autoscaler's floor is above its measured ceiling"
338+
ansible.builtin.fail:
339+
msg: >-
340+
github_runner_arc_autoscaler_floor ({{ github_runner_arc_autoscaler_floor }}) is above the ceiling measured for
341+
{{ github_runner_arc_fleet.autoscaled.label }} ({{ github_runner_arc_measured_sizing[github_runner_arc_fleet.autoscaled.label].max_runners }}).
342+
when:
343+
- github_runner_arc_fleet.autoscaled is not none
344+
- github_runner_arc_fleet.autoscaled.label in (github_runner_arc_measured_sizing | default({}))
345+
- (github_runner_arc_autoscaler_floor | int) > (github_runner_arc_measured_sizing[github_runner_arc_fleet.autoscaled.label].max_runners | int)
346+
331347
- name: "Platform: install/upgrade the autoscaler Deployment"
332348
when: github_runner_arc_fleet.autoscaled is not none
333349
kubernetes.core.k8s:
@@ -360,7 +376,13 @@
360376
- name: AUTOSCALER_USABLE_BUDGET_GI
361377
value: "{{ github_runner_arc_autoscaler_usable_budget_gi | string }}"
362378
- name: AUTOSCALER_MAX_CEILING
363-
value: "{{ (github_runner_arc_fleet.autoscaled.sizing.max_runners if github_runner_arc_fleet.autoscaled.sizing else github_runner_arc_autoscaler_max_ceiling) | string }}"
379+
value: >-
380+
{{
381+
(github_runner_arc_measured_sizing[github_runner_arc_fleet.autoscaled.label].max_runners
382+
if github_runner_arc_fleet.autoscaled.label in (github_runner_arc_measured_sizing | default({}))
383+
else github_runner_arc_fleet.autoscaled.sizing.max_runners if github_runner_arc_fleet.autoscaled.sizing
384+
else github_runner_arc_autoscaler_max_ceiling) | string
385+
}}
364386
- name: AUTOSCALER_FLOOR
365387
value: "{{ github_runner_arc_autoscaler_floor | string }}"
366388
- name: AUTOSCALER_RAISE_CONFIRM_POLLS

‎roles/github_runner_arc/tasks/install_scale_set_profile.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@
5959
- name: Install or remove the pull Secret renewal for {{ profile.label }}
6060
ansible.builtin.include_tasks: install_pull_secret_renewal.yml
6161

62+
# Only when the profile sets no max_runners of its own, which takes precedence; the autoscaled profile's measured sizing is its autoscaler's ceiling, measured in install_platform.yml.
63+
- name: Measure the eligible nodes' free capacity for {{ profile.label }}
64+
ansible.builtin.include_tasks: measure_sizing.yml
65+
vars:
66+
github_runner_arc_measure: "{{ profile }}"
67+
when: profile.sizing is not none and profile.sizing.measured and profile.max_runners is none and not profile.autoscale
68+
6269
- name: Install/upgrade the scale-set release for {{ profile.label }}
6370
# runnerScaleSetName is left to default to the release name: it is the scale set's registration identity on GitHub, so two releases sharing it make the second one's listener crash-loop registering a duplicate. scaleSetLabels is the pooling mechanism instead: an extra label every profile's runners carry alongside their own unique one, so runs-on: <label> matches every profile sharing it. A profile sets its own runs_on_label to stay out of the shared pool, or scale_set_labels to choose the whole list (empty sets none, leaving jobs to target the release name).
6471
kubernetes.core.helm:
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
# Included with `github_runner_arc_measure` set to an expanded profile whose sizing is measured (see plugins/filter/arc.py's arc_measured_max_runners): from install_scale_set_profile.yml for an ordinary profile, where the result becomes the release's maxRunners, and from install_platform.yml for the autoscaled one, where it becomes the autoscaler's ceiling. Reads every Node and every running Pod, so the kubeconfig needs cluster-wide read access to both; read-only, so it also runs under --check. The result is recorded under the profile's label in github_runner_arc_measured_sizing.
3+
4+
- name: Read the nodes for {{ github_runner_arc_measure.label }}
5+
kubernetes.core.k8s_info:
6+
kind: Node
7+
register: github_runner_arc_measure_nodes
8+
9+
- name: Read the pods that still hold requests for {{ github_runner_arc_measure.label }}
10+
kubernetes.core.k8s_info:
11+
kind: Pod
12+
field_selectors:
13+
- status.phase!=Succeeded
14+
- status.phase!=Failed
15+
register: github_runner_arc_measure_pods
16+
17+
# The same nodeSelector the runner pods get (scale-set-overlay.yaml.j2), and the pods of every scale set in the fleet left out, so that runners already running never lower the ceiling they are sized by.
18+
- name: Work out how many runners fit on the eligible nodes for {{ github_runner_arc_measure.label }}
19+
ansible.builtin.set_fact:
20+
github_runner_arc_measured_sizing: >-
21+
{{
22+
github_runner_arc_measured_sizing | default({})
23+
| combine({github_runner_arc_measure.label: github_runner_arc_measure.settings.sizing
24+
| exadev.github_runner.arc_measured_max_runners(
25+
github_runner_arc_measure_nodes.resources,
26+
github_runner_arc_measure_pods.resources,
27+
({github_runner_arc_node_label_key: github_runner_arc_node_label_value | string} if github_runner_arc_node_label_key | length > 0 else {})
28+
| combine(github_runner_arc_measure.node_selector),
29+
github_runner_arc_fleet.profiles | map(attribute='namespace') | list)})
30+
}}
31+
32+
- name: Fail when the runner ceiling cannot be measured for {{ github_runner_arc_measure.label }}
33+
ansible.builtin.fail:
34+
msg: "{{ github_runner_arc_measure.label }}: {{ github_runner_arc_measured_sizing[github_runner_arc_measure.label].errors | join('; ') }}"
35+
when: github_runner_arc_measured_sizing[github_runner_arc_measure.label].errors | length > 0
36+
37+
- name: Report the measured runner ceiling for {{ github_runner_arc_measure.label }}
38+
ansible.builtin.debug:
39+
msg:
40+
max_runners: "{{ github_runner_arc_measured.max_runners }}"
41+
fit_before_the_safety_margin: "{{ github_runner_arc_measured.theoretical }}"
42+
eligible_nodes: "{{ github_runner_arc_measured.nodes }}"
43+
skipped_nodes: "{{ github_runner_arc_measured.skipped_nodes }}"
44+
vars:
45+
github_runner_arc_measured: "{{ github_runner_arc_measured_sizing[github_runner_arc_measure.label] }}"

‎roles/github_runner_arc/tasks/validate.yml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,17 @@
7272
github_runner_arc_autoscaler_max_ceiling, all whole numbers, with the floor no higher than the ceiling.
7373
vars:
7474
github_runner_arc_ceiling: "{{ github_runner_arc_fleet.autoscaled.sizing.max_runners if github_runner_arc_fleet.autoscaled.sizing else (github_runner_arc_autoscaler_max_ceiling | default('')) }}"
75+
# A measured ceiling is only known once the cluster is read, so install_platform.yml checks the floor against it then.
76+
github_runner_arc_ceiling_measured: "{{ github_runner_arc_fleet.autoscaled.sizing.measured | default(false) if github_runner_arc_fleet.autoscaled.sizing else false }}"
7577
when:
7678
- github_runner_arc_fleet.autoscaled is not none
7779
- github_runner_arc_heartbeat_gist_id | length > 0
7880
- >-
7981
github_runner_arc_autoscaler_usable_budget_gi | default('') | string is not match('^[0-9]+$')
8082
or github_runner_arc_autoscaler_floor | default('') | string is not match('^[0-9]+$')
81-
or github_runner_arc_ceiling | string is not match('^[0-9]+$')
82-
or (github_runner_arc_autoscaler_floor | int) > (github_runner_arc_ceiling | int)
83+
or (not github_runner_arc_ceiling_measured
84+
and (github_runner_arc_ceiling | string is not match('^[0-9]+$')
85+
or (github_runner_arc_autoscaler_floor | int) > (github_runner_arc_ceiling | int)))
8386
8487
# The platform installs from whichever host sets github_runner_arc_heartbeat_gist_id, so the question is whether ANY host in the inventory sets one. scripts/heartbeat.sh checks HEARTBEAT_GIST_ID at container start, but that only shows up as a crash-looping pod, and with no gist id anywhere the platform is not installed at all. This creates the gist the same way the README's manual instructions do, then fails on purpose: its id has to be recorded by hand in a host's host_vars (and in any consumer named in github_runner_arc_heartbeat_gist_consumer), and carrying on silently would leave that undone.
8588
- name: Bootstrap the heartbeat gist if no host in the fleet has one yet

0 commit comments

Comments
 (0)