ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

无人机机群编排系统实战:从通信协议到CI/CD部署

无人机机群编排系统实战:从通信协议到CI/CD部署 无人机机群编排软件是连接地面调度中心、无人机节点和业务目标的中间层。单独控制一台无人机只需要遥控器和飞控但当几十台无人机需要同时执行测绘、巡检或物流任务时真正的瓶颈变成软件谁来拆解任务、谁决定哪台飞机去哪、如何处理断线、如何保证状态一致。这就是编排系统要解决的问题。下面从通信协议、数据结构、调度逻辑、容器化联调、CI/CD 发布和问题排查六条线展开带你把一套最小可用的机群编排系统跑起来。1. 机群编排软件到底在编排什么1.1 从单机飞行到机群协同难的从来不是硬件一台无人机本身已经具备完整的闭环能力飞控读取传感器数据执行飞行动作返回遥测状态。机群系统并不是把几十台独立无人机硬塞进同一个上位机软件而是在这些独立节点之上增加一层调度大脑。这层大脑解决三类核心问题任务怎么拆把用户输入的一段航线或者一个巡检目标拆成每台无人机可以独立执行的子任务。资源怎么分综合考虑电量、距离、载荷类型、当前状态决定哪台无人机执行哪一个子任务。状态怎么追实时记录每台无人机的位置、电池、任务阶段并在异常时触发重试、回退或重新分配。把这层大脑做成软件后业务方不再直接面对单台无人机而是面对一个机群服务。指挥端只需要下发任务软件负责把任务翻译成无人机能理解的指令。1.2 编排器的三个职责拆解、分配、回收实际工程里编排器通常被拆成三个独立模块而不是一个大单体Task Planner任务规划器接收业务请求生成任务计划。比如一次巡检任务会生成航线、航点、动作序列和预期结果。Scheduler调度器维护无人机资源池把任务计划中的子任务分配给具体无人机。分配时要考虑在线状态、电量、任务优先级。Fence Manager回收器监控任务执行状态处理超时、失败、失联等情况。任务完成或者失败后回收无人机资源更新状态。这三个模块各自可以独立扩展。机群规模小的时候它们可以运行在同一个进程里机群规模大了任务规划器可以拆成独立服务调度器使用分布式锁和任务队列回收器变成独立的告警和补偿组件。1.3 消息流与状态流要拆开设计很多机群系统在早期设计时把指令下发和状态上报混在同一个接口里导致后面很难排查问题。推荐的模型是两条独立的链消息流云端向无人机下发指令无人机向云端回执 ack。这是短连接、高实时、低频次的消息通道。状态流无人机持续上报遥测数据云端持久化并更新状态视图。这是长连接、高频率、可以容忍短暂乱序的数据通道。把两条链拆开后调度器只关心状态流里的最新值不关心历史上的每一跳轨迹而执行器只关心消息流里的指令不阻塞等待状态上报。这样设计的好处是任一条链路出问题都不会直接拖垮另外一条链路。2. 通信协议和数据格式决定整个系统能否落地2.1 为什么优先选择 MQTT / WebSocket 而不是 HTTP机群系统里无人机到服务器、服务器到无人机的通信都不是典型的请求-响应模型。无人机需要被服务器随时叫醒也需要持续上报状态。如果每次都用 HTTP 轮询服务器压力大、实时性差而且无人机在弱网环境下很难维持稳定连接。下面从工程角度对比三种协议协议适合场景实时性连接模型典型问题HTTP 轮询低频指令查询秒级以上短连接连接开销大实时性差WebSocket双向实时消息毫秒级长连接需要自己处理心跳和重连MQTT大量设备上报、指令下发毫秒级长连接 发布订阅需要部署 Broker主题设计复杂实际项目中最常见的是 MQTT WebSocket 组合无人机端通过 MQTT 接入云端内部服务通过 WebSocket 推送实时状态给前端大屏。MQTT 天然支持 QoS、遗嘱消息和保留消息非常适合无人机会频繁断线的网络环境。2.2 用 JSON 定义三类核心消息为了让编排器、模拟器、前端都能理解同一套数据建议在一开始就把消息格式定成独立模块用 JSON Schema 或 Protobuf 约束。下面以 JSON 为例定义一个最小但完整的消息体系。第一类是遥测上报无人机周期性上报自身状态{ type: telemetry, agent_id: drone-001, ts: 1710000000, pos: { lat: 31.23, lng: 121.47, alt: 120.5 }, battery: 0.82, mode: hover }第二类是指令下发编排器向指定无人机发送任务{ type: mission, mission_id: m-1001, assigned_to: drone-001, action: survey, waypoints: [ { lat: 31.22, lng: 121.46, alt: 100 }, { lat: 31.23, lng: 121.47, alt: 100 } ], deadline: 1710003600 }第三类是任务事件无人机反馈任务阶段变化{ type: mission_event, mission_id: m-1001, agent_id: drone-001, status: completed, result: { flight_time_s: 320, photo_count: 42 } }每类消息都需要一个统一的type字段方便下游消费者按类型路由。ts字段统一使用 Unix 时间戳避免不同时区客户端的解析歧义。2.3 Redis 存储状态比关系型数据库更合适的原因编排器需要维护一张无人机当前状态表数据特点是读多写少、每次更新都覆盖旧值、对实时性要求高。如果用关系型数据库直接存每秒几十台无人机的状态更新会造成大量行锁竞争和索引膨胀。Redis 更适合这种场景原因有几点Hash 结构天然适合存储单台无人机的多字段状态。可以设置过期时间自动清理失联节点。Pub/Sub 能力可以辅助状态变更通知。缓存和实时视图共用一套存储降低架构复杂度。一个简单设计如下Redis Key类型说明drone:onlineSet在线无人机 ID 集合drone:status:{agent_id}Hash无人机最新状态mission:queueList待分配任务队列mission:running:{agent_id}String无人机当前执行的任务 ID这里不把任务详情直接塞进 Redis任务详情仍然放在数据库或对象存储中Redis 只保存关联关系避免大对象占据内存。3. 用 Go 写一个可运行的最小编排器3.1 项目和依赖准备下面示例使用 Go 1.21 编写编排器使用 Python 编写无人机模拟器。选择 Go 是因为它的并发模型和部署产物非常适合中台服务选择 Python 做模拟器是因为写模拟脚本更快而且不涉及真实飞控硬件。项目目录建议如下drone-fleet/ ├── orchestrator/ │ ├── main.go │ ├── go.mod │ └── internal/ │ ├── mission.go │ ├── scheduler.go │ └── redis.go ├── simulator/ │ ├── drone_sim.py │ └── requirements.txt ├── docker-compose.yml └── .drone.yml编排器依赖两个关键组件go get github.com/go-redis/redis/v8 go get github.com/eclipse/paho.mqtt.golang模拟器依赖 paho-mqtt 客户端pip install paho-mqtt3.2 模拟无人机节点心跳与状态上报真实无人机逻辑复杂但模拟器只需要保留两个核心行为持续上报遥测接收指令并回复 ack。下面是一个最小 Python 模拟器片段import json import random import threading import time from paho.mqtt import client as mqtt_client class DroneSimulator: def __init__(self, drone_id, broker, port1883): self.id drone_id self.broker broker self.port port self.client mqtt_client.Client(drone_id) self.client.on_connect self.on_connect self.client.on_message self.on_message def on_connect(self, client, userdata, flags, rc): print(f{self.id} connected) client.subscribe(fdrones/{self.id}/cmd) threading.Thread(targetself.telemetry_loop, daemonTrue).start() def on_message(self, client, userdata, msg): cmd json.loads(msg.payload) print(f{self.id} receive mission {cmd.get(mission_id)}) # 模拟处理耗时 time.sleep(random.uniform(0.5, 2)) client.publish( fdrones/{self.id}/event, json.dumps({ type: mission_event, mission_id: cmd.get(mission_id), agent_id: self.id, status: completed, }), qos1, ) def telemetry_loop(self): while True: payload { type: telemetry, agent_id: self.id, ts: int(time.time()), battery: round(random.uniform(0.5, 1.0), 2), mode: idle, } self.client.publish(drones/telemetry, json.dumps(payload), qos1) time.sleep(3)这段代码的关键点有三个使用qos1保证消息至少到达一次避免遥测全丢。遥测上报采用独立线程不阻塞指令接收。收到 mission 指令后必须回发mission_event这是编排器判断任务完成的标准。3.3 编排器的调度与派单逻辑编排器启动后需要同时做三件事订阅遥测、订阅任务事件、提供 REST API。调度逻辑在最简单的版本里可以用在线无人机轮询实现。type Scheduler struct { mu sync.Mutex agents map[string]*AgentState mission map[string]string // missionID - agentID } func (s *Scheduler) pickAgent() (string, error) { s.mu.Lock() defer s.mu.Unlock() for id, state : range s.agents { if state.Online time.Since(state.LastSeen) 10*time.Second { state.Online false // 简单占用 return id, nil } } return , fmt.Errorf(no available agent) } func (s *Scheduler) dispatch(mission Mission) error { agentID, err : s.pickAgent() if err ! nil { return err } payload, _ : json.Marshal(map[string]any{ type: mission, mission_id: mission.ID, assigned_to: agentID, action: mission.Action, waypoints: mission.Waypoints, }) token : mqttClient.Publish(drones/agentID/cmd, 1, false, payload) token.Wait() return token.Error() }这里的pickAgent只是最简单的演示逻辑。真实系统中应该引入优先级队列、电量过滤和任务类型匹配否则容易把低电量无人机提前派出去。任务状态机建议如下状态触发条件下一步pending任务创建进入调度队列dispatched调度器选出无人机等待 ackrunning模拟器回执等待 mission_eventcompletedmission_event statuscompleted释放无人机failed超时或 mission_event statusfailed重新调度或告警调度器在dispatched状态下应该启动一个超时定时器比如 10 秒内没有收到 ack就需要把无人机资源释放并重试或标记失败。3.4 REST API 接入任务下发和机群状态查询编排器还需要一个对业务方暴露的入口。用 Go 标准库即可实现最小 APIhttp.HandleFunc(/api/missions, func(w http.ResponseWriter, r *http.Request) { if r.Method ! http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var mission Mission if err : json.NewDecoder(r.Body).Decode(mission); err ! nil { w.WriteHeader(http.StatusBadRequest) return } if err : scheduler.dispatch(mission); err ! nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusAccepted) }) http.HandleFunc(/api/fleet, func(w http.ResponseWriter, r *http.Request) { states : scheduler.snapshot() json.NewEncoder(w).Encode(states) })业务方调用POST /api/missions创建任务调用GET /api/fleet查看机群实时状态。这里的任务 ID、航线、动作等数据正常项目应该落到数据库中而不是全部放在 Redis 里。4. 用 Docker Compose 跑通本地联调环境4.1 Compose 服务划分本地联调需要一个 MQTT Broker、一个 Redis、一个编排器容器和若干个模拟器容器。Docker Compose 配置如下version: 3.8 services: mqtt: image: eclipse-mosquitto:2 ports: - 1883:1883 volumes: - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro redis: image: redis:7-alpine ports: - 6379:6379 orchestrator: build: ./orchestrator environment: REDIS_ADDR: redis:6379 MQTT_BROKER: mqtt:1883 HTTP_PORT: 8080 ports: - 8080:8080 depends_on: - mqtt - redis drone-sim: build: ./simulator environment: MQTT_BROKER: mqtt:1883 DRONE_IDS: drone-001,drone-002,drone-003 depends_on: - mqtt服务之间通过服务名互相访问。编排器和模拟器都依赖 MQTT 和 Redis使用depends_on只能保证启动顺序不能保证服务已经就绪所以容器内代码要加入重试逻辑比如在连接失败后等待 2 秒再重试。4.2 启动顺序和配置检查先构建镜像再启动docker compose up --build -d查看日志确认三个模拟器都成功连接docker compose logs -f drone-sim正常输出类似drone-001 connected drone-002 connected drone-003 connected如果模拟器一直重连先检查 MQTT Broker 端口是否映射正确再检查容器内环境变量MQTT_BROKER是否指向mqtt。注意容器内不能使用localhost因为它是容器自己的回环地址不是宿主机。4.3 用 curl 验证完整链路下发一个测试任务curl -X POST http://localhost:8080/api/missions \ -H Content-Type: application/json \ -d { id: m-1001, action: survey, waypoints: [ {lat: 31.22, lng: 121.46, alt: 100}, {lat: 31.23, lng: 121.47, alt: 100} ] }正常响应码是202 Accepted。然后查看机群状态curl http://localhost:8080/api/fleet如果返回中能看到某台无人机的任务 ID 被占用说明消息链路已经打通。再等待几秒第二次查询时该无人机应该恢复空闲状态说明mission_event成功回写。5. 用 Drone 搭建自动化构建和发布流水线5.1 机群编排软件同样需要 CI/CD机群系统通常包含编排器、模拟器、地面站 web 前端和算法组件。只要改动了一个消息字段就可能影响多个模块所以手工部署很容易漏掉某个镜像。Drone 是轻量级 CI/CD与 Gitea、Harbor、Docker、Nginx 配合可以形成一条完整的代码提交 - 构建 - 推送 - 部署链路。选择 Drone 而不是 Jenkins主要考虑是配置即代码.drone.yml放在仓库里审查和执行都透明。基于 Docker 容器执行每个 Step 都是独立镜像环境隔离。与 Gitea 集成简单Webhook 推送代码变更即可触发流水线。5.2 一个可落地的 .drone.yml 示例下面是一个最小流水线运行测试构建镜像推送到 Harbor再通过 SSH 触发服务器拉取镜像和重启容器。kind: pipeline type: docker name: build-test-push steps: - name: test image: golang:1.21 commands: - go test ./... when: event: - push - pull_request - name: build-image image: plugins/docker settings: registry: harbor.example.com repo: harbor.example.com/drone-fleet/orchestrator tags: ${DRONE_COMMIT_SHA} username: from_secret: harbor_username password: from_secret: harbor_password - name: deploy image: appleboy/drone-ssh settings: host: 192.168.1.20 username: root key: from_secret: ssh_key script: - docker pull harbor.example.com/drone-fleet/orchestrator:${DRONE_COMMIT_SHA} - cd /opt/drone-fleet docker compose up -d orchestrator trigger: branch: - main这个示例中需要注意两个易错点from_secret里的密钥要在 Drone 管理后台配置不能直接明文写在 yaml 文件里。部署 Step 使用 SSH 连接生产服务器必须提前配置 Docker Compose 文件和镜像拉取凭据否则服务器上无法执行docker pull。5.3 Gitea、Harbor、Drone、Nginx 的部署组合这组工具的典型职责如下组件职责Gitea托管源码提供 Webhook 事件Drone监听 Webhook执行构建和测试Docker构建和运行容器Harbor私有镜像仓库提供镜像存储和扫描Nginx反向代理统一入口和 TLS 终止本地开发时可以在 Gitea 仓库设置中添加 Drone Webhook指向http://drone.example.com/hook。Drone 收到 hook 后拉取代码并执行流水线。Harbor 则负责保存不可变镜像 tag例如用 Git commit SHA 标记镜像版本避免同名 tag 覆盖造成回滚困难。6. 机群系统中最难排查的五类问题6.1 消息下发成功但 Agent 不执行现象编排器日志显示Publish成功但模拟器没有打印接收日志。排查顺序确认 MQTT 主题是否匹配。下发主题是drones/{agent_id}/cmd订阅主题也必须是完全相同字符串MQTT 不会自动处理通配符之外的前缀。检查 QoS。发布 QoS 1 必须等待 Broker 回执如果 Broker 配置了匿名访问关闭消息会被拒绝。检查 agent_id 是否一致。容器内环境变量和编排器注册实例是否使用了同一批 ID。问题根源检查方式处理建议主题不匹配订阅者日志 Broker 管理面板统一主题常量禁止字符串拼接QoS 不一致发布端和订阅端配置统一使用 QoS 1权限拒绝MQTT Broker 日志开启匿名访问或添加账号密码6.2 状态上报乱序与时间戳陷阱现象无人机先发出的遥测比后发出的更晚到达导致编排器用旧值覆盖新值。原因网络重传、多线程发送顺序、Broker 内部队列都可能造成乱序。处理方式接收端不要直接覆盖状态先比较ts只接受更新时间大于等于当前值的消息。发布端设置消息单调递增序号例如seq字段。依赖时间戳时必须保证所有设备使用 UTC不要使用本地时间否则会出现跨时区后的乱序判断。6.3 断线重连后状态丢失现象无人机网络抖动 30 秒恢复后编排器仍然认为它离线或者它的任务状态停留在 running。原因没有使用 MQTT 遗嘱消息也没有在调度器中设置最后在线时间过期逻辑。解决方案无人机连接 Broker 时设置will遗嘱消息内容标记为 offline。编排器每次收到遥测后更新LastSeen超过指定时间未更新就将Online置为 false。任务状态增加超时看门狗超过deadline未收到完成事件自动把任务状态改为 failed 并回收无人机。6.4 MQTT QoS 选择导致的重复和丢失QoS 0 可能丢失消息QoS 1 可能重复QoS 2 可能增加延迟。无人机遥测数据量大重复几条可以容忍但指令消息不能丢失也不能重复执行。工程建议遥测流使用 QoS 1配合消息去重。指令流也使用 QoS 1但在业务层加入幂等控制也就是同一个mission_id只允许执行一次。不要在弱网环境使用 QoS 0 下发指令否则回执缺失会导致调度器误判。6.5 任务堆积和调度倾斜现象某台无人机一直空闲另外几台任务排满或者任务队列积压无人机却没有被调度。原因pickAgent算法过于简单只按 map 遍历顺序取第一个可用无人机导致前几台总是被选中。处理方案引入排序条件例如电量从高到低、历史任务次数从少到多。调度器从 Redis List 弹出任务而不是在内存里维护任务队列。调度循环失败时要做退避重试避免无意义空转消耗 CPU。7. 从学习环境走向生产环境还需要补齐六件事7.1 安全通信和设备认证上述本地联调环境默认使用匿名 MQTT 访问生产环境必须替换为 TLS 用户名密码或者证书认证。无人机和编排器之间需要确认双方身份防止伪造消息。建议MQTT Broker 开启 TLS端口从 1883 改为 8883。每台无人机使用独立账号权限限制在/drones/{agent_id}/#主题。云端 API 使用 HTTPS并在网关层加 Token 校验。7.2 数据持久化与审计遥测数据和任务数据不能只存在 Redis 中否则一旦 Redis 重启任务状态和飞行轨迹全部丢失。生产环境需要两类存储热数据实时状态、在线列表放在 Redis。冷数据任务日志、遥测轨迹、异常事件落入数据库或对象存储。审计需求上建议每个任务都记录完整的操作时间线谁创建、何时下发、哪台无人机执行、何时完成、结果数据在哪里。7.3 仿真测试和真机切换策略模拟器只能验证软件逻辑不能替代真机兼容性测试。切换真机前建议制定分级测试策略纯模拟测试验证编排逻辑和消息链路。半实物测试一台真机接入其余节点用模拟器验证通信协议和指令格式。小规模真机测试在合规空域、安全环境下验证多台无人机协同。每一级测试都要有独立的 MQTT 主题空间和数据库环境避免影响线上机群。7.4 发布前检查清单检查项说明是否通过环境变量MQTT Broker、Redis、数据库地址是否配置外置化是/否消息主题是否统一常量避免字符串拼接是/否任务幂等mission_id 重复执行是否有保护是/否超时处理任务 ack 超时、mission_event 超时是否有看门狗是/否安全认证MQTT TLS、API Token、设备证书是否启用是/否数据备份Redis 和数据库是否有持久化和备份策略是/否日志监控编排器、模拟器、Broker 日志是否有采集和告警是/否回滚方案镜像 tag 是否唯一能否快速回滚上一版本是/否7.5 下一步扩展方向这套最小系统跑通后可以按三条路线继续扩展调度算法升级从简单轮询改成优先级队列、任务类型匹配、动态电量预估。多机协同算法在编排器之上增加集群航点规划解决多无人机碰撞和空域冲突。前端可视化通过 WebSocket 订阅 Redis 状态变更在大屏上实时显示机群位置和任务状态。机群编排软件的核心不在于某个语言或框架而在于把任务、通信、状态、异常四件事拆清楚。能做好这四件事即使模拟器换成真实飞控软件架构也不会需要推倒重写。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进