边缘计算到端侧AI框架的演化(一) 边缘计算Faas计算

504 阅读13分钟

随着AI时代的到来,在工业控制的领域比如工控机上进行小型推理变得愈发重要。传统的边缘计算框架需要支持轻量级的计算任务运行以及资源管控.这个系列。会以百度开源的easyfaas(现已停止维护)讲讲如何做到端侧AI框架的演化

边缘计算一般是指在用户或数据源的物理位置或附近进行的计算。通过让计算服务靠近这些位置,用户能够使用更快速可靠的服务. 这也就意味着在这是一个依赖轻、适配性强、资源占用少

集群版需要支持Golang实现Python计算任务的调度流程。当提交Python计算任务时,主要涉及controller和funclet两个核心组件的协作,实现了容器的分配、准备和执行。

当用户打包好算法插件后,编码成base64,发布到kafka的生产者, 在容器中解码执行函数.

架构图

image.png

调度流程概述

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,获取队列数据并消费。如下图所示

QQ_1748142648434.png

尽然有这么好的处理机制,但是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分区数的解耦。

QQ_1748142273109.png

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 有序偏移提交机制

核心逻辑确保偏移量按顺序提交:

  1. 检查当前消息是否为队首消息
  2. 如果是队首,直接提交并检查后续已完成消息
  3. 如果不是队首,标记为已完成等待队首消息 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 消息消费主流程

QQ_1748142360153.png

4.2 偏移提交协调时序

QQ_1748142386178.png

4.3 启动和关闭时序

QQ_1748142441334.png

5. 并发与内存相关设计

5.1 并发安全

使用sync.Mutex保护waitCommitQueuewaitCommitMap的并发访问,确保偏移提交的原子性。

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",  
}

当计算函数代码插件上传到代码仓库,又会怎么执行?

image.png

流程说明

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字段的配置