随着AI时代的到来,在工业控制的领域比如工控机上进行小型推理变得愈发重要。传统的边缘计算框架需要支持轻量级的计算任务运行以及资源管控.这个系列。会以百度开源的easyfaas(现已停止维护)讲讲如何做到端侧AI框架的演化
边缘计算一般是指在用户或数据源的物理位置或附近进行的计算。通过让计算服务靠近这些位置,用户能够使用更快速可靠的服务. 这也就意味着在这是一个依赖轻、适配性强、资源占用少
集群版需要支持Golang实现Python计算任务的调度流程。当提交Python计算任务时,主要涉及controller和funclet两个核心组件的协作,实现了容器的分配、准备和执行。
当用户打包好算法插件后,编码成base64,发布到kafka的生产者, 在容器中解码执行函数.
架构图
调度流程概述
1. 任务提交:通过HTTP请求提交到controller组件
2. 资源分配:controller使用RuntimeManager管理和分配容器资源
3. 容器准备:找到或创建合适的容器来运行Python代码
4. 任务执行:funclet组件负责实际执行Python代码
详细调度过程
1. 任务接收与处理
当提交Python计算任务时,请求首先被controller的InvokeHandler处理: handler.go:43-47
2. 资源分配与容器调度
controller使用RuntimeManager来管理容器资源,它会尝试找到一个可用的"热"容器或创建一个新的"冷"容器:
1. 首先尝试找到已经准备好的容器: invoke.go:366-370
2. 如果没有可用容器,则分配一个新容器: invoke.go:374-379
3. 容器资源分配由OccupyColdRuntime方法实现: runtime_dispatcher.go:187-193
3. 容器准备与函数加载
一旦分配了容器,controller会通过funclet客户端调用WarmUp方法准备容器环境: invoke.go:382-394
funclet客户端实现了与funclet组件的通信: client.go:146-160
4. 任务执行与资源回收
执行完成后,系统会进行资源回收,包括释放已使用的资源和标记的资源: task.go:159-173
那么如果用户有一个计算任务,如何提交且执行呢?
用例:提交Python计算任务
1. 准备Python函数代码
首先,创建一个简单的Python函数代码文件:
# index.py
def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
# 简单的计算任务
number = int(event.get('number', 10))
result = sum(range(1, number + 1))
return {
'input': number,
'result': result,
'message': f'计算1到{number}的和为{result}'
}
2. 打包函数代码
将Python代码打包成zip文件并转换为base64编码:
zip code.zip index.py
base64 code.zip > code_base64.txt
3. 创建函数
使用curl调用func-registry接口创建函数:
curl -X POST "http://<YOUR_IP>:<YOUR_FUNC_REGISTRY_PORT>/v1/functions/pythonCalculation" \
-H 'Content-Type: application/json; charset=utf-8' \
-d '{
"Version": "1",
"Description": "Python计算示例",
"Runtime": "python3",
"Timeout": 5,
"MemorySize": 128,
"Handler": "index.handler",
"PodConcurrentQuota": 10,
"Code": "'$(cat code_base64.txt)'"
}'
op_func.md:32-36
4. 调用函数执行计算任务
使用curl调用controller接口执行函数:
curl -X POST "http://<YOUR_IP>:<YOUR_CONTROLLER_PORT>/v1/functions/brn:cloud:faas:bj:<ACCOUNT_ID>:function:pythonCalculation:1/invocations" \
-H 'X-easyfaas-Account-Id: <YOUR_ACCOUNT_ID>' \
-H 'Content-Type: application/json; charset=utf-8' \
-d '{"number": 100}'
op_func.md:119-124
5. 预期响应
{
"input": 100,
"result": 5050,
"message": "计算1到100的和为5050"
}
调度流程说明
1. 当您提交请求时,Controller组件的InvokeHandler方法接收请求 handler.go:43-47
2. Controller通过RuntimeDispatcher查找或分配容器资源 invoke.go:366-370
3. 如果没有可用容器,系统会分配一个新容器 invoke.go:374-379
4. Funclet组件准备Python运行环境并执行代码
5. 执行结果通过Controller返回给用户
如何实现边缘计算的实时算法
当边缘计算框架采集到终端数据后,一般需要有一个缓冲层需要推送到kafka,然后绑定kafka和算法函数的映射关系。进而进行计算. 当前EasyFaas不支持kafka作为数据源。故此需要一些改造
要将Kafka作为EasyFaaS的数据源,我们需要基于Shopify/sarama:
1. 创建一个新的Kafka存储驱动实现()
2. 注册该驱动到存储驱动工厂
3. 实现从Kafka获取代码的逻辑
并行消费组件sarama的工作原理是什么? 存在的问题是什么
消费消息示例
func consume(){
// 定义一个消费者,并开始消费
consumer := Consumer{}
ConsumerHighLevel.Consume(ctx, []string{Conf.topic}, &consumer); err != nil {
sarama.Logger.Printf("[ERROR] Error from Consumer: %s", err.Error())
}
}
type Consumer struct {}
func (consumer *Consumer) Setup(sarama.ConsumerGroupSession) error { return nil }
func (consumer *Consumer) Cleanup(sarama.ConsumerGroupSession) error { return nil }
func (consumer *Consumer) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) (err error) {
for {
message := <-claim.Messages()
println(message.Value) // 消费逻辑
session.MarkMessage(message, "") // 提交偏移
}
return nil
}
Client 支持并行消费多个Topic。消费时,会针对各Partition分别启动一个ConsumeClaim 的goroutine,获取队列数据并消费。如下图所示
尽然有这么好的处理机制,但是sarama也有一些弊端
-
保证消费消息的顺序性,需一个队列由一个 goroutine 消费。此模式下并行度则由kafka的队列数量来控制。Kafka 队列数量多,并行度就越大,队列数越少,并行度就小。
-
每条消息需要处理的时间平均为10ms,则一个队列的最大消费个数就是100/s;设置32分片,可以达到3200/s的消息处理量。当业务增长时,可能需要增加kakfa Topic的分片数量来提升消息处理量了。
-
但Kafka的分片数量并不能无限增长。因设置太多的分片可能会造成 Broker 选举慢,客户端需要cache 的消息量过大等问题
Sarama消费者组本地二次分片优化方案
1. 架构设计概述
1.1 传统Sarama消费架构
Sarama的标准消费者组架构中,每个Kafka分区对应一个goroutine进行消费。 consumer_group.go:864-894 这种设计确保了分区内消息的顺序性,但并行度受限于Kafka分区数量。
1.2 本地二次分片架构
本地二次分片方案在Sarama标准架构基础上增加了本地队列层,实现了消费并行度与Kafka分区数的解耦。
2. 核心组件设计
2.1 消息包装器 (CMessage)
type CMessage struct {
Message *sarama.ConsumerMessage
MarkMessage func()
}
2.2 增强消费者 (Enhanced Consumer)
type Consumer struct {
chMessage []chan *CMessage // 本地队列数组
}
2.3 偏移管理组件
- waitCommitQueue: 维护消息接收顺序的链表
- waitCommitMap: 标记已完成但未提交的消息映射
3. 子功能实现详解
3.1 消息分发机制
消息从Kafka分区接收后,通过自定义分片算法分发到本地队列:
consumer.chMessage[consumer.Sharding(message)] <- &CMessage{
Message: message,
MarkMessage: func() { /* 偏移提交逻辑 */ }
}
3.2 有序偏移提交机制
核心逻辑确保偏移量按顺序提交:
- 检查当前消息是否为队首消息
- 如果是队首,直接提交并检查后续已完成消息
- 如果不是队首,标记为已完成等待队首消息 consumer_group.go:918-920
3.3 本地队列管理
使用带缓冲的channel实现本地队列,防止内存溢出:
chMessage := make([]chan *CMessage, queueCount)
for i := range chMessage {
chMessage[i] = make(chan *CMessage, bufferSize)
}
3.4 具体实现
type Consumer struct {
chMessage []chan *CMessage
}
type None struct{}{}
func (consumer *Consumer) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) (err error) {
waitCommitQueue := list.New()
waitCommitMap := make(map[int64]None, 100000)
var mutex sync.Mutex
for {
message := <-claim.Messages()
mutex.Lock()
waitCommitQueue.PushBack(message)
mutex.Unlock()
// 自定义的local 队列, 使用channel 实现
// sharding 是自定义的算法
consumer.chMessage[consumer.Sharding(message)] <- &CMessage{
Message: message,
MarkMessage: func() {
mutex.Lock()
defer mutex.Unlock()
if waitCommitQueue.Front().Value.(*sarama.ConsumerMessage).Offset == message.Offset {
waitCommitQueue.Remove(waitCommitQueue.Front())
session.MarkMessage(message, "")
for waitCommitQueue.Len() > 0 {
item := waitCommitQueue.Front()
offset := item.Value.(*sarama.ConsumerMessage).Offset
if _, ok := waitCommitMap[offset]; !ok {
break
}
delete(waitCommitMap, offset)
session.MarkMessage(item.Value.(*sarama.ConsumerMessage), "")
waitCommitQueue.Remove(item)
}
} else {
waitCommitMap[message.Offset] = None{}
}
},
}
}
}
在做消费时,仅需要启动协程,分别消费各个channel中的数据即可:
func (consumer *Consumer) consume(){
queues := consumer.chMessage
for i := 0; i < len(queues); i++ {
wg.Add(1)
go func(queue chan *kafkautils.CMessage) {
defer wg.Done()
for {
select {
case message := <-queue:
// time.Sleep(100 * time.Millisecond)
time.Sleep(time.Duration(rand.Int() % 20) * time.Millisecond)
message.MarkMessage()
atomic.AddInt32(&count, 1)
case <-closed:
return
}
}
}(queues[i])
}
}
4. 时序图
4.1 消息消费主流程
4.2 偏移提交协调时序
4.3 启动和关闭时序
5. 并发与内存相关设计
5.1 并发安全
使用sync.Mutex保护waitCommitQueue和waitCommitMap的并发访问,确保偏移提交的原子性。
5.2 内存管理
- 使用带缓冲的channel防止内存无限增长
- 及时清理
waitCommitMap中已提交的条目 - 监控队列深度,实现背压机制
5.3 容错处理
- 服务重启后可能出现重复消费,需要业务层保证幂等性
- 实现优雅关闭,确保所有消息处理完成后再退出
- 异常情况下的偏移回滚机制
EasyFaas改造支持kafka数据源
1. 创建Kafka存储驱动包
首先,创建一个新的包pkg/repository/kafka来实现Kafka存储驱动:
// pkg/repository/kafka/kafka.go
package kafka
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/Shopify/sarama"
"github.com/baidu/easyfaas/pkg/repository"
"github.com/baidu/easyfaas/pkg/repository/factory"
"github.com/baidu/easyfaas/pkg/util/logs"
)
const (
driverName = "kafka"
DefaultBasePath = "/var/faas/tmp"
)
type DriverParameters struct {
BasePath string
Brokers []string
Topic string
GroupID string
MaxMessages int
}
func init() {
logs.Infof("register driver %s", driverName)
factory.Register(driverName, &kafkaDriverFactory{})
}
type kafkaDriverFactory struct{}
func (factory *kafkaDriverFactory) Create(parameters map[string]interface{}) (repository.StorageDriver, error) {
return FromParameters(parameters)
}
type driver struct {
basePath string
brokers []string
topic string
groupID string
maxMessages int
}
func FromParameters(parameters map[string]interface{}) (*driver, error) {
params, err := fromParametersImpl(parameters)
if err != nil || params == nil {
return nil, err
}
return New(*params)
}
func fromParametersImpl(parameters map[string]interface{}) (*DriverParameters, error) {
var basePath string
val, ok := parameters["basePath"]
if val != nil {
if item, ok := val.(string); ok {
basePath = item
}
}
if basePath == "" || !ok {
basePath = DefaultBasePath
}
// 获取Kafka配置
var brokers []string
if val, ok := parameters["brokers"]; ok && val != nil {
if items, ok := val.([]string); ok {
brokers = items
}
}
if len(brokers) == 0 {
return nil, fmt.Errorf("kafka brokers not configured")
}
var topic string
if val, ok := parameters["topic"]; ok && val != nil {
if item, ok := val.(string); ok {
topic = item
}
}
if topic == "" {
return nil, fmt.Errorf("kafka topic not configured")
}
var groupID string
if val, ok := parameters["groupID"]; ok && val != nil {
if item, ok := val.(string); ok {
groupID = item
}
}
if groupID == "" {
groupID = "easyfaas-consumer"
}
var maxMessages int
if val, ok := parameters["maxMessages"]; ok && val != nil {
if item, ok := val.(int); ok {
maxMessages = item
}
}
if maxMessages <= 0 {
maxMessages = 1
}
params := &DriverParameters{
BasePath: basePath,
Brokers: brokers,
Topic: topic,
GroupID: groupID,
MaxMessages: maxMessages,
}
return params, nil
}
func New(params DriverParameters) (*driver, error) {
return &driver{
basePath: params.BasePath,
brokers: params.Brokers,
topic: params.Topic,
groupID: params.GroupID,
maxMessages: params.MaxMessages,
}, nil
}
func (d *driver) Name() string {
return driverName
}
func (d *driver) Fetch(messageKey string) (filePath string, err error) {
logger := logs.NewLogger()
defer logger.TimeTrack(time.Now(), fmt.Sprintf("Fetch code from Kafka topic %s with key %s", d.topic, messageKey))
// 配置Kafka消费者
config := sarama.NewConfig()
config.Consumer.Return.Errors = true
// 创建消费者
consumer, err := sarama.NewConsumer(d.brokers, config)
if err != nil {
return "", fmt.Errorf("failed to create Kafka consumer: %v", err)
}
defer consumer.Close()
// 获取指定主题的分区列表
partitions, err := consumer.Partitions(d.topic)
if err != nil {
return "", fmt.Errorf("failed to get partitions for topic %s: %v", d.topic, err)
}
// 为每个分区创建一个分区消费者
for _, partition := range partitions {
pc, err := consumer.ConsumePartition(d.topic, partition, sarama.OffsetNewest)
if err != nil {
logger.Errorf("Failed to start consumer for partition %d: %v", partition, err)
continue
}
defer pc.Close()
// 创建临时文件路径
filename := filepath.Join(d.basePath, messageKey)
logger.Infof("Creating directory %s for Kafka message", filepath.Dir(filename))
if err := os.MkdirAll(filepath.Dir(filename), 0777); err != nil {
return "", err
}
// 创建文件
f, err := os.Create(filename)
if err != nil {
os.Remove(filename)
return "", err
}
defer f.Close()
// 设置超时
timeout := time.After(30 * time.Second)
// 消费消息
messagesConsumed := 0
for messagesConsumed < d.maxMessages {
select {
case msg := <-pc.Messages():
// 检查消息键是否匹配
if string(msg.Key) == messageKey {
logger.Infof("Found message with key %s", messageKey)
if _, err := f.Write(msg.Value); err != nil {
os.Remove(filename)
return "", err
}
messagesConsumed++
}
case err := <-pc.Errors():
logger.Errorf("Error consuming from Kafka: %v", err)
return "", err
case <-timeout:
if messagesConsumed == 0 {
return "", fmt.Errorf("timeout waiting for message with key %s", messageKey)
}
return filename, nil
}
}
return filename, nil
}
return "", fmt.Errorf("no partitions available for topic %s", d.topic)
}
2. 修改代码管理器以支持Kafka
更新pkg/funclet/code/manager.go文件,添加Kafka驱动支持:
// pkg/funclet/code/manager.go
package code
import (
"github.com/baidu/easyfaas/pkg/api"
"github.com/baidu/easyfaas/pkg/funclet/context"
"github.com/baidu/easyfaas/pkg/repository"
_ "github.com/baidu/easyfaas/pkg/repository/bos"
"github.com/baidu/easyfaas/pkg/repository/factory"
_ "github.com/baidu/easyfaas/pkg/repository/filesystem"
_ "github.com/baidu/easyfaas/pkg/repository/kafka" // 导入Kafka驱动
"github.com/baidu/easyfaas/pkg/util/logs"
)
const (
DriverBos = "bos"
DriverLocal = "filesystem"
DriverKafka = "kafka" // 添加Kafka驱动常量
)
// NewManager
func NewManager(basePath string) *Manager {
params := map[string]interface{}{
"basePath": basePath,
}
// 初始化BOS驱动
if driver, err := factory.Create(DriverBos, params); err != nil {
logs.Errorf("install driver %s failed: %s", DriverBos, err)
} else {
repository.RegisterStorageDriver(driver)
}
// 初始化文件系统驱动
if driver, err := factory.Create(DriverLocal, params); err != nil {
logs.Errorf("install driver %s failed: %s", DriverLocal, err)
} else {
repository.RegisterStorageDriver(driver)
}
// 初始化Kafka驱动
kafkaParams := map[string]interface{}{
"basePath": basePath,
"brokers": []string{"kafka-broker1:9092", "kafka-broker2:9092"},
"topic": "easyfaas-code",
"groupID": "easyfaas-consumer",
}
if driver, err := factory.Create(DriverKafka, kafkaParams); err != nil {
logs.Errorf("install driver %s failed: %s", DriverKafka, err)
} else {
repository.RegisterStorageDriver(driver)
}
return &Manager{}
}
3. 使用示例
以下是如何使用Kafka作为数据源的示例:
// 使用示例
func ExampleUsingKafkaDataSource() {
// 创建代码存储对象,指定使用Kafka作为数据源
codeStorage := &api.CodeStorage{
RepositoryType: "kafka",
Location: "function-code-key-123", // Kafka消息的key
}
// 创建代码管理器
codeManager := code.NewManager("/var/faas/tmp")
// 获取代码
ctx := context.NewContext()
filePath, err := codeManager.FetchCode(ctx, codeStorage)
if err != nil {
logs.Errorf("Failed to fetch code from Kafka: %v", err)
return
}
logs.Infof("Successfully fetched code from Kafka, saved to: %s", filePath)
}
python计算任务消费实现步骤
1. 将Python代码发布到Kafka
首先,将您的Python函数代码打包成ZIP文件并发布到Kafka主题:
# 打包Python代码
zip function-code.zip index.py
# 使用Kafka生产者发布代码(示例使用kafka-console-producer)
kafka-console-producer --bootstrap-server localhost:9092 --topic easyfaas-code --property "key.separator=:" --property "parse.key=true" < function-code.zip
2. 创建使用Kafka数据源的函数
创建函数时,在函数元数据中指定使用Kafka作为代码存储: func-registry.md:90-96
curl -X POST "http://<YOUR_IP>:<YOUR_FUNC_REGISTRY_PORT>/v1/functions/pythonKafkaTask" \
-H 'Content-Type: application/json; charset=utf-8' \
-d '{
"Version": "1",
"Description": "Python任务使用Kafka数据源",
"Runtime": "python3",
"Timeout": 10,
"MemorySize": 256,
"Handler": "index.handler",
"PodConcurrentQuota": 5,
"Code": {
"RepositoryType": "kafka",
"Location": "python-function-123"
}
}'
3. 调度执行流程
当您调用函数时,EasyFaaS的调度流程如下:
1. Controller接收请求:
2. 代码获取:Funclet通过Kafka驱动获取代码 fetch.go:26-35
3. Kafka驱动执行:根据我们之前实现的Kafka驱动,系统会:
a. 连接到Kafka集群
b. 从指定主题消费消息
c. 根据消息键"python-function-123"获取对应的ZIP代码包
d. 将代码保存到临时目录
4. 容器调度:Controller分配容器资源并准备Python运行环境
4. 完整调用示例
curl -X POST "http://<YOUR_IP>:<YOUR_CONTROLLER_PORT>/v1/functions/brn:cloud:faas:bj:<ACCOUNT_ID>:function:pythonKafkaTask:1/invocations" \
-H 'X-easyfaas-Account-Id: <YOUR_ACCOUNT_ID>' \
-H 'Content-Type: application/json; charset=utf-8' \
-d '{"data": "processing task", "number": 42}'
关键配置
在代码管理器初始化时,需要确保Kafka驱动已正确配置:
kafkaParams := map[string]interface{}{
"basePath": "/var/faas/tmp",
"brokers": []string{"kafka-broker1:9092", "kafka-broker2:9092"},
"topic": "easyfaas-code",
"groupID": "easyfaas-consumer",
}
当计算函数代码插件上传到代码仓库,又会怎么执行?
流程说明
1. 容器预热阶段
● 容器状态检查: Funclet首先检查容器是否正在运行 warmup.go:73-80
● 异步代码下载: 使用goroutine异步下载函数代码 warmup.go:102-113
● 代码挂载: 将代码挂载到容器的/var/task路径 options.go:53
● 信号触发: 代码准备完成后发送SIGUSR1信号触发容器初始化 warmup.go:164-167
2. 函数执行阶段
-
运行时调用: Controller通过Funclet调用预热好的容器执行函数 invoke.go:394
-
结果返回: 执行结果通过相同路径返回给客户端
-
Python代码在容器中解压后,执行是通过多步骤过程触发的:
1.代码准备和挂载: 首先,在预热阶段,Python代码被获取、解压并挂载到容器中。解压的代码随后被挂载到容器的数据代码路径。 container_manager.go:45-56
2.基于信号的执行触发: 触发Python代码执行的关键机制是通过向容器进程发送SIGUSR1信号。 process_manager.go:68-76 在代码成功下载和挂载后,系统发送此信号通知容器可以继续初始化。 process_manager.go:45-53
3.运行时配置: Python运行时配置遵循与其他支持的运行时相同的模式,其中使用引导脚本来初始化执行环境。Python函数配置有处理程序(如"index.handler")和运行时规范(如"python2.7")。 function_manager.go:11-19 function_manager.go:58-75
4,容器路径和环境: 解压的Python代码在容器的目标代码路径/var/task中可用, container_manager.go:92-101 运行时环境设置有适当的路径和套接字配置。
-
详细实现步骤
1. SIGUSR1信号机制:ProcessManager确实使用SIGUSR1信号来触发容器进程的初始化
2. 代码挂载到/var/task:ContainerManager将函数代码挂载到容器的/var/task路径
3. 代码下载和解压:有专门的方法处理代码包的下载和解压过程
4. Python运行时支持:支持多个Python版本(python2.7, python3.6, python3.7)
5. 函数处理程序配置:FunctionSpec结构体包含handler和runtime字段的配置