跳到正文
DEV Community· ByteChef·· 3 小時前AI 評分56

ByteChef Runtime Job app 如何像 CLI 命令般執行工作流程

Run a ByteChef workflow like a CLI command (no server, no database)

AI 導讀

ByteChef Runtime Job app 可在單一 JVM 中執行匯出的工作流程,完成後退出,無需常駐伺服器或資料庫。使用者以工作流程名稱及 JSON 傳入輸入參數與連線資料,程式以退出碼回報執行結果,亦可用於 Kubernetes CronJob 或 CI。

正文

Most automation platforms are built to stay up. ByteChef is no exception: the server stores your projects, fires triggers, listens for webhooks, and records every execution in PostgreSQL.

That's great until you look at where a lot of real work runs:

  • a nightly batch sitting in a Kubernetes CronJob next to twenty others, with alerting on failed pods
  • a CI step that should go red when something breaks
  • one task inside an Airflow DAG
  • a product with its own job runner that just wants to execute a workflow

In each of those, a standing server plus a database plus a message broker is overhead. You need the engine for the length of one run, and then you want it gone.

That's what the Runtime Job app does. You pass it a workflow name, inputs as JSON, and credentials as JSON. It boots the full Atlas engine in one JVM, runs the workflow to completion, and exits 0 or 1. Nothing to provision, nothing to clean up.

The Runtime Job app is part of ByteChef's Enterprise Edition. Source: server/ee/apps/runtime-job-app.

Same engine, different packaging

This isn't a lightweight interpreter. The coordinator, workers and task dispatchers are all there. What changes is everything around them:

ByteChef server Runtime Job app
Execution state PostgreSQL In memory, gone on exit
Message broker Redis, Kafka, SQS, RabbitMQ, JMS… In-process
Connections Encrypted store in the DB JSON passed at launch
Triggers Scheduler, polling, webhooks None. The run starts with the process
UI / API Yes No
Record of a run Execution history Exit code + log

The workflow JSON is identical in both, so the loop is: build and test in ByteChef, export, hand the file to the Runtime Job app.

A real example

A daily sales report: take a date, total the paid orders for that day from PostgreSQL, log a summary.

{
  "label": "Daily Sales Report",
  "inputs": [
    { "name": "reportDate", "label": "Report date", "type": "string", "required": true }
  ],
  "triggers": [
    { "label": "Manual", "name": "trigger_1", "type": "manual/v1/manual" }
  ],
  "tasks": [
    {
      "label": "Sum paid orders",
      "name": "postgresql_1",
      "type": "postgresql/v1/query",
      "parameters": {
        "query": "SELECT count(*) AS orders, coalesce(sum(total), 0) AS revenue FROM orders WHERE status = 'PAID' AND created_at = CAST(:reportDate AS date)",
        "parameters": { "reportDate": "${reportDate}" }
      }
    },
    {
      "label": "Log summary",
      "name": "logger_1",
      "type": "logger/v1/info",
      "parameters": {
        "text": "Sales for ${reportDate}: ${postgresql_1[0].orders} paid orders, revenue ${postgresql_1[0].revenue}"
      }
    }
  ]
}

Save it as ~/bytechef/data/workflows/daily-sales.json (the default lookup location). Note the task name postgresql_1 and component name postgresql; both matter for connections later.

Build and run:

./gradlew :server:ee:apps:runtime-job-app:bootJar

CONNECTIONS='{"postgresql": {"host": "localhost", "port": 5432, "database": "shop", "username": "etl", "password": "..."}}'

java -jar runtime-job-app.jar \
  --workflow=daily-sales.json \
  --parameters='{"reportDate": "2026-10-14"}' \
  --connections="$CONNECTIONS"

echo $?   # 0

On a laptop the whole thing took about 10 seconds, roughly one of which was the workflow itself. That's the trade: a few seconds of boot for zero standing infrastructure. Fine for a nightly batch; for a thousand calls a minute, use the server.

The flags

Flag Required What it does
--workflow=<file> yes File name, e.g. daily-sales.json. A lookup key, not a path.
--parameters=<json> no Workflow inputs as a JSON object.
--connections=<json> no Connection name → parameters. Repeatable; objects are merged.
--timeout=<duration> no 30s, 15m, 1h. Exit 1 if exceeded.
--outputs-file=<path> no Write the workflow's outputs as JSON on success.

Any Spring Boot property works too, e.g. --logging.level.com.bytechef=DEBUG.

Gotcha: --workflow is not a path

The app drops the directory, strips the extension, and Base64-encodes the rest into an id (daily-sales → ZGFpbHktc2FsZXM=). At startup it indexes every file in the enabled workflow repositories the same way, then looks the id up. So these are identical:

--workflow=daily-sales.json
--workflow=/anything/at/all/daily-sales.json

…and pointing at a file outside the repositories won't work. Repositories available: filesystem (default, ~/bytechef/data/workflows), classpath (workflows/ inside the JAR), and Git (off by default).

# read workflows from somewhere else
BYTECHEF_WORKFLOW_REPOSITORY_FILESYSTEM_LOCATION_PATTERN='/opt/workflows/*.json'

# or straight from Git, so a merge to main is a deploy
BYTECHEF_WORKFLOW_REPOSITORY_GIT_ENABLED=true
BYTECHEF_WORKFLOW_REPOSITORY_GIT_URL=https://github.com/acme/workflows.git
BYTECHEF_WORKFLOW_REPOSITORY_GIT_BRANCH=main

Tip: export workflows from the editor rather than hand-writing them. Component defaults are written into the JSON when you add a node, not at runtime, so a hand-written file can fail mid-run with Unknown value for : <property>.

Connections

No credential store means credentials arrive as JSON. Keys are connection names; values use the same property names as the component's connection form in the UI.

Before each task runs, its connection is resolved like this:

  1. By task name (postgresql_1) — applies to that task only.
  2. By component name (postgresql) — shared by every task of that component.
  3. No match — the task runs without one (fine for logger, fatal for a database).

Task-name keys let two tasks of the same component use different credentials, say a read replica and a primary. And you can split credentials across secrets by repeating the flag:

java -jar runtime-job-app.jar \
  --workflow=daily-sales.json \
  --connections="$OPENAI_CONNECTION" \
  --connections="$POSTGRES_CONNECTION"

Two caveats worth knowing up front:

  • Connection JSON is logged at INFO on startup. Treat the log as credential-bearing: restrict access and set retention.
  • Not every connector works this way yet. Database components (PostgreSQL, MySQL) and LLM providers that take an API key work. SaaS connectors whose auth is applied as an HTTP header by the platform (bearer, API key, OAuth2) don't get that header from --connections. Test before relying on them.

Exit codes are the API

Code Meaning
0 Job COMPLETED
1 Job FAILED or STOPPED, or --timeout hit
other non-zero Couldn't start: missing flag, bad JSON, unknown workflow
143 Killed by SIGTERM (eviction, drain, docker stop)

That's all a scheduler needs. A Kubernetes Job retries per backoffLimit, Airflow marks the task failed, CI goes red. Nobody parses logs.

Always set --timeout. Without it the app waits forever, and a stuck process is never retried because it never fails. This matters doubly for JavaScript/Python script tasks, which have no CPU or heap cap of their own.

Make writes idempotent. A 143 leaves no trace in the log, and a retry starts from scratch. Prefer upserts or check for an existing result.

Getting results out

Declare outputs in the workflow as usual:

"outputs": [
  { "name": "orders",  "value": "${postgresql_1[0].orders}" },
  { "name": "revenue", "value": "${postgresql_1[0].revenue}" }
]

They're logged as one JSON line on completion. For programs, use --outputs-file:

java -jar runtime-job-app.jar ... --outputs-file=/tmp/daily-sales.json
jq .revenue /tmp/daily-sales.json   # 1540.50

The file is only written for a completed job, so check the exit code first. Keep outputs small (counts, ids, a status); write anything big to where it's used and return a pointer.

Docker

docker build -t registry.example.com/bytechef-runtime-job:2026-10-16 server/ee/apps/runtime-job-app/

docker run --rm \
  -v "$(pwd)/workflows:/workflows" \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e BYTECHEF_WORKFLOW_REPOSITORY_FILESYSTEM_LOCATION_PATTERN='/workflows/*.json' \
  registry.example.com/bytechef-runtime-job:2026-10-16 \
  --workflow=daily-sales.json \
  --parameters='{"reportDate": "2026-10-14"}' \
  --connections="$CONNECTIONS"

The image is about 2 GB (GraalVM base + a ~770 MB JAR with ~100 components and the polyglot runtime). Use immutable tags with imagePullPolicy: IfNotPresent so each node pulls it once. Use the prod profile; local builds default to dev, which logs at DEBUG.

Kubernetes CronJob

This is where it fits best: a pod starts, runs one workflow, exits, and the exit code decides whether Kubernetes retries.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-sales
spec:
  schedule: "15 6 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: runtime-job
              image: registry.example.com/bytechef-runtime-job:2026-10-16
              imagePullPolicy: IfNotPresent
              command: ["/bin/sh", "-c"]
              args:
                - >-
                  exec java -jar /opt/bytechef/runtime-job/runtime-job-app.jar
                  --workflow=daily-sales.json
                  --parameters="{\"reportDate\": \"$(date -d yesterday +%F)\"}"
                  --connections="$POSTGRES_CONNECTION"
                  --timeout=20m
              env:
                - name: SPRING_PROFILES_ACTIVE
                  value: prod
                - name: BYTECHEF_WORKFLOW_REPOSITORY_FILESYSTEM_LOCATION_PATTERN
                  value: /workflows/*.json
                - name: POSTGRES_CONNECTION
                  valueFrom:
                    secretKeyRef:
                      name: daily-sales-connections
                      key: postgresql.json
              resources:
                requests: { cpu: 500m, memory: 768Mi }
                limits:   { memory: 1Gi }
              volumeMounts:
                - name: workflows
                  mountPath: /workflows
          volumes:
            - name: workflows
              configMap:
                name: daily-sales-workflow

Why it looks like this:

  • restartPolicy: Never + backoffLimit — exit 1 fails the pod; each retry is a clean process.
  • --timeout — backoffLimit only retries failed pods, not stuck ones. (activeDeadlineSeconds works too, but it kills the pod with a silent 143.)
  • Memory — a small workflow used ~370 MiB. Measure a real run and size from that; OOMKilled exits 137.
  • sh -c + exec — computes yesterday's date, and exec keeps the JVM's exit code as the pod's.
  • Secret + ConfigMap — credentials stay out of the manifest; updating the workflow is a kubectl apply, not an image rebuild.

CI

Exit codes make it a normal CI step. GitHub Actions, generating release notes on every tag:

name: Release notes
on:
  push:
    tags: ['v*']

jobs:
  notes:
    runs-on: ubuntu-latest
    container:
      image: registry.example.com/bytechef-runtime-job:2026-10-16
    steps:
      - uses: actions/checkout@v4
      - name: Generate and publish release notes
        env:
          SPRING_PROFILES_ACTIVE: prod
          BYTECHEF_WORKFLOW_REPOSITORY_FILESYSTEM_LOCATION_PATTERN: ${{ github.workspace }}/.bytechef/*.json
          OPENAI_CONNECTION: ${{ secrets.BYTECHEF_OPENAI_CONNECTION }}
        run: |
          java -jar /opt/bytechef/runtime-job/runtime-job-app.jar \
            --workflow=release-notes.json \
            --parameters="{\"tag\": \"${GITHUB_REF_NAME}\"}" \
            --connections="$OPENAI_CONNECTION" \
            --timeout=10m

The workflow lives in .bytechef/ in the repo it automates and gets code-reviewed like everything else. Same idea works in GitLab CI, Jenkins, or Airflow's KubernetesPodOperator.

What's in, what's out

In: ~100 components — HTTP client, script and Bash, helpers, CSV/JSON/XML/XLSX, PostgreSQL and MySQL, the major LLM providers, Google Workspace and Microsoft 365 basics, Slack, GitHub, Jira, HubSpot, Shopify — plus control flow (branch, condition, each, loop, map, parallel, fork-join, subflow, approval). Check the app's build.gradle.kts before depending on a specific one.

Out, on purpose: triggers, the UI and REST API, execution history (ship your logs somewhere), and scaling within a run (launch more processes instead).

Quick troubleshooting

Symptom Fix
Workflow with id: <base64> does not exist File isn't in a configured repository (or the volume isn't mounted). The directory in --workflow is ignored.
Unknown value for : <property> Re-export the workflow from the editor.
Auth error on a task Connection key doesn't match task/component name, or property names are wrong.
Process never exits Add --timeout.
Exit 143, nothing in log SIGTERM from outside. Check pod events.
Exit 137 / OOMKilled Raise the memory limit (and -Xmx).
No outputs file The job didn't complete. Check the exit code.

Server or Runtime Job?

Use the server when you need triggers, webhooks, a UI, run history, RBAC, a central credential store, or always-on/high-frequency processing.

Use the Runtime Job app for nightly batches, CI steps, and anything whose schedule already lives in Kubernetes, Airflow or GitHub Actions.

They're not competitors. A common setup: people build and test on a ByteChef server, and CronJobs run the exported, reviewed versions in production. Same JSON, unchanged.


Full reference: Runtime Job Runner docs. Originally published on the ByteChef blog.

來源:DEV Community · dev.to