Kubernetes CronJob vs native InfluxDB tasks

You run refrigeration monitoring for a retail chain: roughly 40,000 compressor and case-temperature sensors across 1,900 stores stream into a refrigeration-raw bucket, and every fifteen minutes something must roll that raw stream into a compliance-grade refrigeration-15m summary, flag any case that has drifted above its food-safety threshold, and page the duty engineer. The workload is trivially a scheduled job — but where it should run is not obvious. A native InfluxDB task written in Flux lives inside the database and needs no extra infrastructure; a Kubernetes CronJob wraps the same logic in a container you already know how to build, ship, and observe. Picking wrong costs you either an ungoverned pile of kubectl-only jobs or a Flux task that cannot reach the vendor food-safety API it needs. This page runs the same refrigeration workload both ways and gives you a decision rule grounded in concurrency semantics, missed-schedule behaviour, observability, and cost, extending the Kubernetes CronJob orchestration approach to a direct comparison.

Prerequisites

Solution walkthrough

1. Build the baseline as a native InfluxDB task

The native path is the shortest. A Flux task reads the last window of raw case temperatures, computes a 15-minute mean per store and case, and writes the summary back. It runs on the InfluxDB scheduler with no moving parts outside the database.

flux
option task = {name: "refrigeration-15m-rollup", every: 15m, offset: 90s}

from(bucket: "refrigeration-raw")
    |> range(start: -task.every)
    |> filter(fn: (r) => r._measurement == "case_temp" and r._field == "celsius")
    |> aggregateWindow(every: 15m, fn: mean, createEmpty: false)
    |> set(key: "tier", value: "15m")
    |> to(bucket: "refrigeration-15m", org: "retail-cold-chain")

The offset: 90s holds the window open long enough for late store-gateway batches to land before the task fires — the same lateness-versus-freshness trade-off covered in cron and interval scheduling logic. This task has zero deployment surface: it is a row in the database, it inherits InfluxDB’s own high availability, and it cannot fail because a container registry is down. What it cannot do is call the external food-safety API, sign a webhook payload with a rotated secret, or run a Python model — Flux has no general egress or third-party libraries.

2. Build the equivalent as a Kubernetes CronJob

The CronJob wraps the same rollup in a container, but now the job can also do the things Flux cannot: enrich each breach with store metadata from an internal service and post a signed alert to the food-safety platform.

yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: refrigeration-15m-rollup
  namespace: cold-chain
spec:
  schedule: "2,17,32,47 * * * *"     # 90s after each quarter-hour, matching the task offset
  timeZone: "Etc/UTC"
  concurrencyPolicy: Forbid          # never overlap runs
  startingDeadlineSeconds: 120       # skip, don't stampede, if a run is late
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 600
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: rollup
              image: registry.internal/cold-chain/rollup:1.8.3
              envFrom:
                - secretRef:
                    name: influx-rollup-token
              env:
                - name: WINDOW
                  value: "15m"

The container entrypoint runs the rollup and the enrichment step the native task could not:

python
import os
from datetime import datetime, timedelta, timezone
from influxdb_client import InfluxDBClient

WINDOW_MIN = 15
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
start = now - timedelta(minutes=WINDOW_MIN)

flux = f'''
from(bucket: "refrigeration-raw")
  |> range(start: {start.isoformat()}, stop: {now.isoformat()})
  |> filter(fn: (r) => r._measurement == "case_temp" and r._field == "celsius")
  |> aggregateWindow(every: {WINDOW_MIN}m, fn: mean, createEmpty: false)
  |> set(key: "tier", value: "15m")
  |> to(bucket: "refrigeration-15m", org: "retail-cold-chain")
'''

with InfluxDBClient(url=os.environ["INFLUX_URL"],
                    token=os.environ["INFLUX_TOKEN"],
                    org="retail-cold-chain") as client:
    client.query_api().query(flux)          # run the rollup
    breaches = detect_breaches(client, start, now)   # your threshold logic
    for b in breaches:
        post_signed_alert(b)                # external egress Flux cannot do

The container path costs you an image to build, a registry to keep up, a token mounted as a Secret, and a Kubernetes CronJob object to govern — but it buys arbitrary Python, outbound network access, and the same GitOps pipeline that ships the rest of your platform.

3. Reconcile the concurrency and missed-schedule semantics

The two schedulers behave differently the moment a run is slow or the environment hiccups, and this is where most surprises live. Set both sides explicitly rather than trusting defaults.

On the Kubernetes side, concurrencyPolicy: Forbid guarantees a slow rollup is never overlapped by the next fire, and startingDeadlineSeconds: 120 tells the controller to skip a schedule it missed by more than two minutes instead of back-filling a stampede of jobs the moment the control plane recovers. Leaving startingDeadlineSeconds unset is the classic footgun: after any control-plane outage the CronJob controller can count dozens of missed schedules and either launch them all or, past 100 misses, refuse to schedule at all.

On the InfluxDB side, a native task does not overlap itself either — the scheduler will not start a new run while the previous one for that task is still executing — but a task that consistently runs longer than its every interval silently falls behind, and a task that errors is retried on the engine’s own cadence, recorded in the _tasks system bucket. The parity you want is: one non-overlapping run per window on both sides, with late windows skipped rather than replayed blindly. The table below maps the whole comparison.

Dimension Native InfluxDB task Kubernetes CronJob
Infrastructure to run None beyond InfluxDB A Kubernetes cluster, image, registry, Secret
Language / libraries Flux only Any language in the container
External network egress Not available Full outbound access
Overlap control Implicit, no self-overlap concurrencyPolicy: Forbid/Replace
Missed-schedule handling Falls behind, retried on engine cadence startingDeadlineSeconds skips stale fires
Observability _tasks bucket, task run logs Pod logs, Job status, Prometheus, events
Failure blast radius Contained in the database Pod-level; noisy-neighbour isolation
Cost profile Included in InfluxDB compute Extra pod scheduling and cluster capacity
GitOps / CI fit Task API or Terraform Native manifest in your repo
Backfill / ad-hoc replay influx task run retry only kubectl create job --from=cronjob/...
Choosing between a native InfluxDB task and a Kubernetes CronJob If the workload needs external egress, a non-Flux language, or third-party libraries, use a Kubernetes CronJob. If it is fully expressible in Flux over InfluxDB buckets, use a native task with no extra infrastructure; otherwise fall back to a CronJob. Scheduled InfluxDB workload Needs egress, a non-Flux language, or third-party libraries? yes no Fully expressible in Flux over InfluxDB buckets alone? yes no Native InfluxDB task Kubernetes CronJob

The rule that falls out: keep pure bucket-to-bucket transforms as native tasks, and promote a workload to a Kubernetes CronJob the moment it needs egress, a non-Flux language, or a library. For the fuller build-versus-buy framing across engines, see the native vs external orchestration decision.

Gotchas and edge cases

The startingDeadlineSeconds stampede. Leaving the field unset means a Kubernetes CronJob that misses fires during a control-plane outage will try to catch up all of them at once, and if more than 100 schedules are missed the controller stops scheduling entirely and logs FailedNeedsStart. For a 15-minute rollup, set startingDeadlineSeconds to less than the interval (120 seconds here) so a late fire is skipped, not replayed — the rollup is idempotent per window anyway, so a skipped fire self-heals on the next run.

Two schedulers, two clocks, double writes. If you migrate from a native task to a CronJob without disabling the task, both write the same 15-minute mean into refrigeration-15m, doubling points and corrupting any downstream sum. Cut over atomically: set the native task to option task = {..., every: ...} inactive with influx task update --status inactive in the same change that deploys the CronJob, and verify only one writer remains before trusting the summary tier.

A CronJob token is a Secret, not an env literal. Mounting the InfluxDB token as a plain env value bakes it into the manifest and leaks it in kubectl describe. Use a secretRef (as above) with a token scoped to only the two buckets it touches, and rotate it on the same cadence as the rest of your scheduled fleet described in task scheduling and orchestration. A native task sidesteps this entirely by running under the database’s own authorization.

Verification

Prove exactly one writer is populating the summary tier and that each 15-minute window has a single point per case. On the InfluxDB side, confirm the native task is inactive (or absent) before trusting the CronJob:

bash
influx task list --org retail-cold-chain --token $INFLUX_TOKEN
# refrigeration-15m-rollup should read status=inactive after cutover.

Then assert one summary point per window — a count above 1 means both schedulers are still writing:

flux
from(bucket: "refrigeration-15m")
    |> range(start: -1h)
    |> filter(fn: (r) => r._measurement == "case_temp" and r.tier == "15m")
    |> aggregateWindow(every: 15m, fn: count, createEmpty: false)
    |> group(columns: ["_time"])
    |> sum()
    |> map(fn: (r) => ({r with _value: if r._value > 1 then 1 else 0}))
    |> sum()
    |> yield(name: "windows_with_double_writes_should_be_zero")

For the CronJob itself, confirm the last runs succeeded and none are wedged:

bash
kubectl -n cold-chain get cronjob refrigeration-15m-rollup
kubectl -n cold-chain get jobs -l job-name --sort-by=.metadata.creationTimestamp | tail -4

A clean result is a non-zero LAST SCHEDULE, zero active jobs between fires, and a zero double-write count from the Flux query.

FAQ

Can a native InfluxDB task call an external API to raise alerts?

No. Flux runs inside the database and has no general outbound network egress or third-party libraries, so anything requiring an HTTP call to a food-safety platform, a signed webhook, or a Python model must run in a Kubernetes CronJob or another external worker. Keep the pure rollup native and let the CronJob own the egress step.

How do I stop a slow rollup from overlapping the next run?

On Kubernetes set concurrencyPolicy: Forbid so the controller never starts a new job while the previous one runs. A native InfluxDB task already refuses to self-overlap; the risk there is falling behind, which you detect by watching run duration against the every interval in the _tasks system bucket.

Is a CronJob more expensive than a native task?

Usually, yes, at small scale: the CronJob adds pod scheduling, image pulls, and a slice of a Kubernetes cluster’s capacity, whereas a native task runs on compute you are already paying InfluxDB for. The CronJob earns its cost once the workload needs egress, custom code, or the same GitOps and observability pipeline as the rest of your platform.


Up one level: Kubernetes CronJob Orchestration