fix(build): reap finished build Jobs — they accumulated forever

Every other Job family carries a TTLSecondsAfterFinished (fileedit 2m,
backup/restore 10m) but the build lane never set one: each build left a
completed Job + Pod in felis-build indefinitely (5 already on the drill
cluster, oldest 26h), growing etcd and — since completed pods count
against the node's pod budget (110 on stock k3s) — eventually blocking
new builds.  The code even anticipated GC it never got ("JobUnknown
means the Job was not found (e.g. GC'd)").

Set a deliberately long TTL (7 days): the kaniko log is the admin
failure-triage surface (GET /images/build/{id}/logs), so the window
keeps a week of logs while bounding steady-state pods; terminal builds
are idempotent under Sync, so a late log-404 is the only cost.
This commit is contained in:
Lemon-miaow committed 2026-09-24 02:03:20 +08:00
1 parent d5e623c7ba
commit 2755e41ff3
2 files changed
+33

No files matched your search

+16
View File
@@ -51,6 +51,20 @@ const (
// upload is capped at 1 GiB by the submit lane; 4 GiB leaves expansion room. // upload is capped at 1 GiB by the submit lane; 4 GiB leaves expansion room.
var contextSizeLimit = resource.MustParse("4Gi") var contextSizeLimit = resource.MustParse("4Gi")
// buildJobTTL is how long a finished build Job survives before the Job
// controller deletes it — and with it the Pod whose kaniko log is the admin
// failure-triage surface (GET /api/v1/images/build/{id}/logs).
//
// Every other Job family the platform renders carries a TTL (fileedit 2m,
// backup/restore 10m); the build lane deliberately keeps a much longer one
// because the logs are the point. Without ANY TTL the Job and its completed
// Pod accumulate one pair per build forever: they count against the node's
// pod budget (110 on stock k3s), grow etcd, and eventually block new builds.
// Sync already tolerates a vanished Job (JobUnknown → failed; terminal builds
// are returned unchanged), so a week-old log falling off costs a 404, not a
// status flip.
const buildJobTTL = 7 * 24 * time.Hour
// JobParams are the rendered inputs to a build Job. They are derived from a // JobParams are the rendered inputs to a build Job. They are derived from a
// Build + Config by the Builder; jobspec is a pure function of them so the // Build + Config by the Builder; jobspec is a pure function of them so the
// security-critical Job shape is unit-tested without a cluster. // security-critical Job shape is unit-tested without a cluster.
@@ -241,6 +255,8 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
// A poisoned build must not loop — one shot, then a terminal verdict. // A poisoned build must not loop — one shot, then a terminal verdict.
BackoffLimit: int32Ptr(0), BackoffLimit: int32Ptr(0),
ActiveDeadlineSeconds: int64Ptr(deadline), ActiveDeadlineSeconds: int64Ptr(deadline),
// ...and a finished one must not linger forever (see buildJobTTL).
TTLSecondsAfterFinished: int32Ptr(int32(buildJobTTL / time.Second)),
Template: corev1.PodTemplateSpec{ Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: buildLabels(p)}, ObjectMeta: metav1.ObjectMeta{Labels: buildLabels(p)},
Spec: corev1.PodSpec{ Spec: corev1.PodSpec{
+17
View File
@@ -71,6 +71,23 @@ func TestBuildJobIsBoundedAndOneShot(t *testing.T) {
} }
} }
// A finished build must not squat in the namespace forever: without a TTL the
// Job and its completed Pod accumulate one pair per build and eventually eat
// the node's pod budget. The window is deliberately long (see buildJobTTL) so
// the admin log stream stays useful for triage.
func TestBuildJobIsReapedAfterCompletion(t *testing.T) {
job, err := BuildJob(sampleJobParams())
if err != nil {
t.Fatalf("BuildJob: %v", err)
}
if job.Spec.TTLSecondsAfterFinished == nil {
t.Fatal("TTLSecondsAfterFinished must be set so the finished Job (and its log Pod) is GC'd")
}
if got, want := *job.Spec.TTLSecondsAfterFinished, int32((7*24*time.Hour)/time.Second); got != want {
t.Errorf("TTLSecondsAfterFinished = %d, want %d (buildJobTTL)", got, want)
}
}
// No build container may be privileged or able to escalate, and all containers // No build container may be privileged or able to escalate, and all containers
// must carry resource limits. // must carry resource limits.
func TestBuildJobContainersAreHardened(t *testing.T) { func TestBuildJobContainersAreHardened(t *testing.T) {