华为云 FunctionGraph:Serverless 架构从入门到生产实践

举报
辣椒酱来了 发表于 2026/10/29 13:31:34 2026/10/29
【摘要】 一、问题的起点:脚本时代的运维黑洞我们的原始架构非常朴素:一台 ECS 上跑着 nginx 和几个 Flask 服务,另外十几个 Python 脚本用 crontab 挂着,干的事情包括数据库备份、日志归档、报表生成、第三方接口数据同步。这套东西在一个人的时候勉强能跑,等到业务扩张就暴露了三个问题。第一,失败是静默的。 crontab 执行失败,除了往 /var/log/cron 里写一行...

一、问题的起点:脚本时代的运维黑洞

我们的原始架构非常朴素:一台 ECS 上跑着 nginx 和几个 Flask 服务,另外十几个 Python 脚本用 crontab 挂着,干的事情包括数据库备份、日志归档、报表生成、第三方接口数据同步。

这套东西在一个人的时候勉强能跑,等到业务扩张就暴露了三个问题。

第一,失败是静默的。 crontab 执行失败,除了往 /var/log/cron 里写一行,没有任何人知道。我们曾经有一次因为数据库密码轮换,备份脚本连续失败 11 天没人发现,直到真的需要恢复数据那天。

第二,资源是绑定的。 大部分脚本一天只跑几分钟,但必须常驻一台机器。这台机器要打补丁、要续费、要监控磁盘,运维成本远高于它承担的业务价值。

第三,伸缩是不可能的。 一次批量报表任务因为数据量翻了三倍,脚本跑了 40 分钟还没结束,占满了 CPU,把同机的 Web 服务拖垮了。想给它单独加资源?只能再买一台机器。

这三条指向同一个结论:这些任务本质上是"事件驱动、短时运行、无状态"的,它们不该被绑在常驻服务器上。

二、FunctionGraph 的核心模型

FunctionGraph 是华为云提供的函数计算服务,本质就是一个托管的执行环境:你把一段代码传上去,指定触发条件,平台负责分配计算资源、执行、记录日志、按调用次数计费。

理解它只需要抓住四个概念。

2.1 函数(Function)

函数是最小部署单元,由三部分组成:

  • 代码:支持 Python、Node.js、Java、Go、PHP、C# 等运行时,也可以带自定义镜像
  • 入口:格式为 文件.方法名,例如 index.handler
  • 配置:内存规格、超时时间、环境变量、委托权限(委托决定了函数能访问哪些云服务)

2.2 触发器(Trigger)

触发器回答"什么时候跑"。常用的几种:

触发器类型 适用场景 典型用法
定时触发器(Timer) 周期性任务 替代 crontab,支持 cron 表达式
API 网关触发器 HTTP 接口 对外提供 REST API
OBS 触发器 文件事件 上传图片后自动压缩、生成缩略图
消息通知(SMN) 异步消息 与其他云服务解耦
事件网格(EG) 云服务事件 响应 ECS、RDS 状态变化

我们迁移 crontab 任务用的就是定时触发器,一个函数配一个 Timer,相当于把 crontab 里的一行搬到了云上。

2.3 版本与别名

这是一个容易被忽略但生产必备的能力。函数发布后可以打版本(Version),然后给版本绑别名(Alias),触发器绑在别名上而不是绑定在最新代码上。

这样做的好处是灰度:prod 别名指向版本 3,新代码发版本 4 后先绑到 beta 别名上观察,稳定了再把 prod 切过去。回滚只需要切一次别名,几秒钟完成,不用重新部署代码。

2.4 执行角色(委托)

函数里要访问 OBS、RDS、SMN 这些服务,传统做法是把 AK/SK 写进环境变量——这是很糟糕的实践,密钥一旦泄露,泄露范围是整个账号。

正确做法是创建 IAM 委托,只授予函数需要的最小权限,然后在函数配置里指定这个委托。函数运行时平台会自动注入临时凭证,代码里直接用 SDK 即可,不需要任何硬编码密钥。

三、整体架构

迁移后的结构大致是这样:

                    ┌─────────────────────┐
                    │   定时触发器 Timer   │
                    │  cron: 0 0 2 * * ?  │
                    └──────────┬──────────┘
                               │ 事件触发
                               ▼
                    ┌─────────────────────┐
                    │  FunctionGraph 函数 │
                    │  backup.handler     │
                    │  ├─ 连接 RDS        │
                    │  ├─ mysqldump       │
                    │  └─ 上传 OBS        │
                    └──────────┬──────────┘
                               │
              ┌────────────────┼────────────────┐
              ▼                ▼                ▼
       ┌───────────┐   ┌───────────┐   ┌───────────┐
       │  RDS 实例  │   │  OBS 桶   │   │  SMN 通知 │
       │ (数据源)   │   │ (存储)    │   │ (失败告警)│
       └───────────┘   └───────────┘   └───────────┘
                               │
                               ▼
                    ┌─────────────────────┐
                    │  日志服务 LTS        │
                    │  每次调用一条记录    │
                    └─────────────────────┘

整个链路没有一个常驻进程。触发器负责调度,函数负责执行,OBS 负责存储,SMN 负责告警,LTS 负责留痕。

四、实战:把备份脚本搬上去

4.1 第一步:写函数代码

FunctionGraph 的 Python 函数入口签名固定为 handler(event, context)。event 是触发器传来的数据,context 包含请求 ID、剩余执行时间等信息。

# index.py
import os
import json
import gzip
import subprocess
import datetime
from obs import ObsClient

def handler(event, context):
    # 定时触发器传入的 event 是空结构,主要用于日志留痕
    request_id = context.getRequestID()
    today = datetime.date.today().isoformat()
    dump_file = f"/tmp/backup_{today}.sql"

    try:
        # 1. 导出数据库到 /tmp(FunctionGraph 唯一可写目录)
        cmd = [
            "mysqldump",
            f"-h{os.environ['DB_HOST']}",
            f"-u{os.environ['DB_USER']}",
            f"-p{os.environ['DB_PASS']}",
            "--single-transaction",
            "--quick",
            os.environ["DB_NAME"],
        ]
        with gzip.open(dump_file + ".gz", "wt", encoding="utf-8") as f:
            subprocess.run(cmd, stdout=f, check=True, timeout=600)

        # 2. 上传到 OBS
        client = ObsClient(
            access_key_id=os.environ["TEMP_AK"],
            secret_access_key=os.environ["TEMP_SK"],
            server=os.environ["OBS_ENDPOINT"],
        )
        key = f"db-backup/{today}/backup_{today}.sql.gz"
        resp = client.putFile(os.environ["BUCKET"], key, dump_file + ".gz")

        if resp.status >= 300:
            raise RuntimeError(f"upload failed: {resp.status} {resp.errorMessage}")

        # 3. 清理临时文件
        os.remove(dump_file + ".gz")

        return {
            "statusCode": 200,
            "body": json.dumps({"request_id": request_id, "key": key})
        }

    except Exception as e:
        # 抛出异常,平台会标记本次调用失败,可配置告警
        print(f"backup failed: {e}")
        raise

这里有三个必须注意的点。

/tmp 是唯一可写目录。 函数运行环境的根文件系统是只读的,只有 /tmp 可写,容量约 512 MB。这意味着大文件备份不能先落地再上传,要么流式上传,要么分片直传。

--single-transaction 不是可选项。 不加这个参数,mysqldump 会对表加锁,生产库直接被锁住。加了之后 InnoDB 表在事务快照下导出,不阻塞写入。

环境变量不要放账号密码。 上面代码里的 DB_PASS 是权宜写法,生产环境应该把数据库密码托管到密码安全中心(DEW),函数通过委托读取。临时 AK/SK 同理,应该由 SDK 自动从委托获取,而不是手工填进环境变量。

4.2 第二步:打包依赖

Python 函数默认只带标准库。obs SDK 这类第三方包需要连同代码一起打成 zip,并且依赖必须解压到函数根目录,不能再套一层目录。

正确的包结构:

backup.zip
├── index.py
├── obs/
│   ├── __init__.py
│   └── ...
└── esdk_obs_python-3.x.x.dist-info/

打包命令:

mkdir -p build && cd build
pip install esdk-obs-python --target ./
cp ../index.py ./
zip -r ../backup.zip . -x "*.pyc" -x "__pycache__/*"

一个常见坑是本地 pip install --target 会拉取当前 Python 版本的 wheel,如果本地是 3.12 而函数运行时选的是 3.9,带 C 扩展的包就会加载失败。解决办法是在和运行时一致的 Docker 镜像里打包,或者干脆选纯 Python 实现的包。

4.3 第三步:配置触发器

定时触发器的 cron 表达式是 6 位(秒 分 时 日 月 周),比 Linux crontab 多一个秒位。这是最容易写错的地方:

需求 crontab 写法 FunctionGraph 写法
每天凌晨 2 点 0 2 * * * 0 0 2 * * ?
每 5 分钟 */5 * * * * 0 */5 * * * ?
每周一 9 点 0 9 * * 1 0 0 9 ? * 2

注意最后两位:FunctionGraph 用 ? 表示"不指定",星期是从 1(周日)到 7(周六)编号,和 crontab 的 0-6 完全不同。把 crontab 表达式直接复制过去,任务会在错误的时间点跑,而且不会报错——这才是最麻烦的地方。

4.4 第四步:超时与内存怎么定

两个参数直接影响成败和费用。

超时时间:默认 3 秒,最大 900 秒(15 分钟)。我们第一次迁移时忘了改,备份函数跑到 3 秒就被强杀,日志里只有一行 timeout,非常迷惑。经验值是取正常耗时的 3 倍左右留余量。

内存规格:CPU 是按内存比例分配的,内存给太小不仅跑得慢,还更容易 OOM。一个经验法则:纯 IO 密集型任务(比如备份上传)512 MB 足够;涉及数据处理、压缩、序列化的任务给 1 GB 以上。内存调大一档的费用增加,远小于超时翻倍带来的费用增加。

五、冷启动:Serverless 绕不开的那道坎

这是所有人迁移前最关心的问题,我们的实测情况是:

场景 首次调用延迟 说明
首次部署后第一次调用 3~8 秒 需要拉取代码、初始化运行时
闲置后冷启动 1~3 秒 容器被回收后重建
热调用 50~200 ms 容器复用,取决于业务逻辑
预留实例 稳定在 50~200 ms 无冷启动

结论很清楚:定时任务和异步处理完全不受冷启动影响,因为没人盯着它的响应时间。真正受影响的是同步 HTTP 接口,用户点一下等 3 秒是不能接受的。

FunctionGraph 提供的解法是预留实例:预先保留一定数量的运行环境常驻,请求进来直接命中,不再有初始化过程。

用不用预留实例,是个成本问题:

  • 不用:只付调用次数和运行时长,闲置零成本,但冷启动不可控
  • 用:预留实例按小时计费,相当于养了一台小服务器,换来稳定延迟

我们的策略是分级:面向 C 端的同步接口配 2 个预留实例(覆盖日常 QPS),后台管理接口和定时任务不配,接受冷启动。

另外,把初始化逻辑挪到函数外部能显著缩短冷启动时间:

# 模块级初始化:容器复用时只执行一次
import pymysql

_conn = None

def _get_conn():
    global _conn
    if _conn is None or not _conn.open:
        _conn = pymysql.connect(
            host=os.environ["DB_HOST"],
            user=os.environ["DB_USER"],
            password=os.environ["DB_PASS"],
            database=os.environ["DB_NAME"],
            connect_timeout=5,
        )
    return _conn

def handler(event, context):
    conn = _get_conn()   # 热调用时不重新建连
    ...

数据库连接写在 handler 外面,容器复用时直接省掉一次 TCP 握手加认证往返,冷启动时间能降 30% 以上。但要注意连接是有状态的,函数被回收后连接会失效,所以要做 open 检查。

六、成本对比

迁移前后,我们对比了同一批任务的花费(按中等规模估算,具体价格以官网为准):

项目 迁移前(ECS 常驻) 迁移后(FunctionGraph)
计算资源 1 台 4 核 8G ECS,全天候计费 按调用计费,月调用约 1.2 万次
运维成本 打补丁、监控、磁盘扩容,人工投入 平台托管,基本零运维
弹性能力 无,需要人工扩容 自动并发,单次批量任务可并行
失败可见性 需自己搭日志和告警 LTS 日志 + 调用失败告警,开箱即用

对于"每天只跑几分钟"这类负载,FunctionGraph 的成本优势非常明显;但如果任务是 7×24 持续高负载运行,函数计算反而可能更贵,那时候应该考虑 CCE 或者按量付费的 ECS。

判断标准很简单:看 CPU 利用率。 常年低于 20% 的负载适合迁移到函数计算,常年跑满的负载留在服务器上更划算。

七、生产落地的六条建议

  1. 一律使用版本 + 别名,不要直接把触发器绑在 $LATEST 上。上线即切换别名,回滚也只需要切回去。
  2. 用委托,不用 AK/SK。函数需要什么权限就授什么权限,拒绝为了省事授宽泛的全量权限。
  3. 日志必须结构化输出。print(json.dumps({...})),把关键字段(业务 ID、耗时、结果)打成 JSON,LTS 才能做字段检索和告警。
  4. 幂等设计。触发器可能重复投递,函数可能被执行两次。所有写操作都要能安全重入,比如用业务唯一键做 upsert,或者先查状态再写。
  5. 把重试和死信配起来。异步触发的函数失败后,平台可以按策略重试;重试仍失败的消息进死信队列,这是兜底的最后一道网。
  6. 监控指标至少盯三个:调用错误率、执行时长 P99、并发数。这三个指标异常,基本能定位 80% 的问题。

八、常见问题 Q&A

Q1:函数能读写本地磁盘吗?

只有 /tmp 可写,容量约 512 MB,且容器回收后数据消失。需要持久化的一律用 OBS 或 NAS。

Q2:函数之间怎么调用?

不推荐函数直接调函数(会引入同步耦合和超时风险)。正确做法是通过 SMN、DIS 或者事件网格解耦,一个函数干完自己的事,发个消息就走。

Q3:单个函数最长能跑多久?

15 分钟。超过这个时长的任务应该拆成多个函数串起来,或者改用批量计算服务。

Q4:怎么排查线上失败?

先看 LTS 日志里对应的请求 ID(context.getRequestID()),再根据请求 ID 检索完整调用链路。建议在函数入口第一行就把请求 ID 打出来,后面所有日志都带上它。

Q5:本地怎么调试?

FunctionGraph 提供本地运行工具,可以模拟 event 和 context。更实用的做法是把业务逻辑抽成独立函数,handler 只做参数解析和结果封装,这样 90% 的代码可以在本地单元测试里覆盖。

Q6:并发上限是多少?

默认有并发配额,需要时可以提工单提升。批量任务场景要注意,突然的并发峰值可能触达配额导致限流,建议在代码里做并发控制或者错峰。

九、总结

Serverless 不是银弹,它的价值边界很清晰:事件驱动、短时运行、负载波动大、偶发执行的任务,它就是最优解;长连接、持续高负载、需要本地状态的服务,还是老老实实用服务器。

对中小团队来说,FunctionGraph 真正的收益不在省下的那点服务器钱,而在于把"运维一个进程"这件事从待办清单里彻底划掉了。日志有人管、告警有人配、扩容不用想——这些省下来的注意力,比账单上的数字值钱得多。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。