Custom Github Runner
Table of Contents
GitHub offers managed runners for GitHub Actions/Workflows that are sufficient for most use cases, but not all. There are scenarios where these managed runners reveal their limitations:
- Building large applications requiring more than 7 GB of RAM.
- Building for different architectures.
- Accessing local resources within a private VPC.
For the first scenario, GitHub’s larger runners, which provide up to 256 GB of RAM, are available for a fee.
I encountered the second scenario. While cross-compiling with qemu-user-static
is possible, it significantly increases build times—a five-minute build could extend to nearly an hour.
For macOS, an ARM-based machine is now available in Beta, but for Linux there is no solution.
Since GitHub does not currently support ARM-based systems for Linux, creating your own runner is necessary. GitHub offers an executable for self-hosting runners that you can deploy on any server.
Kubernetes #
There is a dedicated Kubernetes Operator for dynamically spinning up pods and registering these new Github Runners on demand.
For simpler requirements, this may be overkill. Conversely, running the executable directly on a VM is not advisable, as build jobs executed in the runner’s user context could compromise the VM’s integrity. Builds could also leak state to later builds.
A more secure approach is to run the GitHub Runner within Kubernetes with additional restrictions.
Container Support #
By default, Actions cannot use Docker unless the pod is given privileged access and Docker is run as a sidecar.
This setup is acceptable if you trust all the code and dependencies executed on the runner, such as npm packages.
However, a safer method is:
- Prohibit
Dockerfile
container builds using Docker entirely. - Allow only
buildah
for building container images. - Permit
podman
for running test containers.
buildah
and podman
have different system requirements. Configuring buildah with the VFS driver and chroot isolation mode is relatively straightforward.
To improve build performance - since VFS duplicates layers at each step unless layering is disabled - it is possible to configure the system without granting the container privileged access.
Github Runner with Podman Support #
Below is a functional Kubernetes deployment manifest enabling podman
with native kernel overlay
storage.
apiVersion: apps/v1
kind: Deployment
metadata:
name: github-runner
spec:
replicas: 1
selector:
matchLabels:
app: github-runner
template:
metadata:
annotations:
container.apparmor.security.beta.kubernetes.io/github-runner: unconfined
labels:
app: github-runner
spec:
automountServiceAccountToken: false
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- my-arm-host
containers:
- command:
- /usr/bin/sh
- -c
- cd /home/coder/actions-runner; ./run.sh
env:
- name: _BUILDAH_STARTED_IN_USERNS
- name: BUILDAH_ISOLATION
value: chroot
image: ghcr.io/smerschjohann/containers/fedbox:latest
imagePullPolicy: Always
name: github-runner
resources:
limits:
cpu: "3"
memory: 10Gi
requests:
cpu: 100m
memory: 1Gi
securityContext:
seccompProfile:
type: Unconfined
volumeMounts:
- mountPath: /home/coder
name: containers
- mountPath: /dev/net/tun
name: dev-tun
enableServiceLinks: false
hostname: selfhosted-arm64
initContainers:
- command:
- /bin/sh
- -c
- |
mkdir -p /home/coder/.config/containers; echo -e "[containers]\nvolumes = [\n\t\"/proc:/proc\",\n]\ndefault_sysctls = []" > /home/coder/.config/containers/containers.conf; cp -av /runner/* /home/coder; chown -R 1000:1000 /home/coder
image: busybox:1.28
imagePullPolicy: IfNotPresent
name: init-github
resources: {}
securityContext:
runAsUser: 0
volumeMounts:
- mountPath: /runner
name: runner
- mountPath: /home/coder
name: containers
volumes:
- hostPath:
path: /opt/github-runner
type: ""
name: runner
- emptyDir:
sizeLimit: 30Gi
name: containers
- hostPath:
path: /dev/net/tun
type: CharDevice
name: dev-tun
Disabling Security Profiles #
It is necessery to disable Seccomp, SELinux and AppArmor for these build containers to prevent them from blocking storage access or any other Kernel feature required for buildah
and podman
. Crafting a custom SELinux profile that accommodates podman
requirements is complex, but would be best.
From a security perspective, disabling these measures is not ideal. Future developments may allow for more restrictive profiles than Unconfined.
It’s noteworthy that until Kubernetes 1.19, Unconfined was the default mode for all containers. Kubernetes 1.27 introduced the option to set RuntimeDefault as the default for all Pods.
One good thing: Even with these settings, the pod does not run elevated
, there are still a lot of restrictions in place.
Relevant part in manifest #
annotations:
container.apparmor.security.beta.kubernetes.io/github-runner: unconfined
securityContext:
seccompProfile:
type: Unconfined
Volume mount #
For the overlay
storage driver of podman
and buildah
, the underlying filesystem must be of a different type than overlay
, otherwise the Linux-Kernel rejects the mount.
This would be the case, if we do not mount an additional volume. But by doing so, we can make the overlay
driver happy:
Relevant part in manifest #
volumeMounts:
- mountPath: /home/coder
name: containers
[...]
volumes:
- emptyDir:
sizeLimit: 30Gi
name: containers
Networking #
Certain kernels prevent creating network namespaces inside containers when user namespaces are used. To enable networking, it’s necessary to pass the host’s /proc to the child container, as configured in the init container.
podman
also requires the /dev/net/tun
device for slirp4netns to work.
Relevant part in manifest #
[containers]
volumes = [
"/proc:/proc",
]
default_sysctls = []
- hostPath:
path: /dev/net/tun
type: CharDevice
name: dev-tun
Network Policies #
With the Deployment mentioned above, it is possible to run a Github Runner with less privileges. But the Container would still have complete access to the local network.
As it is in general best practice, we can set NetworkPolicies to further reduce the risk of the deployment.
Default Deny Policy #
Start with a default deny policy:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
Allow DNS Policy #
As DNS is required, allow it specifically.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-dns
spec:
egress:
- ports:
- port: 53
protocol: UDP
podSelector: {}
policyTypes:
- Egress
Allow Internet-only Policy #
Block local network traffic and only allow Internet traffic. The Policy below will restrict any IANA networks including Carrier-Grade NAT and the link local network:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-internet
spec:
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 100.64.0.0/10
- 169.254.0.0/16
podSelector: {}
policyTypes:
- Egress
Example #
Inside of your Github Action you can use podman
like this:
$ podman run -i --net=host \
ghcr.io/smerschjohann/containers/fedbox:latest \
/bin/bash -c "echo hello world"
hello world
Using slirp4netns will give you a warning, but will still work:
$ podman run -i \
ghcr.io/smerschjohann/containers/fedbox:latest \
/bin/bash -c "echo hello world"
WARN[0000] failed to set net.ipv6.conf.default.accept_dad sysctl: open /proc/sys/net/ipv6/conf/default/accept_dad: read-only file system
hello world