Skip to content
Discovery

Discovery

DiscoveryPolicy discovers images from external sources. CachedImageSet consumes the discovered list and materializes CachedImage resources.

Why This Exists

Discovery came from operational pain:

  • CI bursts created pull storms where many nodes pulled the same large images at once
  • Registry rate limits and transient outages amplified cold-start latency
  • Hand-maintained image lists became stale and missed newly hot images
  • Node rotation (e.g. Cluster API MachineDeployments rolling new nodes daily or weekly) means fresh nodes start with empty image caches — every rotation triggers a full re-pull of all active images

DiscoveryPolicy continuously refreshes image candidates from usage signals and passes the ranked output to CachedImageSet.

How Discovery Works

DiscoveryPolicy pipeline: queries feed signals, signals feed a single ranking strategy, the ranked list is written to status.discoveredImages and consumed by CachedImageSet to create CachedImage resources that nodes pull.

Queries feed signals, signals feed a single ranking strategy, and the ranked list is written to status.discoveredImages — consumed by CachedImageSet to create CachedImage resources that nodes pull.

StagePurposeAvailable types
QueriesFetch raw observations from a backendprometheus · loki · registry
SignalsReduce a query series to one value per imageaggregate · timeWeightedAggregate · windowAggregate · eventPullTime
RankingOrder images into the final listsignal · weightedSum · modelExposure

The output lands in status.discoveredImages; CachedImageSet reads it and creates/deletes CachedImage children that nodes pull.

Stage 1 — Queries

A query fetches raw observations and is referenced by name from signals.

All snippets below are complete DiscoveryPolicy resources with minimal companion signals/ranking so you can apply them directly.

TypeSourceDiscovered fromUse when
prometheusMetrics seriesimage label on resultsUsage/concurrency from cluster metrics
lokiEvent logsparsed pull eventsPull durations & image sizes
registryTag/catalog APIrepository tagsPre-cache newest tags by name

Prometheus Query

Definition. Runs a PromQL query against any Prometheus-compatible API and turns each returned series into a candidate image. The result must have an image label — that value becomes the image reference.

How it’s used in the CRD

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: prometheus-query-example
spec:
  syncInterval: 1h            # how often the whole pipeline re-runs
  maxImages: 30               # keep only the top 30 ranked images
  # STAGE 1: fetch raw data
  queries:
    - name: runner-image-usage   # unique id; referenced by signals[].query
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com   # any Prometheus-compatible API
        queryType: range        # range = samples over time | instant = single point
        lookback: 168h          # look back 7 days (range queries only)
        step: 1m                # smaller step = more samples + more backend load
        query: |
          # Result must expose an image label — Discovery keys every image by it.
          count(
            container_memory_working_set_bytes{
              container!="", container!="POD",
              namespace="gitlab-runner", pod=~"runner-.*"
            }
          ) by (image)
  # STAGE 2: reduce the series to one number per image
  signals:
    - name: total-usage         # signal name, referenced by ranking below
      query: runner-image-usage  # which query's data to consume
      type: aggregate
      aggregate:
        method: sum             # sum all samples = total activity per image
  # STAGE 3: order the images
  ranking:
    strategy: signal
    signal: total-usage         # sort purely by the total-usage signal

What happens to our query

... by (image) makes Prometheus return one time series per image. A range query samples each series across lookback, one point every step. Discovery reads the raw response:

{
  "data": { "result": [
    { "metric": { "image": "img-A" }, "values": [[t0, "1"], [t1, "2"], [t2, "6"]] },
    { "metric": { "image": "img-B" }, "values": [[t1, "1"], [t2, "3"]] }
  ]}
}

We use this 48h sample (hourly, two days, midday peaks) as the running example for every Prometheus signal below. The total-usage signal sums each series into one value:

Grafana-style time-series panel over 48 hours: img-A peaks midday both days, img-B smaller; x-axis is hour of day, each series summed to one value.

SeriesPatternsumrank
img-Amidday peaks, low at night301
img-Bsmall midday bumps122
FieldControlsDefault
queryTyperange = window of samples · instant = one point nowrange
lookbackhow far back the window reaches (ignored for instant)
stepspacing between samples; smaller = more points, heavier query5m

Field semantics: DiscoveryPrometheusQuery.

Loki Query

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: loki-query-example
spec:
  syncInterval: 1h
  maxImages: 30
  queries:
    - name: image-pull-events    # referenced by eventPullTime signal
      type: loki
      loki:
        endpoint: https://loki.example.com
        queryType: range         # only supported Loki query mode currently
        lookback: 168h
        query: |
          # Successful pulls carry pull duration and image size in the message.
          {job="kubernetes-events", namespace="gitlab-runner"}
          | json
          | involvedObject_name =~ "runner-.*"
          | reason = "Pulled"
        parser:
          type: kubernetesEvents # maps log fields into structured event records
          podField: involvedObject_name  # which field holds the pod name
          reasonField: reason            # only Pulled events are consumed
          messageField: message          # free-text event message
          imageField: message            # image ref is extracted from the message
  signals:
    - name: avg-cold-pull-time
      query: image-pull-events
      type: eventPullTime
      eventPullTime:
        metric: pullTime       # default; aggregates pull duration samples
        statistic: avg          # mean pull duration per image
  ranking:
    strategy: signal
    signal: avg-cold-pull-time   # slowest images rank highest

How it’s used: Loki contributes pull lifecycle data, not usage volume. The kubernetesEvents parser turns each Pulled event into a structured record with podField, reasonField, and messageField, then extracts the image from imageField (typically the same message text).

Alloy shipping (real cluster events):

  • Use loki.source.kubernetes_events forwarding to loki.write.
  • With log_format: json, Alloy emits keys like name, reason, msg in the log body. Default labels are namespace, job, instance.
  • Parser mapping for Alloy JSON should be podField: name, reasonField: reason, messageField: msg, imageField: msg.
  • Raw event-exporter JSON usually uses involvedObject_name + message.

What happens to our query

Loki returns streams, each with [timestamp, line] entries. With Alloy log_format: json, each line is a JSON event:

{
  "stream": {"job": "kubelet", "namespace": "default"},
  "values": [
    ["1719400000000000000", "{\"reason\":\"Pulling\",\"name\":\"runner-1\",\"msg\":\"Pulling image \\\"docker.io/library/redis:7-alpine\\\"\"}"],
    ["1719400002000000000", "{\"reason\":\"Pulled\",\"name\":\"runner-1\",\"msg\":\"Successfully pulled image \\\"docker.io/library/redis:7-alpine\\\" in 704ms\"}"]
  ]
}

The parser extracts image + size from each Pulled entry, then builds per-image samples:

Parsed eventOutput keyValue added
Pulled ... in 704msdocker.io/library/redis:7-alpine0.704 seconds
Pulled ... Image size: N bytesdocker.io/library/redis:7-alpine:size_bytesN

For eventPullTime signals, these samples are reduced by statistic (avg/p50/p95/etc.) into one value per image.

Registry Query

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: registry-query-example
spec:
  syncInterval: 1h
  maxImages: 30
  queries:
    - name: registry-tags
      type: registry
      registry:
        url: https://registry.gitlab.com
        repositories:           # repos to enumerate tags from
          - gitlab-org/gitlab-runner/gitlab-runner-helper
        tagFilter: "^x86_64-v[0-9]+\\."  # only x86_64-v1. / x86_64-v2. ...
        versionPattern: "x86_64-v(.+)"  # capture group 1 is the version
        tagSeek: "x86_64-u~"    # skip straight to the x86_64-v* tags
        maxScan: 2000           # cap tags fetched per repo before filtering
        topX: 3                 # keep the 3 newest matching tags per repo
        imageTemplate: "{{.Registry}}/{{.Repository}}:{{.Tag}}"  # built image ref
      secretRef:
        name: registry-api-creds   # registry auth Secret in the operator namespace

No signals or ranking are needed: registry queries already return their tags newest-first, so the discovered images come out pre-ranked.

How it’s used: registry discovery lists tags per repository via /v2/<repo>/tags/list, applies tagFilter, sorts newest-first, keeps topX, then renders full image references via imageTemplate.

Important behavior notes:

  • tagFilter is regex on tag names. Anchor explicitly (^...$) when needed.
  • Tags are sorted by version descending (newest first). Strict semver tags work out of the box; prefixed/suffixed tags (e.g. GitLab runner helper x86_64-v17.5.0) are handled by extracting an embedded semver substring. Tags with no parseable version fall back to registry push order. topX then keeps the newest N.
  • versionPattern (optional) is a regex with one capture group that pins where the version lives in the tag, e.g. x86_64-v(.+) for GitLab helper images. Use it when the default extraction picks the wrong number.
  • tagSeek (optional) is a pagination cursor sent to the registry as the last query parameter. The registry lists tags lexically after this value, so you can skip large numbers of irrelevant earlier tags (e.g. tens of thousands of digest tags) without fetching them. It is not a real tag name — any string works, e.g. x86_64-u~ jumps straight to the x86_64-v* tags.
  • maxScan (optional) caps how many tags are fetched per repository before filtering. Defaults to 1000. Pair it with tagSeek to fetch only the relevant range on registries with very large tag lists.
  • imageTemplate variables: {{.Registry}}, {{.Repository}}, {{.Tag}}. Default: {{.Registry}}/{{.Repository}}:{{.Tag}}.

Signal fit:

  • Registry queries are self-ranking; signals/ranking are optional and ignored for ordering. Aggregation signals are a no-op (one sample per tag).
  • Not compatible with timeWeightedAggregate/windowAggregate/eventPullTime (tag snapshots are not time series).

What happens to our query

For each repository, the controller calls /v2/<repo>/tags/list, then applies tagFilter, topX, and imageTemplate.

Example registry payload:

{"name":"gitlab-org/gitlab-runner/gitlab-runner-helper","tags":["x86_64-v17.3.0","x86_64-v17.4.0","x86_64-latest","x86_64-v17.5.0","x86_64-v17.10.0"]}

With tagFilter: "^x86_64-v[0-9]+\\.", versionPattern: "x86_64-v(.+)", and topX: 3, the newest kept tags are:

RepositoryMatching tagsKept (topX=3)Rendered images
gitlab-org/gitlab-runner/gitlab-runner-helperx86_64-v17.3.0, x86_64-v17.4.0, x86_64-v17.5.0, x86_64-v17.10.0x86_64-v17.10.0, x86_64-v17.5.0, x86_64-v17.4.0registry.gitlab.com/gitlab-org/gitlab-runner/gitlab-runner-helper:x86_64-v17.10.0:x86_64-v17.4.0

Note x86_64-v17.10.0 correctly ranks above x86_64-v17.5.0 (version-aware, not lexical), and the non-versioned x86_64-latest tag is excluded by tagFilter. Images come out newest-first, so no ranking is required.

Auth / TLS

Both query types support a secretRef for authentication and TLS:

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: query-auth-example
spec:
  syncInterval: 1h
  maxImages: 30
  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        query: ...
      secretRef:
        name: prometheus-creds  # Secret in the operator namespace (typically drop-system)
  signals:
    - name: total-usage
      query: runner-image-usage
      type: aggregate
      aggregate:
        method: sum
  ranking:
    strategy: signal
    signal: total-usage

Supported Secret keys: token, username, password, ca.crt, tls.crt, tls.key, headers.<name>.

Stage 2 — Signals

A signal derives a named per-image value from exactly one query. The four types reduce the same panel differently:

TypeReduces toKey knobs
aggregateOne value over all samplesmethod: sum/max/avg/count/min
timeWeightedAggregateWeighted sum by hour-of-daywindows, weight, timezone
windowAggregateOne sub-window onlyrelativeWindow or window start/end
eventPullTimeEvent metric statisticmetric: pullTime/imageSize, statistic: p50/p90/p95/avg/max/count

Signal × source compatibility:

Signal typePrometheusLokiRegistry
aggregateyesyesno-op
timeWeightedAggregateyesyesno
windowAggregateyesyesno
eventPullTimenoyes (kubernetesEvents)no

Registry queries return tag snapshots, not time series, so time-windowed signals are intentionally rejected. They are already self-ranked newest-first, so aggregate adds nothing and signals/ranking can be omitted entirely.

All Prometheus examples below run on this 48h dataset (sampled every 6h, both days identical):

Series00061218sum/day48h total
img-A23641530
img-B0132612

The graphics use 6h buckets (dots mark each sample) to fit the page; real queries sample every step (e.g. 1m). The shapes and totals match the math, not the true resolution.

aggregate

Aggregates all samples per image using a single method. The method you pick changes what “wins” — same data, different score:

sum adds every sample in the lookback window into one value per image.

On the shared dataset, sum makes total volume win regardless of when it happened: img-A → 30, img-B → 12.

methodReduces toimg-Aimg-BBest for
sumTotal of all samples3012total activity / volume
maxLargest single sample63peak concurrency / bursts
avgMean across samples3.81.5typical load
minSmallest single sample20always-on baseline
countNumber of samples88how often it was seen
apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: aggregate-signal-example
spec:
  syncInterval: 1h
  maxImages: 30
  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        queryType: range
        lookback: 168h
        step: 1m
        query: count(container_memory_working_set_bytes{container!="",container!="POD"}) by (image)
  signals:
    - name: total-usage
      query: runner-image-usage
      type: aggregate
      aggregate:
        method: sum    # sum | max | avg | count | min (sum = total activity)

    - name: peak-concurrency
      query: runner-image-usage
      type: aggregate
      aggregate:
        method: max             # captures burst behavior
  ranking:
    strategy: signal
    signal: total-usage

timeWeightedAggregate

Multiplies each sample value by a per-hour window weight before aggregation.

timeWeightedAggregate scales each time band by its weight (e.g. core hours ×1.0, off-hours ×0.3) then sums.

On the shared dataset: midday bars (×1.0) keep full value, shoulder bars (×0.3) shrink, off-hours (×0) vanish. img-A keeps most of its 30 because its peaks land in core hours; img-B fades further. Business-hour usage outranks 24h volume.

WindowHoursweightimg-A keepsimg-B keeps
warm-up07–090.3shoulder bars ×0.3shoulder bars ×0.3
core09–171.0midday peak fullmidday peak full
taper17–200.3evening ×0.3evening ×0.3
offelse0 (defaultWeight)droppeddropped
total≈ 21≈ 8

method accepts sum/count/avg/max/min, but sum is the only one that meaningfully uses the weights.

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: time-weighted-signal-example
spec:
  syncInterval: 1h
  maxImages: 30
  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        queryType: range
        lookback: 168h
        step: 1m
        query: count(container_memory_working_set_bytes{container!="",container!="POD"}) by (image)
  signals:
    - name: developer-weighted-usage
      query: runner-image-usage
      type: timeWeightedAggregate
      timeWeightedAggregate:
        method: sum
        timezone: Europe/Berlin # evaluate windows in local business time
        defaultWeight: "0"     # hours not listed below contribute nothing
        windows:                # weight = how much each hour-of-day counts
          - startHour: 7
            endHour: 9
            weight: "0.3"     # warm-up window = 0.3×
          - startHour: 9
            endHour: 17
            weight: "1.0"     # core hours = full weight
          - startHour: 17
            endHour: 20
            weight: "0.3"     # taper period = 0.3×
  ranking:
    strategy: signal
    signal: developer-weighted-usage

windowAggregate

Aggregates only the samples within a specific time sub-window. There are two ways to pick the window, and only one may be set per signal:

windowAggregate keeps only samples inside one sub-window (e.g. 09:00–17:00) and sums them.

On the shared dataset: only the shaded 09:00–17:00 band counts; bars outside it are dropped before summing. img-A ≈ 6 (its 12:00 peak), img-B ≈ 3. Everything outside the window is invisible — sharper than weighting.

SettingWindowimg-Aimg-BUse when
relativeWindow: 2hlast 2h from now42“what is hot right now”
window 00:00–09:00off-hours51overnight / batch jobs
window 09:00–17:00core hours63protect active workday

method accepts sum/count/avg/max/min (default sum). Set either relativeWindow or window+timezone — never both.

  • relativeWindow — “the last N hours from now”, measured in UTC. No timezone needed.
  • window — fixed clock hours of the day (e.g. 09:00–17:00). You must also set timezone; those hours are read in that zone. The policy errors if it is missing.
apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: window-aggregate-signal-example
spec:
  syncInterval: 1h
  maxImages: 30
  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        queryType: range
        lookback: 168h
        step: 1m
        query: count(container_memory_working_set_bytes{container!="",container!="POD"}) by (image)
  signals:
    # Relative window: just the last 2 hours of samples (clock zone irrelevant)
    - name: recent-usage
      query: runner-image-usage
      type: windowAggregate
      windowAggregate:
        method: sum
        relativeWindow: 2h      # good for "what is hot right now"

    # Wall-clock window: 00:00–09:00 every day, read in the timezone below
    - name: pre-window-usage
      query: runner-image-usage
      type: windowAggregate
      windowAggregate:
        method: sum
        timezone: Europe/Berlin  # REQUIRED with window; start/end are Berlin local time
        window:
          start: "00:00"       # inclusive
          end: "09:00"         # exclusive

    # Wall-clock window: 09:00–17:00 Berlin (the active period to protect)
    - name: target-window-usage
      query: runner-image-usage
      type: windowAggregate
      windowAggregate:
        method: sum
        timezone: Europe/Berlin  # REQUIRED with window
        window:
          start: "09:00"
          end: "17:00"
  ranking:
    strategy: signal
    signal: recent-usage

eventPullTime

Derives image pull-time statistics from Loki event records. The kubelet emits a Pulled event for every image pull, carrying the pull duration. Drop collects all Pulled events for each image within the lookback window and treats them as the sample set.

Gantt chart of observed pull events. Each bar is one Pulled event; bar width = pull duration. redis:7 has one slower outlier at 30 s (slow link on that node); nginx:1.25 is consistently 14–20 s.

The statistic field reduces these samples to one ranking value per image. Slower images rank higher:

How to read the statistic charts:

  • each row is one image (img-A and img-B)
  • the box spans the interquartile range (p25 to p75)
  • whiskers show the spread of observed pulls
  • small dots are individual pull events
  • the highlighted marker is the selected statistic (vertical tick for p50/p90/p95/avg, ring for max, n= label for count)
p50 boxplot view: vertical tick marks median pull duration per image (img-A 17 s, img-B 23 s). The box and whiskers show spread; dots show each pull event.

Pick p50 as the default: it ranks by typical pull latency and is robust to a single slow outlier. Use p90/p95 when SLO tail latency matters; max for strict worst-case provisioning.

statisticReduces tonginx (8 events)redis (6 events)Best for
p50median17 s23 stypical latency, robust to outliers
p90slow tail19 s28 sworst-case planning
p95slower tail20 s29 sstrict SLOs
avgmean17 s24 soverall cost (skewed by outliers)
maxslowest pull20 s30 sabsolute worst pull
countpull events86how often pulled cold

eventPullTime uses metric + statistic, both derived from Pulled events:

  • metric: pullTime (default) with statistic: p50|p90|p95|avg|max|count
  • metric: imageSize with statistic: p50|p90|p95|avg|max|count (bytes from Image size: N bytes)
apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: event-pull-time-signal-example
spec:
  syncInterval: 1h
  maxImages: 30
  queries:
    - name: image-pull-events
      type: loki
      loki:
        endpoint: https://loki.example.com
        queryType: range
        lookback: 168h
        query: |
          {job="kubernetes-events", namespace="gitlab-runner"}
          | json
          | reason = "Pulled"
        parser:
          type: kubernetesEvents
          podField: involvedObject_name
          reasonField: reason
          messageField: message
          imageField: message
  signals:
    - name: avg-cold-pull-time
      query: image-pull-events
      type: eventPullTime
      eventPullTime:
        metric: pullTime          # pullTime (default) | imageSize
        statistic: avg            # p50 | p90 | p95 | avg | max | count
  ranking:
    strategy: signal
    signal: avg-cold-pull-time

Rank by image size (bytes) from the same Pulled events:

signals:
  - name: avg-image-size
    query: image-pull-events
    type: eventPullTime
    eventPullTime:
      metric: imageSize
      statistic: avg

ranking:
  strategy: signal
  signal: avg-image-size

Stage 3 — Ranking

Exactly one ranking strategy per policy.

Input to Stage 3 is always:

  1. Candidate image set from Stage 1 queries (collectImages from query results).
  2. Per-image signal values from Stage 2 (signalValues[signalName][image]).
  3. Ranking config from spec.ranking.

Ranking does not fetch new data sources by itself. It only combines values already produced by Stages 1 and 2.

Decision map for ranking strategy selection: use signal for one dominant metric, weightedSum for balancing known trade-offs, and modelExposure for minimizing cold-node impact in rotating clusters.

Ranking data flow by strategy: all strategies start from Stage 1 candidate images and Stage 2 signal maps; signal uses one signal key, weightedSum combines multiple normalized signal keys, modelExposure combines target/pre usage plus a pull-time signal and node count.

Where Ranking Data Comes From

StrategyReads from Stage 1Reads from Stage 2Extra config input
signalCandidate image listOne signal map selected by ranking.signalnone
weightedSumCandidate image listMultiple signal maps listed in ranking.weightedSum.signals[].namePer-signal weights
modelExposureCandidate image listThree signal maps referenced by targetSignal, preSignal, pullTimeSignalNode count (nodes)

If a referenced signal name is missing, the image score falls back to 0 for that term (or to default/fallback ordering when no ranking config is usable).

signal

Ranks images directly by the value of a single signal.

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: signal-ranking-example
spec:
  syncInterval: 1h
  maxImages: 30
  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        queryType: range
        lookback: 168h
        step: 1m
        query: count(container_memory_working_set_bytes{container!="",container!="POD"}) by (image)
  signals:
    - name: total-usage
      query: runner-image-usage
      type: aggregate
      aggregate:
        method: sum
  ranking:
    strategy: signal
    signal: total-usage    # simplest strategy: sort by one signal

weightedSum

Use this when you have multiple useful signals and want one final rank.

How it works:

  1. Compute each signal per image.
  2. Normalize each signal to 0..1 (so different units can be compared).
  3. Multiply each normalized signal by its weight.
  4. Add the weighted terms; higher final score ranks first.

In plain terms: weightedSum answers “how good is this image across all criteria, given my priorities?”

final_score(I)=kwknormalize(sk(I)),minMax(x)=xxminxmaxxmin \mathrm{final\_score}(I) = \sum_k w_k \cdot \mathrm{normalize}(s_k(I)), \qquad \mathrm{minMax}(x) = \frac{x - x_{\min}}{x_{\max} - x_{\min}}

Quick example (two signals):

  • normalized total-usage(img-A)=0.90, peak-concurrency(img-A)=0.40
  • weights: 0.7 and 0.3
  • score: 0.7*0.90 + 0.3*0.40 = 0.75

So img-A gets score 0.75; images with larger scores rank above it.

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: weighted-sum-ranking-example
spec:
  syncInterval: 1h
  maxImages: 30
  # STAGE 1: fetch raw data
  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        queryType: range
        lookback: 168h
        step: 1m
        query: count(container_memory_working_set_bytes{container!="",container!="POD"}) by (image)
  # STAGE 2: two signals to balance
  signals:
    - name: total-usage          # sustained activity
      query: runner-image-usage
      type: aggregate
      aggregate:
        method: sum
    - name: peak-concurrency     # burst behavior
      query: runner-image-usage
      type: aggregate
      aggregate:
        method: max
  # STAGE 3: blend the two
  ranking:
    strategy: weightedSum
    weightedSum:
      normalize: minMax      # required for mixed units (counts, seconds, bytes)
      missingSignal: zero    # zero | drop ; drop excludes images missing any term
      terms:                 # weight = importance (best if terms sum near 1.0)
        - signal: total-usage
          weight: "0.7"      # 70% importance
        - signal: peak-concurrency
          weight: "0.3"      # 30% importance

Field semantics: WeightedSumRankingConfig.

modelExposure

Use this when node rotation matters and you want to protect a specific future window (for example, business hours) from cold-pull latency.

What it models:

  1. targetWindowUsageSignal: how much the image will be used in the window you care about.
  2. preWindowUsageSignal: how much warmup happened before that window.
  3. nodeCount: larger clusters keep cache spread longer.
  4. pullTimeSignal: slower cold pulls are more expensive and get higher priority.

In plain terms: images that are busy in the target window, not sufficiently warmed up before it, and expensive to pull cold will rank highest.

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: model-exposure-ranking-example
spec:
  syncInterval: 1h
  maxImages: 30
  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        queryType: range
        lookback: 168h
        step: 1m
        query: count(container_memory_working_set_bytes{container!="",container!="POD"}) by (image)
    - name: image-pull-events
      type: loki
      loki:
        endpoint: https://loki.example.com
        queryType: range
        lookback: 168h
        query: |
          {job="kubernetes-events", namespace="gitlab-runner"}
          | json
          | reason = "Pulled"
        parser:
          type: kubernetesEvents
          podField: involvedObject_name
          reasonField: reason
          messageField: message
          imageField: message
  signals:
    - name: pre-window-usage
      query: runner-image-usage
      type: windowAggregate
      windowAggregate:
        method: sum
        timezone: Europe/Berlin
        window:
          start: "00:00"
          end: "09:00"
    - name: target-window-usage
      query: runner-image-usage
      type: windowAggregate
      windowAggregate:
        method: sum
        timezone: Europe/Berlin
        window:
          start: "09:00"
          end: "17:00"
    - name: avg-cold-pull-time
      query: image-pull-events
      type: eventPullTime
      eventPullTime:
        metric: pullTime
        statistic: avg
  ranking:
    strategy: modelExposure
    modelExposure:
      nodes:
        count: 100                           # N: total nodes in the cluster
      preWindowUsageSignal: pre-window-usage  # J_pre: warmup before target window
      targetWindowUsageSignal: target-window-usage # J_target: demand during target window
      pullTimeSignal: avg-cold-pull-time      # p-hat: cold-pull penalty (slower = larger)

Score formula:

score(I)=Jtarget(I)(11N)Jpre(I)p^(I) \mathrm{score}(I) = J_{\mathrm{target}}(I) \cdot \left(1 - \frac{1}{N}\right)^{J_{\mathrm{pre}}(I)} \cdot \hat{p}(I)

Interpretation of the formula:

  • first factor boosts images heavily used in the target window
  • middle factor discounts images already warmed before the window
  • last factor boosts images with expensive cold pulls

Setting N (node count): the nodes block takes a static count, a dynamic selector, or both.

  • nodes.count: a fixed integer. Best for static clusters.
  • nodes.selector: a standard Kubernetes node selector (the same shape used by node affinity’s nodeSelectorTerms). The operator counts Ready nodes that match it via the Kubernetes API at every sync, so N tracks autoscaling and node rotation automatically. Terms are ORed; within a term, matchExpressions (node labels) and matchFields (e.g. metadata.name) are ANDed. A nil selector counts all Ready nodes. When both are set, the selector wins and count is the fallback used only if node discovery fails (otherwise N=1).

Selection is by labels and fields — it does not evaluate taints (taints are handled via tolerations, not selection). Common selector labels: node-role.kubernetes.io/control-plane (exclude masters), node.kubernetes.io/instance-type, topology.kubernetes.io/zone, or pool labels like karpenter.sh/nodepool and cloud.google.com/gke-nodepool.

Dynamic node count example (replace the nodes: block above):

  ranking:
    strategy: modelExposure
    modelExposure:
      nodes:
        selector:                            # N = live count of matching Ready nodes
          nodeSelectorTerms:
            - matchExpressions:
                - key: node-role.kubernetes.io/control-plane
                  operator: DoesNotExist     # workers only (control-plane is NoSchedule)
        count: 50                            # fallback if node discovery fails (optional)
      preWindowUsageSignal: pre-window-usage
      targetWindowUsageSignal: target-window-usage
      pullTimeSignal: avg-cold-pull-time

Field semantics: ModelExposureRankingConfig.

Complete Examples

Example 1: Total Usage (simplest)

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: total-usage
spec:
  syncInterval: 1h   # rerun pipeline every hour
  maxImages: 30      # keep top 30 ranked images

  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        queryType: range
        lookback: 168h
        step: 1m
        query: |
          count(
            container_memory_working_set_bytes{
              container!="", container!="POD",
              namespace="gitlab-runner", pod=~"runner-.*"
            }
          ) by (image)

  signals:
    - name: total-usage
      query: runner-image-usage
      type: aggregate
      aggregate:
        method: sum  # total usage in lookback window

  ranking:
    strategy: signal
    signal: total-usage

Example 2: Hybrid Usage + Peak Concurrency

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: gitlab-hybrid-usage-concurrency
spec:
  syncInterval: 1h
  maxImages: 30

  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        queryType: range
        lookback: 168h
        step: 1m
        query: |
          count(
            container_memory_working_set_bytes{
              container!="", container!="POD",
              namespace="gitlab-runner", pod=~"runner-.*"
            }
          ) by (image)

  signals:
    - name: total-usage
      query: runner-image-usage
      type: aggregate
      aggregate:
        method: sum

    - name: peak-concurrency
      query: runner-image-usage
      type: aggregate
      aggregate:
        method: max

  ranking:
    strategy: weightedSum
    weightedSum:
      normalize: minMax
      missingSignal: zero
      terms:
        - signal: total-usage
          weight: "0.7" # prioritize sustained usage
        - signal: peak-concurrency
          weight: "0.3" # still account for bursts

Example 3: Developer-Time Weighted Usage

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: gitlab-developer-and-burst
spec:
  syncInterval: 1h
  maxImages: 30

  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        queryType: range
        lookback: 168h
        step: 1m
        query: |
          count(
            container_memory_working_set_bytes{
              container!="", container!="POD",
              namespace="gitlab-runner", pod=~"runner-.*"
            }
          ) by (image)

  signals:
    - name: developer-weighted-usage
      query: runner-image-usage
      type: timeWeightedAggregate
      timeWeightedAggregate:
        method: sum
        timezone: Europe/Berlin
        defaultWeight: "0"   # off-hours ignored by default
        windows:
          - startHour: 7
            endHour: 9
            weight: "0.3"
          - startHour: 9
            endHour: 17
            weight: "1.0"
          - startHour: 17
            endHour: 20
            weight: "0.3"

    - name: peak-concurrency
      query: runner-image-usage
      type: aggregate
      aggregate:
        method: max

  ranking:
    strategy: weightedSum
    weightedSum:
      normalize: minMax
      missingSignal: zero
      terms:
        - signal: developer-weighted-usage
          weight: "0.7"
        - signal: peak-concurrency
          weight: "0.3"

Example 4: Model-Aware Exposure

apiVersion: drop.corewire.io/v1alpha1
kind: DiscoveryPolicy
metadata:
  name: gitlab-model-exposure
spec:
  syncInterval: 1h
  maxImages: 30

  queries:
    - name: runner-image-usage
      type: prometheus
      prometheus:
        endpoint: https://mimir.example.com
        queryType: range
        lookback: 168h
        step: 1m
        query: |
          count(
            container_memory_working_set_bytes{
              container!="", container!="POD",
              namespace="gitlab-runner", pod=~"runner-.*"
            }
          ) by (image)

    - name: image-pull-events
      type: loki
      loki:
        endpoint: https://loki.example.com
        queryType: range
        lookback: 168h
        query: |
          {job="kubernetes-events", namespace="gitlab-runner"}
          | json
          | involvedObject_name =~ "runner-.*"
          | reason = "Pulled"
        parser:
          type: kubernetesEvents
          podField: involvedObject_name
          reasonField: reason
          messageField: message
          imageField: message

  signals:
    - name: pre-window-usage
      query: runner-image-usage
      type: windowAggregate
      windowAggregate:
        method: sum
        timezone: Europe/Berlin   # window hours below are Berlin local time
        window:
          start: "00:00" # prior window
          end: "09:00"

    - name: target-window-usage
      query: runner-image-usage
      type: windowAggregate
      windowAggregate:
        method: sum
        timezone: Europe/Berlin   # window hours below are Berlin local time
        window:
          start: "09:00" # target active window
          end: "17:00"

    - name: avg-cold-pull-time
      query: image-pull-events
      type: eventPullTime
      eventPullTime:
        metric: pullTime
        statistic: avg          # mean latency signal; use p95 if you need tail sensitivity

  ranking:
    strategy: modelExposure
    modelExposure:
      nodes:
        count: 100             # tune to your typical active node count
      preWindowUsageSignal: pre-window-usage
      targetWindowUsageSignal: target-window-usage
      pullTimeSignal: avg-cold-pull-time

Status and Observability

Status records query execution outcomes and the final ordered image list used by CachedImageSet.

status:
  lastSyncTime: "2026-06-18T10:00:00Z"
  imageCount: 2

  conditions:
    - type: Ready
      status: "True"
      reason: Synced
      message: "Discovered 2 images."

  queryResults:
    - name: runner-image-usage
      type: prometheus
      status: success         # success | failed (message set on failure)

  discoveredImages:
    - image: registry.example.com/ci/java-gradle:21
      rank: 1
      finalScore: "0.8768"
    - image: registry.example.com/ci/node:20
      rank: 2
      finalScore: "0.5210"
FieldMeaning
conditions[Ready]reason=Synced once the pipeline runs successfully; message summarizes the result
imageCountNumber of discovered images (also a print column)
queryResults[]Per-query name · type · status · message (on failure)
discoveredImages[]Ordered result: image · rank (1 = highest) · finalScore

Discovery Strategies Reference

#StrategyScore formulaSignals needed
1Total usageΣ count_I(t) over Wtotal-usage
2Peak same-image concurrencymax count_I(t) over Wpeak-concurrency
3Developer-time weighted usageΣ weight(t)·count_I(t)developer-weighted-usage
4Recent usageΣ count_I(t) over recent windowrecent-usage
5Hybrid usage + peakα·norm(total) + (1-α)·norm(peak)total-usage, peak-concurrency
6Hybrid dev-time + peakα·norm(dev) + (1-α)·norm(peak)developer-weighted-usage, peak-concurrency
7Count × pull timetotal_usage(I) · p_hat(I)total-usage, avg-cold-pull-time
8Model-aware exposureJ_target · (1-1/N)^J_pre · p_hatpre-window-usage, target-window-usage, avg-cold-pull-time

Error Handling

  • On transient failures, the operator keeps the last known good discovery results
  • Source health is tracked via conditions on the DiscoveryPolicy status
  • Each query is executed independently — one failing query does not block others
Last updated on