mirror of
https://github.com/actions-runner-controller/actions-runner-controller.git
synced 2026-09-30 22:51:32 +02:00
When the AutoscalingRunnerSet's runner spec changes, the EphemeralRunnerSet has to delete its idle and pending runners so they are rebuilt from the new spec. That was detected by hashing Spec.EphemeralRunnerSpec into the actions.github.com/integrity-hash annotation and comparing the annotation against a freshly computed hash. Replace it with a monotonic revision counter split across spec and status. The AutoscalingRunnerSet controller bumps Spec.ActionableRevision when it patches a new runner spec across; the EphemeralRunnerSet controller advances Status.AppliedActionableRevision only after the cleanup has actually succeeded. Because the applied marker lives in status and is written last, a controller that crashes part-way through the cleanup comes back with the applied revision still behind the spec revision and redoes the work, instead of skipping runners that are still running the old spec. The drift check uses apiequality.Semantic.DeepEqual rather than cmp.Equal or reflect.DeepEqual. Most PodSpec collection fields carry omitempty, so a template containing an explicitly empty value (`env: []`) is dropped when the EphemeralRunnerSet is written and reads back as nil. A strict comparison would report drift on every reconcile, bump the revision each time, and delete every idle and pending runner forever. helpers_drift_test.go pins that behaviour with a round-trip-through-JSON test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
123 lines
4.5 KiB
Go
123 lines
4.5 KiB
Go
package actionsgithubcom
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
)
|
|
|
|
// benchmarkEphemeralRunnerSet builds a runner set roughly the size of a real
|
|
// ARC deployment: a runner container, a dind sidecar, an init container,
|
|
// resource limits, volumes and a dozen environment variables.
|
|
func benchmarkEphemeralRunnerSet() *v1alpha1.EphemeralRunnerSet {
|
|
env := make([]corev1.EnvVar, 0, 12)
|
|
for i := range 12 {
|
|
env = append(env, corev1.EnvVar{Name: fmt.Sprintf("VAR_%d", i), Value: fmt.Sprintf("value-%d", i)})
|
|
}
|
|
q := resource.MustParse
|
|
|
|
return &v1alpha1.EphemeralRunnerSet{
|
|
Spec: v1alpha1.EphemeralRunnerSetSpec{
|
|
Replicas: 10,
|
|
PatchID: 7,
|
|
EphemeralRunnerSpec: v1alpha1.EphemeralRunnerSpec{
|
|
GitHubConfigURL: "https://github.com/octo-org",
|
|
GitHubConfigSecret: "gh-config",
|
|
RunnerScaleSetID: 42,
|
|
PodTemplateSpec: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
ServiceAccountName: "runner-sa",
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
NodeSelector: map[string]string{"kubernetes.io/os": "linux", "node.kubernetes.io/pool": "runners"},
|
|
Tolerations: []corev1.Toleration{{
|
|
Key: "dedicated", Operator: corev1.TolerationOpEqual,
|
|
Value: "runners", Effect: corev1.TaintEffectNoSchedule,
|
|
}},
|
|
Volumes: []corev1.Volume{
|
|
{Name: "work", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
|
|
{Name: "dind-sock", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
|
|
},
|
|
InitContainers: []corev1.Container{{
|
|
Name: "init-dind", Image: "docker:dind",
|
|
Command: []string{"cp"},
|
|
Args: []string{"-r", "/usr/local/bin/.", "/dind"},
|
|
VolumeMounts: []corev1.VolumeMount{{Name: "dind-sock", MountPath: "/dind"}},
|
|
}},
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "runner",
|
|
Image: "ghcr.io/actions/actions-runner:2.337.0",
|
|
Command: []string{"/home/runner/run.sh"},
|
|
Env: env,
|
|
VolumeMounts: []corev1.VolumeMount{
|
|
{Name: "work", MountPath: "/home/runner/_work"},
|
|
{Name: "dind-sock", MountPath: "/var/run"},
|
|
},
|
|
Resources: corev1.ResourceRequirements{
|
|
Requests: corev1.ResourceList{corev1.ResourceCPU: q("500m"), corev1.ResourceMemory: q("1Gi")},
|
|
Limits: corev1.ResourceList{corev1.ResourceCPU: q("2"), corev1.ResourceMemory: q("4Gi")},
|
|
},
|
|
},
|
|
{
|
|
Name: "dind", Image: "docker:dind", Env: env[:6],
|
|
VolumeMounts: []corev1.VolumeMount{{Name: "dind-sock", MountPath: "/var/run"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// BenchmarkEphemeralRunnerSetActionableSpecChanged measures the drift check that
|
|
// runs on every AutoscalingRunnerSet reconcile. Reconciles are driven by
|
|
// EphemeralRunnerSet status churn via Owns(), so this executes constantly and
|
|
// its allocation count feeds directly into controller GC pressure.
|
|
//
|
|
// For reference on the machine this was written on: Semantic.DeepEqual is around
|
|
// 65us/241 allocs, versus 332us/405 allocs for cmp.Equal. If this regresses by an
|
|
// order of magnitude, something switched the comparison back to a reflection
|
|
// heavy implementation.
|
|
func BenchmarkEphemeralRunnerSetActionableSpecChanged(b *testing.B) {
|
|
b.Run("no drift", func(b *testing.B) {
|
|
current, desired := benchmarkEphemeralRunnerSet(), benchmarkEphemeralRunnerSet()
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for range b.N {
|
|
if ephemeralRunnerSetActionableSpecChanged(current, desired) {
|
|
b.Fatal("expected no drift")
|
|
}
|
|
}
|
|
})
|
|
|
|
b.Run("drift", func(b *testing.B) {
|
|
current, desired := benchmarkEphemeralRunnerSet(), benchmarkEphemeralRunnerSet()
|
|
desired.Spec.EphemeralRunnerSpec.PodTemplateSpec.Spec.Containers[0].Image = "ghcr.io/actions/actions-runner:2.338.0"
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for range b.N {
|
|
if !ephemeralRunnerSetActionableSpecChanged(current, desired) {
|
|
b.Fatal("expected drift")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// BenchmarkListenerPodSpecRequiresRecreation measures the listener pod drift
|
|
// check, which also runs on every AutoscalingListener reconcile.
|
|
func BenchmarkListenerPodSpecRequiresRecreation(b *testing.B) {
|
|
desired := desiredListenerPod()
|
|
live := livePodFromDesired(desired)
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for range b.N {
|
|
if listenerPodSpecRequiresRecreation(live, desired) {
|
|
b.Fatal("expected no recreation")
|
|
}
|
|
}
|
|
}
|