工业网关上的任务通常不是孤立执行的:设备数据要先采集,再清洗、聚合、写入本地库或上传平台;异常事件要触发诊断任务;部分指令还要等待设备确认。任务一多,如果没有清晰编排,就容易出现依赖混乱、重复执行、失败后无人处理、上线后无法追溯等问题。
本文从工程实战角度梳理工业网关任务编排的选型、DAG 设计、Airflow / Argo Workflows / Temporal 的适用边界,以及轻量自建方案应该补齐的超时、重试、幂等和观测能力。
一、任务编排要解决什么问题
| 问题 | 无编排时的表现 | 编排系统应提供的能力 |
|---|---|---|
| 依赖管理 | 脚本之间靠启动顺序或注释约定 | 显式 DAG,声明上游和下游 |
| 触发调度 | cron 分散、重复配置 | 时间触发、事件触发、手动触发 |
| 失败处理 | 任务失败后不知道从哪一步重跑 | 重试、断点、失败策略、幂等约束 |
| 并发控制 | 多任务同时抢 CPU、内存、设备连接 | 并发上限、资源隔离、队列 |
| 可追溯性 | 只有零散日志 | 任务状态、输入输出引用、耗时、错误 |
| 版本管理 | 逻辑改动后无法对齐历史执行 | DAG / Workflow 版本、变更记录 |
编排不是把所有逻辑塞进框架,而是把"什么时候执行、依赖谁、失败怎么办、执行到哪一步"变成可声明、可观测、可恢复的系统能力。
二、主流方案与适用边界
| 方案 | 技术定位 | 适合场景 | 边缘注意事项 |
|---|---|---|---|
| Airflow | Python DAG 批处理调度平台 | 数据抽取、清洗、汇总、日报、周期 ETL | 调度器、数据库和 Web 服务有资源开销,不适合单纯事件低延迟场景 |
| Argo Workflows | Kubernetes 原生 Workflow 引擎 | 已运行 K3s / Kubernetes,任务需要容器隔离 | 需要管理镜像、存储、Pod 资源和集群运维能力 |
| Temporal | 持久化工作流引擎 | 长流程、需要断点恢复、人工确认、复杂重试 | 需要 Server / History 服务和 Worker,部署复杂度高于轻量网关 |
| Prefect | Python 原生 Flow 编排 | Python 团队希望动态 DAG、部署较轻 | 仍需评估状态存储、UI 和执行器的资源占用 |
| 自建调度器 | 进程内或服务内 DAG 执行器 | 任务数量少、逻辑稳定、资源极有限 | 必须补齐超时、重试、幂等、状态持久化和观测 |
选型建议先看运行环境:没有 Kubernetes 时,不建议为了几个周期任务强行部署 Argo;任务需要跨进程、跨容器和长周期恢复时,也不建议只用 asyncio.gather() 包装函数。工业边缘常见的务实组合是:实时事件走消息队列或规则引擎,周期批处理走 DAG 调度,长流程走 Temporal 这类持久化工作流。
三、DAG 设计的工程规则
1. 明确节点边界
一个 DAG 节点应该代表一个可重试、可观测的业务单元,而不是任意函数拆分。
推荐按以下边界拆分:
- 采集 / 拉取:从设备、PLC、Modbus 采集或从消息队列消费;
- 清洗 / 转换:单位换算、异常值处理、去重、聚合;
- 存储:写入本地时序库、对象存储或关系库;
- 上传:同步到云端平台或第三方系统;
- 通知 / 诊断:告警、生成诊断报告、触发补偿任务。
2. 节点必须幂等
调度系统通常提供"至少一次"执行语义。网络超时、进程重启、Worker 失联都可能导致任务重复执行,因此节点要按业务键处理重复数据。
常用做法:
- 数据库使用唯一键约束,重复写入执行
UPSERT; - 文件写入使用版本号、批次 ID 或内容哈希;
- 上传前先查询远端状态,或使用带去重 ID 的接口;
- 设备指令设置请求 ID,避免确认包重复触发;
- 聚合任务按时间窗口和站点 ID 重建结果,而不是盲目累加。
3. 传引用,不传大数据
Airflow XCom、Temporal Event History、Argo Artifact 都不适合无限放大。工业数据量高时,任务之间应传递批次号、文件路径、对象存储 Key 或数据库查询条件,而不是完整数据集。
4. 显式声明并发和资源
边缘设备 CPU、内存、磁盘 IO 和设备连接数都有限。DAG 设计时至少明确:
- 同一任务的最大并发运行数;
- 同一设备的互斥锁;
- 任务超时时间;
- 重试次数和退避间隔;
- 本地磁盘写入上限;
- 失败后是否允许跳过、阻塞或触发降级。
四、Airflow:周期批处理 DAG
下面是一个设备数据 ETL 示例。Airflow 更适合分钟级以上的批处理;如果业务要求毫秒级响应,应使用消息队列或规则引擎,而不是把 Airflow 当实时系统。
python
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from airflow.decorators import dag, task
@dag(
dag_id="device_etl",
schedule="*/5 * * * *",
start_date=datetime(2024, 1, 1, tzinfo=timezone.utc),
catchup=False,
max_active_runs=1,
default_args={
"retries": 2,
"retry_delay": timedelta(minutes=1),
},
tags=["edge", "device", "etl"],
)
def device_etl() -> None:
@task
def extract_devices() -> str:
"""Return a batch reference instead of all records."""
batch_id = "batch_20260917_001"
return batch_id
@task
def transform(batch_id: str) -> str:
"""Read by batch reference and write transformed data to storage."""
transformed_ref = f"{batch_id}:transformed"
return transformed_ref
@task
def load(transformed_ref: str) -> dict[str, Any]:
"""Persist records idempotently by business key."""
return {
"status": "ok",
"ref": transformed_ref,
"rows": 0,
}
batch_id = extract_devices()
transformed_ref = transform(batch_id)
load(transformed_ref)
dag = device_etl()
关键配置说明:
schedule是 Airflow 2.4+ 推荐写法,旧项目中的schedule_interval应逐步迁移;catchup=False避免服务长时间停机后补跑大量历史任务;max_active_runs=1限制同一 DAG 的并发实例,防止上一批未完成时重复执行;retries只解决瞬时故障,业务失败仍应通过告警和人工任务处理;- XCom 只传批次引用,大数据走对象存储或数据库。
五、Argo Workflows:容器化步骤编排
如果工业网关已经运行 K3s / Kubernetes,Argo 适合把每个步骤放进独立容器,实现镜像级隔离和资源限制。
yaml
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: device-etl-
spec:
entrypoint: pipeline
activeDeadlineSeconds: 1800
ttlStrategy:
secondsAfterCompletion: 86400
templates:
- name: pipeline
retryStrategy:
limit: "2"
retryPolicy: "OnError"
dag:
tasks:
- name: extract
template: extract
- name: transform
template: transform
dependencies: [extract]
- name: load
template: load
dependencies: [transform]
- name: extract
container:
image: registry.local/edge-etl:1.0.0
command: [python, extract.py]
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
- name: transform
container:
image: registry.local/edge-etl:1.0.0
command: [python, transform.py]
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
- name: load
container:
image: registry.local/edge-etl:1.0.0
command: [python, load.py]
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
工程要点:
- 镜像使用固定版本,避免
latest导致重跑行为不一致; - 每一步都设置
resources,防止任务抢占网关主业务; activeDeadlineSeconds限制整体执行时长;ttlStrategy自动清理历史 Workflow,避免集群元数据膨胀;- 中间结果放在 PVC、S3 / MinIO 或数据库,用批次 ID 关联;
- 需要访问设备网络的任务要配置 NetworkPolicy 和最小权限。
六、Temporal:长流程与可恢复执行
Temporal 的价值在于持久化执行状态:流程在进程重启、网络中断、活动失败后可以从事件历史恢复。它适合设备升级审批、批量指令下发、跨系统对账、人工确认等长流程。
python
from __future__ import annotations
from datetime import timedelta
from typing import Any
from temporalio import activity, workflow
from temporalio.common import RetryPolicy
@activity.defn
async def fetch_device(device_id: str) -> dict[str, Any]:
return await device_api.fetch(device_id)
@activity.defn
async def update_database(data: dict[str, Any]) -> None:
await database.upsert_by_device_id(data["device_id"], data)
@workflow.defn
class DeviceWorkflow:
@workflow.run
async def run(self, device_id: str) -> dict[str, Any]:
data = await workflow.execute_activity(
fetch_device,
device_id,
start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(maximum_attempts=3),
)
await workflow.execute_activity(
update_database,
data,
start_to_close_timeout=timedelta(seconds=10),
retry_policy=RetryPolicy(maximum_attempts=3),
)
return {"device_id": device_id, "status": "updated"}
工程边界:
- Workflow 代码必须确定性,不能直接调用数据库、HTTP、随机数或当前时间;
- IO、网络、设备访问都放到 Activity;
- Activity 要幂等,因为 Worker 失联后可能重复执行;
start_to_close_timeout是单次活动执行上限,复杂任务还应评估schedule_to_close_timeout;- Workflow 和 Activity 变更需要遵循版本兼容规则,不能随意修改已运行流程的逻辑;
- 生产部署要规划 Temporal Server、数据库持久化、Worker 资源和历史数据保留策略。
七、轻量自建:只做受控的小型 DAG
当任务数量少、运行环境简单、只需要进程内编排时,可以自建一个很小的 DAG 执行器。但要清楚:下面这类实现没有持久化历史、没有分布式调度、没有 Web UI,只适合作为网关内部任务的受控执行骨架。
python
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
TaskFunc = Callable[[], Awaitable[Any]]
@dataclass
class TaskSpec:
name: str
func: TaskFunc
dependencies: set[str] = field(default_factory=set)
timeout: float = 30.0
retries: int = 0
class SimpleDag:
def __init__(self, max_parallelism: int = 2) -> None:
self.tasks: dict[str, TaskSpec] = {}
self.max_parallelism = max_parallelism
def add(self, task: TaskSpec) -> None:
if task.name in self.tasks:
raise ValueError(f"duplicate task: {task.name}")
self.tasks[task.name] = task
def validate(self) -> None:
names = set(self.tasks)
for task in self.tasks.values():
unknown = task.dependencies - names
if unknown:
raise ValueError(f"{task.name} has unknown dependencies: {unknown}")
visiting: set[str] = set()
visited: set[str] = set()
def visit(name: str) -> None:
if name in visited:
return
if name in visiting:
raise ValueError(f"cycle detected at {name}")
visiting.add(name)
for dependency in self.tasks[name].dependencies:
visit(dependency)
visiting.remove(name)
visited.add(name)
for name in names:
visit(name)
async def run(self) -> dict[str, Any]:
self.validate()
results: dict[str, Any] = {}
completed: set[str] = set()
running: set[str] = set()
while len(completed) < len(self.tasks):
runnable = [
task
for task in self.tasks.values()
if task.name not in completed
and task.name not in running
and task.dependencies <= completed
][: self.max_parallelism]
if not runnable and running:
await asyncio.sleep(0.05)
continue
if not runnable:
raise RuntimeError("workflow has no runnable tasks")
running.update(task.name for task in runnable)
outcomes = await asyncio.gather(
*(self._run_with_retry(task) for task in runnable),
return_exceptions=True,
)
failures = [
(task.name, error)
for task, error in zip(runnable, outcomes, strict=True)
if isinstance(error, BaseException)
]
if failures:
raise RuntimeError(f"workflow failed: {failures}")
for task, result in zip(runnable, outcomes, strict=True):
results[task.name] = result
completed.add(task.name)
running.remove(task.name)
return results
async def _run_with_retry(self, task: TaskSpec) -> Any:
last_error: BaseException | None = None
for attempt in range(task.retries + 1):
try:
return await asyncio.wait_for(task.func(), timeout=task.timeout)
except Exception as error:
last_error = error
if attempt < task.retries:
await asyncio.sleep(min(2**attempt, 5))
raise RuntimeError(f"task {task.name} failed") from last_error
这个实现仍然缺少:
- 状态持久化和进程重启后的恢复;
- 分布式锁和跨实例任务分配;
- Web UI、执行历史和审计;
- 更细的失败策略,例如跳过、降级、人工介入;
- 子进程或容器隔离;
- 追踪、指标和结构化事件。
如果这些能力是刚需,应优先选择 Airflow、Argo 或 Temporal,而不是继续扩展自研调度平台。
八、边缘部署与运维实践
1. 隔离批任务与实时链路
周期 ETL、模型推理、报表生成等任务容易产生 CPU 和磁盘峰值,应与设备协议接入、实时告警、指令下发等关键链路隔离。可用方案包括独立进程、cgroup / systemd 资源限制、容器资源限制,或将重任务上传到边缘服务器执行。
2. 控制本地状态增长
任务日志、中间文件、Artifact 和历史记录都要设置保留策略。建议按"热数据本地可查、冷数据压缩归档、过期数据自动删除"分层处理,并监控磁盘使用率。
3. 配置重跑和补偿
编排不是只定义成功路径,还要定义失败路径:
- 哪些任务可以自动重试;
- 哪些任务失败后必须人工确认;
- 哪些数据可以重新生成;
- 哪些设备指令不能重复下发;
- 重跑时从哪个批次或时间窗口开始;
- 是否需要先清理半成品数据。
4. 建立任务观测
至少采集:
- 调度触发时间和任务开始时间;
- 每个节点的耗时、成功数、失败数、重试数;
- 当前运行中的任务和排队任务;
- 输入记录数、输出记录数、丢弃记录数;
- 本地磁盘、内存、CPU 和网络连接;
- 关键外部依赖可用性。
告警要区分"任务失败"和"任务连续失败"。偶发失败可以由重试恢复,连续失败、积压、磁盘接近上限和队列阻塞才应立即通知值班人员。
5. 做好版本与回滚
DAG、Workflow、容器镜像和任务脚本要统一版本。发布前至少保留:
- 上一版镜像或代码包;
- 数据库结构回滚方案;
- 调度配置备份;
- 任务的输入输出样例;
- 灰度范围和停止条件。
九、常见坑与处理
| 坑 | 现象 | 处理 |
|---|---|---|
| 循环依赖 | 任务永远 pending,或自建调度器死锁 | 启动前做拓扑校验和环检测 |
| 没有超时 | 外部接口或设备响应挂起,任务长期占用 | 为每个节点设置执行超时 |
| 没有幂等 | 重试后重复写入、重复下发指令 | 用业务键、请求 ID、UPSERT 和窗口重建 |
| 大对象进执行历史 | XCom / Event History / Artifact 膨胀 | 传引用,数据放对象存储或数据库 |
| 并发失控 | CPU、内存、连接数互相挤压 | 限制 DAG 并发、任务并发和设备锁 |
| 无状态持久化 | 进程重启后进度丢失 | 保存批次状态,或使用持久化工作流引擎 |
| 无观测 | 只知道"失败",不知道卡在哪一步 | 记录节点状态、耗时、错误和上下游 |
| 版本随意变更 | 重跑历史任务行为不一致 | 固定镜像和代码版本,遵循兼容策略 |
十、测试与验收清单
- DAG 拓扑无环,依赖名称完整;
- 每个节点都有超时、重试和失败策略;
- 任务按业务键重复执行不会产生脏数据;
- 服务重启后可以恢复或安全重跑;
- 并发上限符合边缘资源预算;
- 输入输出只传引用,大数据有落盘方案;
- 执行历史、错误日志和指标可查询;
- 设备指令有请求 ID 和确认去重;
- 回滚版本和发布检查单可用;
- 压测覆盖高峰数据量、慢网络和磁盘不足。
TL;DR
工业网关任务编排的核心不是选择最复杂的框架,而是让依赖、触发、并发、失败处理和执行状态变得清晰可控。周期批处理可选 Airflow 或 Prefect;Kubernetes 环境中的容器化步骤适合 Argo Workflows;长流程和强恢复需求适合 Temporal;资源极受限时可以自建小型 DAG,但必须补齐幂等、超时、观测和持久化边界。
在 Zenova EdgeOS 这类工业边缘运行环境中,任务编排可用于组织设备数据 ETL、周期诊断、边缘规则联动和跨系统同步,并把执行状态、失败重试与观测能力纳入统一的网关运行链路。