华为云 FunctionGraph:Serverless 架构从入门到生产实践
一、问题的起点:脚本时代的运维黑洞
我们的原始架构非常朴素:一台 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% 的负载适合迁移到函数计算,常年跑满的负载留在服务器上更划算。
七、生产落地的六条建议
- 一律使用版本 + 别名,不要直接把触发器绑在
$LATEST上。上线即切换别名,回滚也只需要切回去。 - 用委托,不用 AK/SK。函数需要什么权限就授什么权限,拒绝为了省事授宽泛的全量权限。
- 日志必须结构化输出。
print(json.dumps({...})),把关键字段(业务 ID、耗时、结果)打成 JSON,LTS 才能做字段检索和告警。 - 幂等设计。触发器可能重复投递,函数可能被执行两次。所有写操作都要能安全重入,比如用业务唯一键做 upsert,或者先查状态再写。
- 把重试和死信配起来。异步触发的函数失败后,平台可以按策略重试;重试仍失败的消息进死信队列,这是兜底的最后一道网。
- 监控指标至少盯三个:调用错误率、执行时长 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 真正的收益不在省下的那点服务器钱,而在于把"运维一个进程"这件事从待办清单里彻底划掉了。日志有人管、告警有人配、扩容不用想——这些省下来的注意力,比账单上的数字值钱得多。
- 点赞
- 收藏
- 关注作者
评论(0)