Ken Muse

Scaling ARC on a Schedule


One of the things I appreciate most about the newest version of Actions Runner Controller (ARC) is that it focus on a single responsibility. It doesn’t try to do everything in a single codebase. Instead, it lets Kubernetes experts manage their resources in alignment with Kubernetes best practices. It makes the code easier to maintain, and it makes the responsibilities clear.

The original version of ARC was powerful, but it often tried to support numerous features, leading to images and controllers that were more complex, needed web hooks, and required more permissions. This is not to disparage the team that create the code. They did a fantastic job of creating a solution that worked for many people. As the project grew, it became clear that the complexity was a barrier to adoption (and development), so they decided to reexamine the approach.

One of the most requested features for the modern version of ARC is probably to port the ScheduledOverrides feature. In the legacy community version, this feature allowed you to change the minimum runner count using a schedule. Unfortunately, it had two big drawbacks:

  • You could only configure the minimum runner count (at the time, minReplicas)
  • Calculating the current minimum runner count added a surprising amount of complexity to the code

Oddly, people that want this feature are often unaware that Kubernetes natively provides the ability to configure resources on a schedule. They just need to deploy a CronJob resource.

CronJob resources

A CronJob resource is simply a Kubernetes resource that runs a job on a schedule. It creates a Pod based on a Cron schedule. If you’re not familiar with the cron syntax, I recommend https://crontab.guru/. It’s a great tool for helping create Cron schedules. Cron syntax expresses time as a series of fields in the form minute hour day month day-of-week. You use an asterisk (*) to indicate “all”. For example, to run a scheduled task every day at 09:00 am, you would use 0 9 * * *. It has more options than that, but that’s the basic idea.

The CronJob resource creates a Kubernetes Job instance. That just means it’s spinning up a Pod to run some task. That task can be an executable, a containerized application, or even a shell script.

The AutorunningScaleSet resource

To change the minRunners, we just need to update the Custom Resource, AutoscalingRunnersets. This is the resource used to track the scale set’s configuration. The resource instance will be in the same namespace as the ARC runners it creates. All of the ARC custom resource definitions (CRDs) are in the actions.github.com group and use the version v1alpha1.

Updating resources

To update resources from thee command line, we would use kubectl. While that could work inside a cluster, Kubernetes has a better way. It has an API Server that exposes RESTful endpoints for modifying the resources. To update a resource, simply send an HTTP PATCH request to the resource’s endpoint. The general format for the API endpoint is:

1https://${APISERVER}/apis/${RESOURCE_GROUP}/${VERSION}/namespaces/${NAMESPACE}/${RESOURCE_TYPE}/${RESOURCE_INSTANCE_NAME}

For example, to update the AutoscalingRunnersets resource named my-runners in the happy namespace, you would use:

1https://kubernetes.default.svc/apis/actions.github.com/v1alpha1/namespaces/happy/autoscalingrunnersets/my-runners

It’s worth mentioning that the API Server is generally exposed to Pods as kubernetes.default.svc. However, that may not be the case in all clusters. Kubernetes provides special environment variables to every pod to that can be used to find the API Server. Those variables can be used to determine the host and port to use. The generalized pattern for the server endpoint is https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}.

Special permissions

Pods don’t automatically get access to any resource they want. They need to have the appropriate permissions. To do this, create a Service Account and bind it to a Role. The Role will have the permissions to be granted. The Pod will reference this role. These resources will exist in the same namespace as runners. The service account is primarily just a name:

1apiVersion: v1
2kind: ServiceAccount
3metadata:
4  name: scheduled-scaler-service-account

The Role is where the permissions are defined. Since we only need to PATCH the AutoscalingRunnerset, the role doesn’t need many permissions:

1kind: Role
2apiVersion: rbac.authorization.k8s.io/v1
3metadata:
4  name: scheduler-scaler-role
5rules:
6- apiGroups: ["actions.github.com"]
7  resources: ["autoscalingrunnersets"]
8  verbs: [ "patch" ]

Now, we just need to bind the two together. This gives the service account the specified Role.

 1kind: RoleBinding
 2apiVersion: rbac.authorization.k8s.io/v1
 3metadata:
 4  name: scheduled-scaler-role-binding
 5subjects:
 6- kind: ServiceAccount
 7  name: scheduled-scaler-service-account
 8roleRef:
 9  kind: Role
10  name: scheduler-scaler-role
11  apiGroup: rbac.authorization.k8s.io

Now we have a service account that has a minimal set of permissions to update the AutoscalingRunnerset resource. We can use this service account in the Pod that will run the scheduled task. Remember that to separate each item with --- when they are in the same file.

Testing permissions

Want to try out the permissions? You can use kubectl to create a Pod that uses the service account. Then use curl to send a PATCH request to the API Server. For example, to create a Pod in the namespace arc-runners and run in a shell within it:

1 kubectl run permtest --image=curlimages/curl:latest --stdin --tty -n arc-runners --overrides='{ "spec": { "serviceAccount": "scheduled-scaler-service-account" }}' --rm -i /bin/sh

To be able to call the API using curl, a few things are needed. First, the call needs a token for accessing the API server as the service account. It also needs the CA certificate for the API Server so that curl can trust the endpoint. All of these are available from a known path within the container: /var/run/secrets/kubernetes.io/serviceaccount. The token is in a file called token, and the CA certificate is in ca.crt. There’s also a file, namespace, that contains the namespace the Pod is running in.

 1# Get the API Server
 2APISERVER=${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}
 3
 4# The folder containing the service account credentials
 5SERVICEACCOUNT_DATA_PATH=/var/run/secrets/kubernetes.io/serviceaccount
 6
 7# The Pod's namespace
 8NAMESPACE=$(cat ${SERVICEACCOUNT_DATA_PATH}/namespace)
 9
10# The service account token contents
11TOKEN=$(cat ${SERVICEACCOUNT_DATA_PATH}/token)
12
13# The CA certificate path
14CACERT=${SERVICEACCOUNT_DATA_PATH}/ca.crt

These settings enable curl to be called. When patching a resource, a merge strategy is required. In this case, the strategy we’ll use is application/merge-patch+json. This strategy updates a resource by sending only the fields to be changed. The other fields will remain unchanged. The call just needs to include the fields that are changing:

1SPEC='{ "spec": { "minRunners": 30  } }'

Putting everything together, the curl call will look like this:

1curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -X PATCH "https://${APISERVER}/apis/${ARC_CUSTOM_RESOURCE_GROUP}/${ARC_CRD_VERSION}/namespaces/${NAMESPACE}/${ARC_SCALESET_RESOURCE}/${SCALESET}" -H "Content-Type: application/merge-patch+json" -H 'Accept: application/json' --data "$SPEC"

Defining the CronJob

With a role and a plan, it’s time to define the CronJob. As an example, we’ll create a CronJob that runs a script every morning at 9am UTC to update the minRunners to 30.

 1apiVersion: batch/v1
 2kind: CronJob
 3metadata:
 4  name: scale-up-arc-runners
 5spec:
 6  # The schedule for the job (required) - every day at 9am
 7  schedule: "0 9 * * *"
 8
 9  # Preserve the history for the last two successful runs (optional, defaults to 3)
10  successfulJobsHistoryLimit: 2
11
12  # Preserve the history for the last two failed runs (optional, defaults to 1)
13  failedJobsHistoryLimit: 2
14
15  # Don't schedule a new job if one is already running (optional)
16  concurrencyPolicy: Forbid
17
18  # The job must start within 60 seconds of the scheduled time (optional)
19  startingDeadlineSeconds: 60
20
21  # The timezone to use for the schedule (optional, defaults to local time)
22  timeZone: Etc/UTC
23  jobTemplate:
24    spec:
25      # The job must finish within 60 seconds (optional)
26      activeDeadlineSeconds: 60
27
28      # The template (and all of these fields) are required
29      template:
30        spec:
31          # The service account to use for the job. It needs permissions for the resource.
32          serviceAccountName: scheduled-scaler-service-account
33          containers:
34          - name: scaler
35            # A simple image with curl and a shell (from the curl team)
36            image: curlimages/curl:latest
37            imagePullPolicy: IfNotPresent
38
39            # Run a shell script
40            command:
41              - sh
42
43            # Inline script to run. Of course, this could be baked into the image!
44            # Using -c to execute a command using a non-login, non-interactive shell
45            # 
46            args:
47              - "-c"
48              - |
49                # Get the details needed for calling the API Server
50                APISERVER=${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}
51                SERVICEACCOUNT_DATA_PATH=/var/run/secrets/kubernetes.io/serviceaccount
52                NAMESPACE=$(cat ${SERVICEACCOUNT_DATA_PATH}/namespace)
53                TOKEN=$(cat ${SERVICEACCOUNT_DATA_PATH}/token)
54                CACERT=${SERVICEACCOUNT_DATA_PATH}/ca.crt
55
56                # The ARC custom resource details
57                ARC_CUSTOM_RESOURCE_GROUP=actions.github.com
58                ARC_CRD_VERSION=v1alpha1
59                ARC_SCALESET_RESOURCE=autoscalingrunnersets
60                
61                # The name of the ARC scaleset to update
62                SCALESET=arc-runner
63                
64                # The data to update. In this case, just `minRunners`.
65                SPEC="{ \"spec\": { \"minRunners\": 30  } }"
66                
67                # Call the API Server
68                curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -X PATCH "https://${APISERVER}/apis/${ARC_CUSTOM_RESOURCE_GROUP}/${ARC_CRD_VERSION}/namespaces/${NAMESPACE}/${ARC_SCALESET_RESOURCE}/${SCALESET}" -H "Content-Type: application/merge-patch+json" -H 'Accept: application/json' --data "$SPEC"                
69          # Don't automatically restart the container. Only run when it is scheduled.
70          restartPolicy: Never

To deploy the CronJob, put the configurations in a YAML file (deploy.yml). After that, kubectl can be used to manage the deployment. Assuming the runners are in the namespace ${NAMESPACE}:

  • kubectl apply -f deploy.yml -n ${NAMESPACE}. Deploys the resources.
  • kubectl get cronjobs -n ${NAMESPACE}. Show the CronJobs.
  • kubectl delete -f deploy.yml -n ${NAMESPACE}. Cleans up the resources.

You can download the complete YML file here. Don’t forget to update the SCALESET name on line 94. You’ll also want to update the SPEC on line 97 to match your requirements. Optionally, consider making a Helm chart that lets you configure the values.

You can create multiple CronJobs (sharing the same service account) if you need to change the settings at different times. If you have multiple namespaces (for multiple scale sets), you will need to deploy these resources in each namespace.

Conclusion

ARC is focused on doing one task: scaling. That means that the other tasks that the legacy version performed – including scheduled resource changes – are not part of the core code. Instead, they have to be handled using Kubernetes-native features approaches. Instead of learning ARC-specific ways to configure and manage resources, focus on understanding Kubernetes (or rely on a Kubernetes administrator). Working this way provides more flexibility and control over how the cluster and its resources are managed.