mirror of
https://github.com/actions-runner-controller/actions-runner-controller.git
synced 2026-10-01 01:18:07 +02:00
458 lines
17 KiB
Go
458 lines
17 KiB
Go
/*
|
|
Copyright 2020 The actions-runner-controller authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package actionsgithubcom
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
|
|
"github.com/actions/scaleset"
|
|
"github.com/go-logr/logr"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"sigs.k8s.io/controller-runtime/pkg/manager"
|
|
)
|
|
|
|
const (
|
|
// unregistrationRetryDelay is how long a request waits before it is attempted
|
|
// again after the service reported that the runner is still executing a job.
|
|
unregistrationRetryDelay = 30 * time.Second
|
|
|
|
// unregistrationMinWorkers is the lower bound on the size of the worker pool.
|
|
unregistrationMinWorkers = 4
|
|
|
|
// unregistrationMaxIdleWait bounds how long a worker sleeps when the queue
|
|
// holds nothing it can act on yet. Pushes wake workers directly, so this is
|
|
// only a backstop against a wake-up that was coalesced away.
|
|
unregistrationMaxIdleWait = 30 * time.Second
|
|
)
|
|
|
|
// runnerSelfDeregistered reports whether the runner removed its own
|
|
// registration from the Actions service before its EphemeralRunner was deleted.
|
|
//
|
|
// It is an inference drawn from state the controller already has, not a lookup,
|
|
// because the whole point is to avoid the API call. It is deliberately
|
|
// conservative in the direction that costs an API call rather than the one that
|
|
// leaks a registration.
|
|
//
|
|
// The Succeeded phase is set from a single observation: the runner container
|
|
// terminated with exit code 0. Runners are configured as ephemeral, so a runner
|
|
// that reaches a clean exit has already removed its own registration on the way
|
|
// out, and asking the service to remove it again is a wasted round trip on the
|
|
// hottest path the controller has.
|
|
//
|
|
// Every other phase is reachable with the registration still in place. A runner
|
|
// that never started, was killed, exited non-zero, or was found to be outdated
|
|
// did not get to deregister itself.
|
|
func runnerSelfDeregistered(ephemeralRunner *v1alpha1.EphemeralRunner) bool {
|
|
return ephemeralRunner.Status.Phase == v1alpha1.EphemeralRunnerPhaseSucceeded
|
|
}
|
|
|
|
// runnerUnregistration is a single queued attempt to remove one runner from the
|
|
// Actions service.
|
|
type runnerUnregistration struct {
|
|
// runner is a copy of the EphemeralRunner taken before it was deleted from
|
|
// the cluster. The workers hold the whole object rather than just the runner
|
|
// ID because resolving the credentials of the scale set the runner belongs
|
|
// to needs its GitHub configuration, and by the time a worker gets to it the
|
|
// object is usually gone from the API server.
|
|
runner *v1alpha1.EphemeralRunner
|
|
|
|
// runnerID identifies the registration to remove. It is carried separately
|
|
// because it is not always the one the runner's status reports: a runner
|
|
// deleted before the controller recorded the ID has its registration
|
|
// identified from the jitconfig secret instead.
|
|
runnerID int
|
|
|
|
// readyAt is the earliest time this request may be attempted. The zero value
|
|
// means it is ready immediately.
|
|
readyAt time.Time
|
|
}
|
|
|
|
// key identifies the registration this request is trying to remove.
|
|
//
|
|
// The runner ID alone would be the obvious key, but it is assigned by the
|
|
// Actions service and only unique within the scale set's GitHub scope. One
|
|
// controller can serve several of those, so the runner is named as well.
|
|
func (r runnerUnregistration) key() unregistrationKey {
|
|
return unregistrationKey{
|
|
namespace: r.runner.Namespace,
|
|
name: r.runner.Name,
|
|
runnerID: r.runnerID,
|
|
}
|
|
}
|
|
|
|
type unregistrationKey struct {
|
|
namespace string
|
|
name string
|
|
runnerID int
|
|
}
|
|
|
|
// RunnerUnregistrationQueue removes runners from the Actions service outside of
|
|
// the EphemeralRunner reconcile loop.
|
|
//
|
|
// Finalizing an EphemeralRunner is two unrelated pieces of work: deleting the
|
|
// Kubernetes resources it owns, and removing its registration from the Actions
|
|
// service. Only the first is local and fast. Doing them in sequence puts pod
|
|
// garbage collection behind an external, eventually consistent API, so a burst
|
|
// of completing jobs leaves completed pods standing around waiting on calls
|
|
// they have no real dependency on.
|
|
//
|
|
// So the reconciler hands the registration over to this queue and moves on.
|
|
// Push appends to a slice under a mutex and returns; a fixed pool of workers
|
|
// issues the API calls. Nothing the Actions service does can extend a reconcile.
|
|
//
|
|
// The queue is in-memory only, and deliberately so. Requests still queued when
|
|
// the controller restarts or loses leader election are dropped, and nothing
|
|
// recovers them. That is acceptable because this is not the only thing that
|
|
// removes a runner: the Actions service drops a registration on its own once
|
|
// the runner stops reporting in, so a lost request delays that cleanup rather
|
|
// than leaking the registration. Making the queue durable would mean writing to
|
|
// the API server for every deletion, which is the cost this type exists to
|
|
// avoid.
|
|
//
|
|
// A zero value is not usable. Use NewRunnerUnregistrationQueue.
|
|
type RunnerUnregistrationQueue struct {
|
|
log logr.Logger
|
|
secretResolver SecretResolver
|
|
workers int
|
|
retryDelay time.Duration
|
|
|
|
mu sync.Mutex
|
|
|
|
// ready holds requests that can be attempted now, oldest first. It is
|
|
// consumed through readyHead rather than by resliding from the front,
|
|
// because a burst of deletions arrives all at once and shifting the tail
|
|
// down on every take would make draining it quadratic, under the lock that
|
|
// Push needs.
|
|
ready []runnerUnregistration
|
|
readyHead int
|
|
|
|
// delayed holds requests waiting out a retry delay. Only a runner the
|
|
// service reported as still executing a job ends up here, so it stays short
|
|
// enough to scan on every take.
|
|
delayed []runnerUnregistration
|
|
|
|
// claimed holds the key of every request that has been taken on and not yet
|
|
// finished with, whether it is waiting in the queue or in the middle of an
|
|
// API call. Pushing a key that is already in here drops the request, so the
|
|
// service is never asked twice to remove the same registration.
|
|
//
|
|
// A retry keeps its claim, which is what this mainly protects: two copies of
|
|
// a request for a runner that is still executing a job would sit in the
|
|
// delayed list retrying in lockstep for as long as the job runs.
|
|
claimed map[unregistrationKey]struct{}
|
|
|
|
// notify carries a single wake-up token for a waiting worker. It is only
|
|
// ever sent to without blocking, so that a push is never slowed down by the
|
|
// state of the pool.
|
|
notify chan struct{}
|
|
}
|
|
|
|
// The manager owns the lifecycle of the pool.
|
|
var _ manager.Runnable = (*RunnerUnregistrationQueue)(nil)
|
|
|
|
// NewRunnerUnregistrationQueue returns a queue drained by a pool of workers
|
|
// goroutines, never fewer than unregistrationMinWorkers. The controller
|
|
// defaults to a single concurrent reconcile, and sizing the pool off that alone
|
|
// would leave one goroutine to absorb every burst.
|
|
func NewRunnerUnregistrationQueue(log logr.Logger, secretResolver SecretResolver, workers int) *RunnerUnregistrationQueue {
|
|
return &RunnerUnregistrationQueue{
|
|
log: log,
|
|
secretResolver: secretResolver,
|
|
workers: max(workers, unregistrationMinWorkers),
|
|
retryDelay: unregistrationRetryDelay,
|
|
claimed: make(map[unregistrationKey]struct{}),
|
|
notify: make(chan struct{}, 1),
|
|
}
|
|
}
|
|
|
|
// Push queues the removal of runnerID, the registration held by the given
|
|
// runner, from the Actions service.
|
|
//
|
|
// It copies what the workers need and returns. It never blocks, never fails and
|
|
// never touches the network, so no caller can be delayed by how far behind the
|
|
// pool is or by how the service is behaving.
|
|
//
|
|
// A registration that is already queued, or is in the middle of being removed,
|
|
// is not queued again, so the service is asked to remove it exactly once.
|
|
//
|
|
// Pushing to a nil queue drops the request. The only way to get one is to build
|
|
// an EphemeralRunnerReconciler by hand and never call SetupWithManager, which
|
|
// wires a queue up when the field is left unset.
|
|
func (q *RunnerUnregistrationQueue) Push(ephemeralRunner *v1alpha1.EphemeralRunner, runnerID int) {
|
|
if q == nil {
|
|
return
|
|
}
|
|
request := runnerUnregistration{runner: ephemeralRunner.DeepCopy(), runnerID: runnerID}
|
|
if !q.push(request) {
|
|
q.log.V(1).Info(
|
|
"Runner is already queued for removal from the service",
|
|
"ephemeralRunner", types.NamespacedName{Namespace: ephemeralRunner.Namespace, Name: ephemeralRunner.Name},
|
|
"runnerId", runnerID,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Start drains the queue until ctx is cancelled.
|
|
//
|
|
// It implements manager.Runnable so the pool shares the manager's lifecycle,
|
|
// starting alongside the controllers and stopping when the manager shuts down.
|
|
// Whatever is still queued at that point is dropped; see the type
|
|
// documentation for why that is safe.
|
|
func (q *RunnerUnregistrationQueue) Start(ctx context.Context) error {
|
|
q.log.Info("Starting runner unregistration workers", "workers", q.workers)
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(q.workers)
|
|
for range q.workers {
|
|
go func() {
|
|
defer wg.Done()
|
|
q.work(ctx)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
q.log.Info("Stopped runner unregistration workers", "dropped", q.len())
|
|
return nil
|
|
}
|
|
|
|
// work is the loop of a single worker. It takes whatever is ready, and when
|
|
// nothing is, waits for the shorter of the next request becoming ready and the
|
|
// next push.
|
|
func (q *RunnerUnregistrationQueue) work(ctx context.Context) {
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
request, wait, ok := q.next(time.Now())
|
|
if ok {
|
|
q.unregister(ctx, request)
|
|
continue
|
|
}
|
|
|
|
// Nothing is ready, so park until something is. wait is how long the
|
|
// earliest delayed request still has to go, and it is an upper bound
|
|
// rather than a commitment: a push cuts it short. That is what keeps a
|
|
// request needing no wait from queueing behind ones that do, even when
|
|
// every worker is parked on a retry 30 seconds out.
|
|
timer := time.NewTimer(wait)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return
|
|
case <-q.notify:
|
|
timer.Stop()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// unregister issues the removal for a single request.
|
|
//
|
|
// Failures are logged and dropped rather than retried, with one exception: the
|
|
// service refuses to remove a runner it still considers to be executing a job,
|
|
// and that request goes back on the queue. Everything else is left to the
|
|
// service, which removes a registration that stops reporting in on its own.
|
|
// There is no reconcile to fail here and no caller waiting on the result, so
|
|
// retrying anything else would only build a backlog of calls that the service
|
|
// is already going to make unnecessary.
|
|
func (q *RunnerUnregistrationQueue) unregister(ctx context.Context, request runnerUnregistration) {
|
|
runner := request.runner
|
|
log := q.log.WithValues(
|
|
"ephemeralRunner", types.NamespacedName{Namespace: runner.Namespace, Name: runner.Name},
|
|
"runnerId", request.runnerID,
|
|
)
|
|
|
|
// The claim taken when this was queued is held until the request is done
|
|
// with, so nothing can queue the same registration alongside it. A retry is
|
|
// not done with, and keeps the claim.
|
|
retrying := false
|
|
defer func() {
|
|
if !retrying {
|
|
q.release(request)
|
|
}
|
|
}()
|
|
|
|
actionsClient, err := q.secretResolver.GetActionsService(ctx, runner)
|
|
if err != nil {
|
|
log.Error(err, "Failed to get actions client to remove the runner from the service; leaving the runner for the service to clean up")
|
|
return
|
|
}
|
|
|
|
err = actionsClient.RemoveRunner(ctx, int64(request.runnerID))
|
|
switch {
|
|
case err == nil:
|
|
log.Info("Removed runner from the service")
|
|
|
|
case errors.Is(err, scaleset.RunnerNotFoundError), errors.Is(err, scaleset.NotFoundError):
|
|
// The registration is gone, which is the outcome this request wanted.
|
|
log.Info("Runner is already removed from the service")
|
|
|
|
case errors.Is(err, scaleset.JobStillRunningError):
|
|
log.Info("Runner is still executing a job, retrying the removal later", "retryAfter", q.retryDelay)
|
|
retrying = true
|
|
q.pushAfter(request, q.retryDelay)
|
|
|
|
case ctx.Err() != nil:
|
|
log.Info("Shutting down before the runner could be removed from the service; leaving the runner for the service to clean up")
|
|
|
|
default:
|
|
log.Error(err, "Failed to remove the runner from the service; leaving the runner for the service to clean up")
|
|
}
|
|
}
|
|
|
|
// next takes the oldest request that is ready to be attempted at now.
|
|
//
|
|
// When nothing is ready it reports how long to wait before asking again, which
|
|
// is the time until the earliest delayed request, capped at
|
|
// unregistrationMaxIdleWait.
|
|
func (q *RunnerUnregistrationQueue) next(now time.Time) (runnerUnregistration, time.Duration, bool) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
wait := q.promoteLocked(now)
|
|
|
|
if q.readyHead == len(q.ready) {
|
|
return runnerUnregistration{}, wait, false
|
|
}
|
|
|
|
request := q.ready[q.readyHead]
|
|
// Drop the reference so that the consumed entry does not keep the copy of
|
|
// the runner alive until the slice is reused.
|
|
q.ready[q.readyHead] = runnerUnregistration{}
|
|
q.readyHead++
|
|
if q.readyHead == len(q.ready) {
|
|
q.ready = q.ready[:0]
|
|
q.readyHead = 0
|
|
} else if q.readyHead >= len(q.ready)-q.readyHead {
|
|
remaining := len(q.ready) - q.readyHead
|
|
copy(q.ready, q.ready[q.readyHead:])
|
|
clear(q.ready[remaining:])
|
|
q.ready = q.ready[:remaining]
|
|
q.readyHead = 0
|
|
}
|
|
|
|
if q.readyHead < len(q.ready) || len(q.delayed) > 0 {
|
|
// Hand the wake-up on so that a burst of pushes, which only ever leaves a
|
|
// single token behind, spreads across the pool instead of being worked
|
|
// through by whichever worker happened to take the first one. Waking for
|
|
// a request that turns out to still be delayed costs one more pass
|
|
// through this function.
|
|
q.wake()
|
|
}
|
|
|
|
return request, 0, true
|
|
}
|
|
|
|
// promoteLocked moves every delayed request that has come due onto the ready
|
|
// list and returns how long to wait for the earliest of the ones that have not,
|
|
// capped at unregistrationMaxIdleWait.
|
|
func (q *RunnerUnregistrationQueue) promoteLocked(now time.Time) time.Duration {
|
|
if len(q.delayed) == 0 {
|
|
return unregistrationMaxIdleWait
|
|
}
|
|
|
|
// Partitioned in a single pass. A burst of runners refused together comes
|
|
// due together, and removing them one at a time would shift the rest of the
|
|
// list on every promotion, under the lock Push needs.
|
|
wait := unregistrationMaxIdleWait
|
|
kept := q.delayed[:0]
|
|
for _, request := range q.delayed {
|
|
if request.readyAt.After(now) {
|
|
wait = min(wait, request.readyAt.Sub(now))
|
|
kept = append(kept, request)
|
|
continue
|
|
}
|
|
|
|
request.readyAt = time.Time{}
|
|
q.ready = append(q.ready, request)
|
|
}
|
|
|
|
// Compacting leaves the promoted requests duplicated in the tail, where they
|
|
// would keep their copy of the runner alive until the slice is reused.
|
|
clear(q.delayed[len(kept):])
|
|
q.delayed = kept
|
|
|
|
return wait
|
|
}
|
|
|
|
// pushAfter queues request again, to be attempted no earlier than delay from
|
|
// now.
|
|
//
|
|
// The request keeps the claim it already holds, so this cannot be turned away
|
|
// as a duplicate of itself.
|
|
func (q *RunnerUnregistrationQueue) pushAfter(request runnerUnregistration, delay time.Duration) {
|
|
request.readyAt = time.Now().Add(delay)
|
|
q.enqueue(request)
|
|
}
|
|
|
|
// push queues a request for a registration that is not already queued, and
|
|
// reports whether it took it on.
|
|
func (q *RunnerUnregistrationQueue) push(request runnerUnregistration) bool {
|
|
q.mu.Lock()
|
|
if _, ok := q.claimed[request.key()]; ok {
|
|
q.mu.Unlock()
|
|
return false
|
|
}
|
|
q.claimed[request.key()] = struct{}{}
|
|
q.mu.Unlock()
|
|
|
|
q.enqueue(request)
|
|
return true
|
|
}
|
|
|
|
// release gives up the claim on a request that is done with, whatever the
|
|
// outcome was. A later request to remove the same registration is then taken on
|
|
// as a new one.
|
|
func (q *RunnerUnregistrationQueue) release(request runnerUnregistration) {
|
|
q.mu.Lock()
|
|
delete(q.claimed, request.key())
|
|
q.mu.Unlock()
|
|
}
|
|
|
|
func (q *RunnerUnregistrationQueue) enqueue(request runnerUnregistration) {
|
|
q.mu.Lock()
|
|
if request.readyAt.IsZero() {
|
|
q.ready = append(q.ready, request)
|
|
} else {
|
|
q.delayed = append(q.delayed, request)
|
|
}
|
|
q.mu.Unlock()
|
|
|
|
q.wake()
|
|
}
|
|
|
|
// wake releases one worker from its wait. The send is non-blocking against a
|
|
// single-slot buffer, so a push is never delayed by the pool, and a wake-up
|
|
// that nobody is waiting for is kept for whoever waits next.
|
|
func (q *RunnerUnregistrationQueue) wake() {
|
|
select {
|
|
case q.notify <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (q *RunnerUnregistrationQueue) len() int {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
return len(q.ready) - q.readyHead + len(q.delayed)
|
|
}
|