Skip to content

Commit ca8556c

Browse files
authored
fix(ci): pull the KWOK images once per run instead of once per job (#2497)
Signed-off-by: Mark Chmarny <mark@chmarny.com>
1 parent d76b43d commit ca8556c

6 files changed

Lines changed: 1083 additions & 64 deletions

File tree

.github/actions/kwok-test/action.yml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,70 @@ runs:
167167
make build
168168
ls -la dist/
169169
170+
# ── Image cache: load the public images this lane needs from the tarball
171+
# the prime-images job saved, instead of pulling them here (#2483).
172+
#
173+
# preload_image checks the host Docker cache before every pull attempt, so
174+
# an image loaded here means the lane never contacts the registry at all —
175+
# which is the point: 127 concurrent jobs pulling the same image is what
176+
# trips the per-IP throttle.
177+
#
178+
# BEST EFFORT, unlike the prime job that fills the cache. Every step below
179+
# tolerates a miss (fail-on-cache-miss defaults to false, and the load step
180+
# swallows a non-zero), because the fallback is intact: preload_image pulls,
181+
# and the kubelet pulls behind that. A fork PR whose cache scope differs, or
182+
# a first run after a pin bump, must degrade to today's behavior rather than
183+
# fail. The gate lives in prime-images, not here.
184+
#
185+
# Placed after setup-build-tools because resolving the pins needs yq.
186+
- name: Resolve image pins and cache keys
187+
id: images
188+
shell: bash
189+
run: |
190+
set -euo pipefail
191+
bash kwok/scripts/lib/image-cache.sh settings .settings.yaml | tee -a "$GITHUB_OUTPUT"
192+
193+
- name: Restore registry image
194+
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
195+
with:
196+
path: /tmp/kwok-image-cache/registry
197+
key: ${{ steps.images.outputs.registry_key }}
198+
199+
# Gitea is installed only for the *-git deployers (see install-infra.sh), so
200+
# the other lanes skip a ~250MB restore they would never use.
201+
- name: Restore Gitea image
202+
if: endsWith(inputs.deployer, '-git')
203+
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
204+
with:
205+
path: /tmp/kwok-image-cache/gitea
206+
key: ${{ steps.images.outputs.gitea_key }}
207+
208+
- name: Load cached images
209+
shell: bash
210+
env:
211+
REGISTRY_IMAGE: ${{ steps.images.outputs.registry_image }}
212+
GITEA_IMAGE: ${{ steps.images.outputs.gitea_image }}
213+
DEPLOYER: ${{ inputs.deployer }}
214+
run: |
215+
set -uo pipefail
216+
217+
# A miss is not fatal — preload_image still pulls — but it does mean
218+
# this lane is back to contending for the registry, which is the whole
219+
# point of #2483. Annotate it: a key that drifts from the prime job's
220+
# would otherwise leave every lane pulling again, green, with the fix
221+
# silently inert and nothing to notice it by.
222+
load_image() { # <dir> <image>
223+
if bash kwok/scripts/lib/image-cache.sh load "$1" "$2"; then
224+
return 0
225+
fi
226+
echo "::warning::Image cache miss for $2 — this lane will pull it from the registry"
227+
}
228+
229+
load_image /tmp/kwok-image-cache/registry "${REGISTRY_IMAGE}"
230+
if [[ "${DEPLOYER}" == *-git ]]; then
231+
load_image /tmp/kwok-image-cache/gitea "${GITEA_IMAGE}"
232+
fi
233+
170234
- name: Run KWOK recipe test
171235
id: validate
172236
shell: bash

.github/workflows/kwok-recipes.yaml

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,13 @@ jobs:
7676
name: Discover Recipes
7777
if: github.event_name != 'schedule' || github.repository == 'nvidia/aicr'
7878
runs-on: ubuntu-latest
79-
timeout-minutes: 5
79+
# Raised from 5→10 when image-cache_test.sh joined the script unit tests
80+
# below. Those suites are deliberately wall-clock bound — they assert that
81+
# a hanging pull or an exhausted budget stays inside its deadline, which
82+
# costs real seconds — and the block now runs ~90s locally before this job
83+
# even starts classifying recipes. A discovery job that times out would be
84+
# exactly the kind of self-inflicted red this change exists to remove.
85+
timeout-minutes: 10
8086
outputs:
8187
tier1_pairs: ${{ steps.classify.outputs.tier1_pairs }}
8288
tier2_pairs: ${{ steps.classify.outputs.tier2_pairs }}
@@ -98,6 +104,7 @@ jobs:
98104
bash kwok/scripts/lib/sync-budget_test.sh
99105
bash kwok/scripts/lib/profile-select_test.sh
100106
bash kwok/scripts/lib/preload-image_test.sh
107+
bash kwok/scripts/lib/image-cache_test.sh
101108
bash kwok/scripts/run-all-recipes_test.sh
102109
103110
- name: Classify recipes into tiers
@@ -375,12 +382,75 @@ jobs:
375382
if (( dropped_count > 0 )); then
376383
echo "Filtered from matrix: ${dropped_count} recipe(s) with no matching KWOK profile — $(echo "$dropped" | jq -r 'join(", ")')"
377384
fi
385+
# ── Image cache priming: one pull per run, not one per matrix job (#2483) ──
386+
#
387+
# The KWOK lanes need two public images — an in-cluster OCI registry, and
388+
# Gitea for the *-git deployers. Every matrix job used to pull them itself.
389+
# In run 33350102144, 127 jobs pulled registry:3.1.1 at the same moment from
390+
# the shared GitHub-runner egress IPs; 125 succeeded and the one that was
391+
# shed reddened main. That is a per-IP throttle, not an outage, so retrying
392+
# harder cannot fix it — #2496 narrowed the window, it did not close it. The
393+
# fix is to stop making 127 requests.
394+
#
395+
# This job makes one, saves a tarball, and lets actions/cache carry it to the
396+
# matrix, which loads it from disk (see .github/actions/kwok-test). On a warm
397+
# cache not even this job pulls.
398+
#
399+
# HARD GATE by design, unlike the best-effort preload it feeds: if an image is
400+
# genuinely unreachable, failing one job in a minute beats 127 jobs each
401+
# burning a rollout timeout. The summary job fails with it — without that, a
402+
# failed prime would skip the tiers and the run would report green.
403+
#
404+
# One job per image (rather than one job for both) so a Gitea failure cannot
405+
# discard a registry pull that already succeeded: actions/cache saves at the
406+
# end of a job only when that job succeeds.
407+
prime-images:
408+
name: 'Prime Image Cache (${{ matrix.image }})'
409+
if: github.event_name != 'schedule' || github.repository == 'nvidia/aicr'
410+
runs-on: ubuntu-latest
411+
timeout-minutes: 15
412+
strategy:
413+
fail-fast: false
414+
matrix:
415+
# Must match IMAGE_CACHE_IMAGES in kwok/scripts/lib/image-cache.sh.
416+
image: [registry, gitea]
417+
steps:
418+
- name: Checkout
419+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
420+
with:
421+
persist-credentials: false
422+
423+
# .settings.yaml stays the single source of truth for the pins; the key
424+
# derivation is shared with the consuming side through image-cache.sh, so
425+
# the two cannot drift into never agreeing on a key.
426+
- name: Resolve image pins and cache keys
427+
id: images
428+
shell: bash
429+
run: |
430+
set -euo pipefail
431+
bash kwok/scripts/lib/image-cache.sh settings .settings.yaml | tee -a "$GITHUB_OUTPUT"
432+
433+
- name: Cache image tarball
434+
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
435+
with:
436+
path: /tmp/kwok-image-cache/${{ matrix.image }}
437+
key: ${{ steps.images.outputs[format('{0}_key', matrix.image)] }}
438+
439+
- name: Prime image
440+
shell: bash
441+
env:
442+
IMAGE: ${{ steps.images.outputs[format('{0}_image', matrix.image)] }}
443+
CACHE_DIR: /tmp/kwok-image-cache/${{ matrix.image }}
444+
run: |
445+
set -euo pipefail
446+
bash kwok/scripts/lib/image-cache.sh save "${CACHE_DIR}" "${IMAGE}"
447+
378448
# Thin caller of the shared kwok-test-run.yaml reusable workflow (#1172).
379449
# tier1_pairs is well under 256 entries, so it's passed in a single call —
380450
# no batching needed.
381451
test-tier1:
382452
name: 'Tier 1'
383-
needs: discover
453+
needs: [discover, prime-images]
384454
if: >-
385455
github.event_name != 'schedule' &&
386456
needs.discover.outputs.tier1_pairs != '[]' &&
@@ -393,7 +463,7 @@ jobs:
393463
# Helm-only by deliberate policy — see ADR-003 "Tier 2 deployer coverage".
394464
test-tier2:
395465
name: 'Tier 2'
396-
needs: discover
466+
needs: [discover, prime-images]
397467
if: >-
398468
github.event_name == 'pull_request' &&
399469
needs.discover.outputs.tier2_pairs != '[]' &&
@@ -411,7 +481,7 @@ jobs:
411481
# single run in its own group so they all run in parallel.
412482
test-tier3:
413483
name: 'Tier 3'
414-
needs: discover
484+
needs: [discover, prime-images]
415485
concurrency:
416486
group: kwok-tier3-${{ github.sha }}-${{ matrix.batch.id }}
417487
cancel-in-progress: false
@@ -430,7 +500,7 @@ jobs:
430500
# ── Summary: aggregate all tiers ──
431501
summary:
432502
name: KWOK Test Summary
433-
needs: [test-tier1, test-tier2, test-tier3]
503+
needs: [prime-images, test-tier1, test-tier2, test-tier3]
434504
runs-on: ubuntu-latest
435505
timeout-minutes: 5
436506
if: always()
@@ -440,19 +510,31 @@ jobs:
440510
echo "## KWOK Cluster Validation Summary" >> $GITHUB_STEP_SUMMARY
441511
echo "" >> $GITHUB_STEP_SUMMARY
442512
513+
prime="${{ needs.prime-images.result }}"
443514
tier1="${{ needs.test-tier1.result }}"
444515
tier2="${{ needs.test-tier2.result }}"
445516
tier3="${{ needs.test-tier3.result }}"
446517
447-
echo "| Tier | Result |" >> $GITHUB_STEP_SUMMARY
518+
echo "| Stage | Result |" >> $GITHUB_STEP_SUMMARY
448519
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
520+
echo "| Image cache priming | ${prime} |" >> $GITHUB_STEP_SUMMARY
449521
echo "| Tier 1 (generic) | ${tier1} |" >> $GITHUB_STEP_SUMMARY
450522
echo "| Tier 2 (diff-aware) | ${tier2} |" >> $GITHUB_STEP_SUMMARY
451523
echo "| Tier 3 (full matrix) | ${tier3} |" >> $GITHUB_STEP_SUMMARY
452524
echo "" >> $GITHUB_STEP_SUMMARY
453525
454526
failed=false
455527
528+
# Priming gates every tier, so its failure must be checked HERE and
529+
# not inferred from the tiers. A failed prime leaves them "skipped",
530+
# and the tier checks below deliberately tolerate "skipped" — so
531+
# without this the whole run would report green having validated
532+
# nothing.
533+
if [[ "$prime" == "failure" || "$prime" == "cancelled" ]]; then
534+
echo "Image cache priming failed or was cancelled — no tier ran" >> $GITHUB_STEP_SUMMARY
535+
failed=true
536+
fi
537+
456538
# Tier 1 must pass (unless skipped on schedule-only with no recipes)
457539
if [[ "$tier1" == "failure" || "$tier1" == "cancelled" ]]; then
458540
echo "Tier 1 (generic) failed or was cancelled" >> $GITHUB_STEP_SUMMARY

kwok/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,24 @@ Manual trigger:
231231
gh workflow run kwok-recipes.yaml -f recipe=your-recipe-name
232232
```
233233

234+
### Public image cache
235+
236+
The lanes need two images from public registries — the in-cluster OCI registry and, for the `*-git` deployers, Gitea. Both are pinned in `.settings.yaml` under `testing_tools`.
237+
238+
A full Tier 3 run fans out to well over a hundred concurrent jobs, and a job that pulls these itself competes with every sibling for the same per-IP quota at the registry: 127 jobs pulling at once reliably gets one or two shed, which reddens the run with nothing wrong in the repo (#2483). So CI pulls each image exactly once, in the `prime-images` job, and carries it to the matrix as a tarball in `actions/cache`. Each test job loads from that tarball, and `preload_image` then finds the image already in the host Docker cache and never contacts the registry.
239+
240+
`kwok/scripts/lib/image-cache.sh` owns both ends, so the priming job and the test jobs derive the cache key from the same code:
241+
242+
```bash
243+
bash kwok/scripts/lib/image-cache.sh settings .settings.yaml # resolve pins + keys
244+
bash kwok/scripts/lib/image-cache.sh save <dir> <image> # pull once, write the tarball
245+
bash kwok/scripts/lib/image-cache.sh load <dir> <image> # restore it into Docker
246+
```
247+
248+
Priming is a hard gate: if an image is genuinely unreachable, the run fails there rather than in every lane. Loading is best effort — a cache miss falls back to `preload_image`'s pull, and the kubelet pull behind that, so a cold cache degrades to the old behavior instead of failing.
249+
250+
Local runs (`make kwok-test-all`) do not use the cache; they pull through `preload_image` as before.
251+
234252
## Troubleshooting
235253

236254
**Pods stuck Pending** — check tolerations and node selectors:

0 commit comments

Comments
 (0)