To run GitHub Actions self-hosted runners faster and stably by making full use of idle machine resources.
- Deploy and manage GitHub Actions self-hosted runners on multiple servers easily by using Kubernetes.
- Enable runners to finish a time-consuming initialization step before jobs are assigned in order not to make users wait longer.
- Extend lifetimes of runners from outside when jobs are failed, to investigate what causes the failure.
- Notify users whether jobs are failed or not via Slack and extend the lifetime from Slack.
- Autoscaling
- Workflow: GitHub Actions workflow
defined in one YAML file as a unit (e.g.
.github/workflows/main.yaml). - Job: Job is a user-defined sequence of commands defined under
jobs. A workflow consists of a/some job(s). - Runner: Machine or container a GitHub Actions workflow runs on. In this document, you can read the word "runner" as "self-hosted runner".
This section provides a brief description of meows. First, the architecture diagram is as follows.
As you see, the meows uses two kinds of namespaces.
One is the meows namespace, and the other is runner's namespaces.
(As a matter of convenience, I wrote only one runner's namespace in the diagram.
But you can use multiple namespaces as needed.)
The meows namespace contains controllers which admin users create.
The runner's namespace contains RunnerPool resources which users create. And some Kubernetes resources which the meows generates are there.
The meows provides one Custom Resource.
This is a Kubernetes resource for defining the specification of runner pods. According to the definition of this resource, the meows will create runner pods and register runners.
Users can create RunnerPool resources in any namespaces.
The meows consists of three types of Kubernetes workloads.
A deployment that controls runner pods on a Kubernetes cluster and runners registered to GitHub.
It consists of 3 sub-components.
- RunnerPool Reconciler
- A controller for the
RunnerPoolcustom resource.
- A controller for the
- Runner manager
- A component to manage pods and runners.
- It launches one goroutine for each RunnerPool resource and the goroutine manages pods and runners related to the RunnerPool.
- The goroutine deletes pods that exceed the deletion time or the recreate deadline.
- The goroutine deletes runners who are offline and do not have a related runner pod.
- Secret Updater
- A component to update secrets for GitHub registration tokens.
- It launches one goroutine for each RunnerPool resource.
- The goroutine periodically issues a registration token for the RunnerPool and update the secret for the token.
A deployment for extending the lifetimes of runner pods. With this, you can use Slack to control the pod extension.
It consists of 2 sub-components.
- Notifier
- An HTTP server.
- It accepts requests from the
meows-controllerand sends a message to Slack.
- Extender
- A Socket Mode client of the Slack.
- It watches Slack button events and extends the lifetime of a runner pod by calling the extended deletion time API of the pod.
It is a pod (a deployment) to run GitHub Actions self-hosted runner on.
On this, the GitHub Actions Runner will run under our agent program (endpoint) controls.
GitHub Actions schedules jobs on runners in the ways written in this section.
The following steps are needed to register a runner on GitHub Actions.
- Fetch a registration token via GitHub Actions API.
- Execute
config.shto configure a runner. - Execute
Runner.Listenerto start the long polling for GitHub Actions API.
Runner.Listener start a long polling in the end, and cmd/entrypoint/cmd/root.go#runService
handles some errors and then restarts the Runner.Listener automatically for upgrade themselves.
They upgrade the binary by themselves when a new release is out. This help
us avoid unnecessary Pod recreation.
We should execute Runner.Listener within about 30 seconds after executing config.sh.
After that, Runner.Listener fails to open a connection with GitHub Actions
API. Note that this behavior is not clearly written in the official documentation
and might change unexpectedly.
Runner has the status and busy state as written here.
status:online: The runner is running a long polling.offline: The runner is NOT running a long polling.
busy:true: The runner is running a workflow.false: The runner is NOT running a workflow.
If the --ephemeral option is given to config.sh does not repeat the
long polling again, and never gets online after the assigned job is done.
This behavior is useful for ensuring to make a clean environment for each job.
ref: https://docs.github.com/en/actions/hosting-your-own-runners/autoscaling-with-self-hosted-runners#using-ephemeral-runners-for-autoscaling
Some experiments reveal the following behaviors. Note that this behavior is not clearly written in the official documentation and might change unexpectedly.
- If there is no
onlinerunners at the time a job is created, the job is not scheduled on any runner. - If there is any
onlineand non-busyrunner at the time a job is created, the job is scheduled one of the runners. - If all the runners are
onlineandbusyat the time a job is created, the job is queued first and then scheduled right after any runner finishes its job and gets non-busy. - If all the runners are
onlineandbusyat the time a job is created and then a runner is created before any existing runner finishes its job, the job is scheduled on the newly created runner. - Two identical runners registered with the same name are recognized as the same
runner. If one runner dies
offlineholding a unprocessed job and another runner is created with the same name, the new runner starts the job once it getsonline.
The custom label
is a label to route jobs to specific types of runners. Users usually use
self-hosted runners by setting self-hosted to runs-on in a workflow
definition. If custom labels are given, users are allowed to set one of the
custom label value to runs-on. This is useful when you want to use multiple
types of runners, for example, highmemand highcpu.
meows sets the namespaced name of a RunnerPool as a custom label.
- The
RunnerPoolreconciler watchesRunnerPoolcreation events and creates aDeploymentand emptySecretwhich own runnerPods. The controller adds some labels toPods to tell other components that thePods are managed by the controller. After these steps, Other components recognize whichPods they should handle with those labels. - The secret updater get a registration token via GitHub API and update the initially created empty
Secret. After that, the secret updater will automatically update theSecretbased on the expiration date of the registration token. - Each runner
Poddoes the following steps.- Register itself as a self-hosted runner with the injected token.
- Initialize runner environment by doing the user-defined process.
- Start a long polling process and wait for GitHub Actions to assign a job.
- Run an assigned job.
- Call the Slack agent to notify users. GitHub API does not seem to provide
a way to know which runner ran a succeeded or failed job. So, this repository
provides a simple
job-failedcommand, and asks users to execute this command when the job is failed. Theif: failure()syntax allows users to run the step only when one of previous steps exit with non-zero code. - Publish the timestamp of when to delete this pod in the
/deletion_timeendpoint. If the job is succeeded or canceled, thePodpublishes the current time for delete itself. If the job is failed, thePodpublishes the future time for delete itself, for example 20 min later.
- The Slack agent notifies the result of the job on a Slack channel.
- Users can extend the failed runner if they want to by clicking a button on Slack.
- The Slack agent is running a WebSocket process to watch extending messages
from Slack. If it receives a message, it requests the
Podto update the designated time. - The Runner manager periodically checks if there are
Pods past a deletion time and if any, it deletesPods.
A Runner Pod has the following state as a GitHub Actions job runner.
initializing:Podinitializing. Prepare the necessary environment for Job. for example, booting a couple of VMs needed in a job before the job is assigned.running:Podis running. Registered in GitHub Actions.debugging: The job has finished with failure and Users can enterPodto debug.stale: The environment in thePodis dirty. If a runner restarts before completing a job, the environment in thePodmay be dirty. This state means waiting for the Pod to be removed to prevent Job execution with that stale Pod.
In addition, it has the following states as the exit state of the execution result of Runner.Listener.
retryable_error: If execution fails due to a factor other than a job, restartRunner.Listener.updating: When a newRunner.Listeneris released, it updates itself and restartsRunner.Listener.undefined: When the exit code ofRunner.Listeneris undefined. It restartsRunner.Listener.
The above states are exposed from /metrics endpoint as Prometheus metrics. See metrics.md.
Detailed running state of the runner as seen on GitHub is not provided
in the /metrics endpoint of the runner Pod.
Because those detailed states are going to be provided metrics by controller based
on the state that
controller can get from GitHubActionsAPI.
