| 1 | --- |
| 2 | title: Jobs & CronJobs |
| 3 | description: Run work to completion with Kubernetes.Job, as a container image or as an Effect program, and on a schedule as a CronJob. Covers how Job names, re-runs, retries, and cleanup work. |
| 4 | --- |
| 5 | |
| 6 | [`Kubernetes.Job`](/providers/kubernetes/job) runs work until it |
| 7 | finishes, such as migrations, seeds, smoke tests, and reports. It |
| 8 | applies a **ServiceAccount** and a Kubernetes **Job**. When you set |
| 9 | `schedule`, it applies a **CronJob** instead. |
| 10 | |
| 11 | A Job runs one of two things: |
| 12 | |
| 13 | - **A container image.** It can be any image, with its own |
| 14 | entrypoint and arguments. Nothing about the program needs to know |
| 15 | about Alchemy. |
| 16 | - **An Effect program.** It's an Effect you write in the same |
| 17 | codebase, bundled into an image and run in the pod. It can read |
| 18 | configuration, call other services, and use bindings. |
| 19 | |
| 20 | ## Run a container image |
| 21 | |
| 22 | Pass `image`, and optionally `command` and `args`: |
| 23 | |
| 24 | ```typescript |
| 25 | const migrate = yield* Kubernetes.Job("Migrate", { |
| 26 | cluster, |
| 27 | name: "migrate", |
| 28 | namespace: "apps", |
| 29 | image: "ghcr.io/acme/migrator:v3", |
| 30 | args: ["up"], |
| 31 | env: { DATABASE_URL: db.connectionString }, |
| 32 | backoffLimit: 2, |
| 33 | }); |
| 34 | ``` |
| 35 | |
| 36 | The cluster's nodes pull the image as written, so this works on any |
| 37 | cluster. It takes the same image, environment, resource, and pod |
| 38 | template props as a [Deployment](/kubernetes/workloads/deployments), |
| 39 | without the port and Service. |
| 40 | |
| 41 | ## Run an Effect program |
| 42 | |
| 43 | Declare the Job in its own module with `main: import.meta.url` and an |
| 44 | implementation that returns `{ run }`. This example assumes |
| 45 | `src/infra.ts` exports the cluster and an `Api` Deployment, as |
| 46 | described in [Why the props are an Effect](#why-the-props-are-an-effect): |
| 47 | |
| 48 | ```typescript |
| 49 | // src/Seed.ts |
| 50 | import * as Kubernetes from "alchemy/Kubernetes"; |
| 51 | import * as Effect from "effect/Effect"; |
| 52 | import { Api, Cluster } from "./infra.ts"; |
| 53 | |
| 54 | export default Kubernetes.Job( |
| 55 | "Seed", |
| 56 | Effect.gen(function* () { |
| 57 | const cluster = yield* Cluster; |
| 58 | return { |
| 59 | cluster, |
| 60 | main: import.meta.url, |
| 61 | name: "seed", |
| 62 | backoffLimit: 2, |
| 63 | }; |
| 64 | }), |
| 65 | Effect.gen(function* () { |
| 66 | const api = yield* Api; |
| 67 | const apiService = yield* api.serviceName; |
| 68 | |
| 69 | return { |
| 70 | run: Effect.gen(function* () { |
| 71 | const service = yield* apiService; |
| 72 | yield* Effect.log(`seeding records through ${service}`); |
| 73 | }).pipe(Effect.orDie), |
| 74 | }; |
| 75 | }), |
| 76 | ); |
| 77 | ``` |
| 78 | |
| 79 | Then yield it from your Stack with `yield* Seed`. |
| 80 | |
| 81 | On deploy, Alchemy bundles the module, builds an image for the |
| 82 | cluster's nodes, pushes it to the cluster's registry, and applies the |
| 83 | Job. In the pod, a generated entrypoint runs `run` to completion and |
| 84 | exits: |
| 85 | |
| 86 | - **Success.** The process exits 0 and the Job completes. |
| 87 | - **Failure.** A failed or defected `run` exits non-zero, which |
| 88 | Kubernetes counts against `backoffLimit`. |
| 89 | |
| 90 | Yielding a resource attribute such as `api.serviceName` in the |
| 91 | implementation binds it to the Job. Alchemy resolves the value at |
| 92 | deploy time and delivers it to the pod, and `run` reads it by |
| 93 | yielding the returned Effect. The binding also makes the Job depend |
| 94 | on the resource. |
| 95 | |
| 96 | `run` can use `HttpClient` to call other services. It can also use the Bun |
| 97 | platform services, such as the file system and child processes. A |
| 98 | Job has nowhere to return errors, so `run` must handle them or turn |
| 99 | them into defects with `Effect.orDie`. |
| 100 | |
| 101 | ### What the cluster needs |
| 102 | |
| 103 | The image has to be pushed somewhere the nodes can pull from: |
| 104 | |
| 105 | - **[`Kubernetes.LocalCluster`](/kubernetes/clusters/local).** It has |
| 106 | a built-in registry, so there's nothing to configure. |
| 107 | - **Any other cluster.** It needs a |
| 108 | [`registry`](/kubernetes/clusters/registries) on the connection, |
| 109 | such as `ghcr.io/acme`. |
| 110 | - **[Amazon EKS](/kubernetes/clusters/eks).** It uses ECR, which is |
| 111 | created for you. |
| 112 | |
| 113 | The generated entrypoint imports `@effect/platform-bun`, so install |
| 114 | it alongside `alchemy` and `effect`. |
| 115 | |
| 116 | ### Why the props are an Effect |
| 117 | |
| 118 | The props are an Effect so the module can `yield*` shared resources |
| 119 | that the Stack also uses, such as the cluster, a namespace, or a |
| 120 | Deployment to call. Declare those shared resources at module scope in |
| 121 | a file such as `src/infra.ts`. Each `yield*` resolves to the same |
| 122 | resource. When the props don't reference anything, pass a plain |
| 123 | object instead. |
| 124 | |
| 125 | ### Tagged Jobs |
| 126 | |
| 127 | The tagged form declares the Job as a class, so other code can depend |
| 128 | on it by type and the implementation is provided as a Layer: |
| 129 | |
| 130 | ```typescript |
| 131 | export class Backfill extends Kubernetes.Job<Backfill>()("Backfill") {} |
| 132 | |
| 133 | export default Backfill.make( |
| 134 | { cluster, main: import.meta.url, backoffLimit: 1 }, |
| 135 | Effect.gen(function* () { |
| 136 | return { run: Effect.log("backfilling") }; |
| 137 | }), |
| 138 | ); |
| 139 | ``` |
| 140 | |
| 141 | ## When a Job runs |
| 142 | |
| 143 | Kubernetes starts a Job as soon as it's created. Alchemy creates a |
| 144 | new Job whenever what it runs changes. A Job runs: |
| 145 | |
| 146 | - On the first deploy. |
| 147 | - On every deploy that changes its spec. That means the image, |
| 148 | `args`, `env` values, resources, or pod template. `env` values |
| 149 | include Outputs that resolved to new values. |
| 150 | - For Effect programs, on every deploy that changes the program or |
| 151 | anything it imports. |
| 152 | |
| 153 | A Job doesn't run on deploys that leave it unchanged. The deploy |
| 154 | doesn't wait for the Job to finish. Follow it with |
| 155 | `kubectl wait --for=condition=complete job/<jobName>` and |
| 156 | `kubectl logs job/<jobName>`. |
| 157 | |
| 158 | ## Job names |
| 159 | |
| 160 | A Job's pod template can't change after the Job is created, so |
| 161 | Alchemy names each one-shot Job after its spec. `jobName` is the base |
| 162 | name plus the first eight characters of a hash, for example |
| 163 | `seed-5133e709`. A changed spec produces a new name. Alchemy applies |
| 164 | the new Job and deletes the previous one along with its pods. |
| 165 | |
| 166 | Kubernetes caps Job names at 63 characters, so the base name is |
| 167 | truncated to fit. Read the current name from the `jobName` |
| 168 | attribute rather than predicting it. |
| 169 | |
| 170 | ## Retries, restarts, and cleanup |
| 171 | |
| 172 | - `backoffLimit` sets how many failed pods Kubernetes retries before |
| 173 | marking the Job failed. |
| 174 | - `restartPolicy` sets what happens after a failure. `"Never"` is the |
| 175 | default and starts a new pod. `"OnFailure"` restarts the container |
| 176 | in place. |
| 177 | - `ttlSecondsAfterFinished` makes Kubernetes delete the finished Job |
| 178 | this many seconds after it completes or fails. |
| 179 | |
| 180 | With `ttlSecondsAfterFinished`, Kubernetes removes the Job object on |
| 181 | its own. Alchemy tracks the Job through its ServiceAccount, so a |
| 182 | garbage-collected Job is not treated as missing and does not run |
| 183 | again until its spec changes. |
| 184 | |
| 185 | ## Run on a schedule |
| 186 | |
| 187 | Set `schedule` to a standard five-field cron expression and Alchemy |
| 188 | applies a `CronJob` instead. It works for both kinds of Job: |
| 189 | |
| 190 | ```typescript |
| 191 | // an image |
| 192 | const nightly = yield* Kubernetes.Job("NightlyReport", { |
| 193 | cluster, |
| 194 | name: "nightly-report", |
| 195 | image: "ghcr.io/acme/reports:v2", |
| 196 | schedule: "0 3 * * *", |
| 197 | backoffLimit: 1, |
| 198 | ttlSecondsAfterFinished: 86_400, |
| 199 | }); |
| 200 | ``` |
| 201 | |
| 202 | ```typescript |
| 203 | // an Effect program |
| 204 | export default Kubernetes.Job( |
| 205 | "Heartbeat", |
| 206 | Effect.gen(function* () { |
| 207 | const cluster = yield* Cluster; |
| 208 | return { |
| 209 | cluster, |
| 210 | main: import.meta.url, |
| 211 | name: "heartbeat", |
| 212 | schedule: "*/5 * * * *", |
| 213 | }; |
| 214 | }), |
| 215 | Effect.gen(function* () { |
| 216 | return { run: Effect.log("still here") }; |
| 217 | }), |
| 218 | ); |
| 219 | ``` |
| 220 | |
| 221 | `backoffLimit`, `restartPolicy`, and `ttlSecondsAfterFinished` apply |
| 222 | to each Job the CronJob starts. A CronJob can be updated in place, so |
| 223 | it keeps a stable name. That name is the base name, truncated to 52 |
| 224 | characters so Kubernetes can append its own suffix to each Job it |
| 225 | starts. Changing the program updates the CronJob's image, and the |
| 226 | next scheduled run uses it. |
| 227 | |
| 228 | Schedules are evaluated in the time zone of the cluster's controller |
| 229 | manager, which is UTC on most hosted clusters. The CronJob |
| 230 | `timeZone` field isn't exposed as a prop. If you need it, write the |
| 231 | CronJob as a [Manifest](/kubernetes/objects/manifests). |
| 232 | |
| 233 | Adding or removing `schedule` switches between a one-shot Job and a |
| 234 | CronJob. Alchemy applies the new object and deletes the old one. |
| 235 | |
| 236 | ## Attributes |
| 237 | |
| 238 | - `jobName` is the name of the Job or CronJob object. |
| 239 | - `kind` is `"Job"` or `"CronJob"`. |
| 240 | - `schedule` is the cron schedule, or `undefined` for a one-shot Job. |
| 241 | - `serviceAccountName` is the ServiceAccount the pods run as. |
| 242 | - `namespace` is the namespace of the objects. |
| 243 | - `imageUri` is the image the pods run. |
| 244 | - `connection` is the cluster connection. |
| 245 | - `kubernetesObjects` holds references to the applied objects. |
| 246 | |
| 247 | ## Where next |
| 248 | |
| 249 | - **[Tutorial part 3](/kubernetes/tutorial/part-3).** Builds an |
| 250 | Effect smoke test and a scheduled Effect Job on a local cluster. |
| 251 | - **[Configuration & bindings](/kubernetes/workloads/bindings).** |
| 252 | Pass values and credentials to a Job. |
| 253 | - **[`Job` reference](/providers/kubernetes/job).** Lists every prop |
| 254 | and attribute. |