华为云 FunctionGraph 实战:定时触发、OBS 事件驱动与冷启动优化
项目里有一台 ECS,唯一的作用是每天凌晨跑一次清理脚本,把对象存储里七天前的临时文件删掉。这台机器 4 核 8G,月账单六十多块,而它的 CPU 平均利用率是 0.3%——99% 的时间在空转,剩下 1% 里还有大半在等 IO。后来把这套逻辑搬到函数工作流上,代码量没变,账单直接归零(在免费额度内)。这篇把迁移过程中的配置细节和几个真实的坑写清楚。
一、先判断这件事该不该用函数
函数工作流不是万能的。判断标准只有一条:这段逻辑是不是"短时、偶发、无状态"。满足就用,不满足就别硬上。
| 方案 | 适合场景 | 主要代价 |
|---|---|---|
| ECS 常驻 | 长连接、本地缓存、需要固定出口 IP | 空转也要付整机钱,还要自己运维补丁 |
| CCE CronJob | 已有集群、任务较重、需要镜像环境 | 集群本身有固定成本,节点闲置照样计费 |
| 函数工作流 | 定时清理、事件回调、轻量数据转换 | 单次执行有超时上限,冷启动有延迟 |
我这次的需求是典型的第三种:每天跑一次,单次十几秒,跑完就没事。换成函数之后,没有机器要维护,也没有闲置成本。
二、定时触发:清理 OBS 临时对象
在函数工作流控制台创建函数,运行时选 Python 3.9,内存 256MB 就够,超时时间从默认的 3 秒改到 60 秒——这一点很关键,后面会讲。
依赖方面,对象存储的 SDK 需要随代码一起上传。做法是在本地建一个目录,把 SDK 装在目录里,再连代码一起打成 zip:
mkdir -p pkg && cd pkg
pip install esdk-obs-python -t .
# 代码放到根目录,然后整体压缩上传
函数本体不长,核心是把列举和删除做成翻页循环,不要一次性拉全量:
import os
from datetime import datetime, timedelta, timezone
from obs import ObsClient
BUCKET = os.environ.get('BUCKET', 'demo-temp')
PREFIX = os.environ.get('PREFIX', 'tmp/')
KEEP_DAYS = int(os.environ.get('KEEP_DAYS', '7'))
client = ObsClient(
access_key_id=os.environ['OBS_AK'],
secret_access_key=os.environ['OBS_SK'],
server=os.environ['OBS_ENDPOINT'])
def handler(event, context):
deadline = (datetime.now(timezone.utc) - timedelta(days=KEEP_DAYS)).isoformat()
scanned, deleted, marker = 0, 0, None
while True:
resp = client.listObjects(BUCKET, prefix=PREFIX,
marker=marker, max_keys=1000)
if resp.status_code >= 300:
return {'ok': False, 'msg': resp.errorMessage}
for obj in resp.body.contents:
scanned += 1
if obj.lastModified < deadline:
client.deleteObject(BUCKET, obj.key)
deleted += 1
if not resp.body.is_truncated:
break
marker = resp.body.next_marker
return {'ok': True, 'scanned': scanned, 'deleted': deleted}
几个容易忽略的点:lastModified 是 UTC 的 ISO8601 字符串,和同样格式的 deadline 直接做字符串比较就是正确的时间比较,不需要转成 datetime;翻页一定要用 is_truncated 判断,靠"返回条数小于 1000"来结束循环在并发写入的桶里会漏数据。
定时触发器填 Cron 表达式,格式是六段(秒 分 时 日 月 周),每天凌晨三点写成 0 0 3 * * ?。注意时区是 UTC,想让它在北京时间三点跑,实际要写 0 0 19 * * ?。这个坑我踩过一次,第二天早上发现任务在上午十一点执行了。
三、事件驱动:上传即处理
同一个函数还可以挂对象存储的事件触发器,比如图片上传后自动生成缩略图。触发器配置里选事件类型 ObjectCreated:Put,指定桶和前缀即可,不需要写轮询逻辑。
def handler(event, context):
for rec in event.get('Records', []):
bucket = rec['s3']['bucket']['name']
key = rec['s3']['object']['key']
etag = rec['s3']['object']['eTag']
if is_done(bucket, key, etag): # 幂等判断
continue
process(bucket, key)
return {'ok': True}
幂等必须自己做。事件通知是至少一次投递,网络抖动、函数超时重试都会让同一个对象被处理两次。最省事的做法是把 对象名 + eTag 记一张表,处理前查一下。
更要命的是递归触发。如果函数把处理结果写回同一个桶的同一个前缀,写入动作又会触发一次事件,函数再处理、再写回……几秒钟就能把调用次数刷到几十万。规避方式只有两个:结果写到不同的前缀(比如 thumb/ 对 raw/),或者干脆写到另一个桶。我在测试环境用错前缀跑出过一次,靠配额告警才发现。
四、冷启动:四个真正有效的动作
函数按需拉起,第一次调用要初始化运行环境,这就是冷启动。实测下来,Python 运行时从零到就绪在 200ms 到 2s 之间,取决于依赖体积。四个改善手段按性价比排序:
1. 把初始化逻辑挪到 handler 外面。模块级代码只在实例创建时执行一次,后续复用同一实例的调用都能省掉这段开销。SDK 客户端、数据库连接、配置读取都该放在外面——这也是上面代码里 ObsClient 写在函数体之外的原因。
2. 压缩依赖体积。SDK 和第三方库是冷启动时间的大头。只装真正用到的包,别把整个数据分析栈打进去;体积大的公共依赖做成层,多个函数共用一份。
3. 合理提高内存。函数工作流的 CPU 配额和内存是绑定的,内存给太小,初始化阶段反而慢。把 128MB 提到 512MB,单次费用略增,但执行时间缩短,总成本经常是下降的。
4. 预留实例。对延迟敏感的场景(比如同步接口回调),直接配置预留实例,让实例常驻,冷启动就消失了。代价是按预留时长计费,只适合调用频繁的函数,一天跑一次的定时任务配它纯属浪费。
五、观测与成本
函数默认把日志投递到云日志服务,每个函数一个日志组,排查时按请求 ID 检索最方便。指标面板重点看四个:调用次数、错误次数、最大运行时长、并发数。最大运行时长持续贴近超时阈值,说明该拆任务或调大超时;错误率突然抬升,多半是权限或下游限流。
计费由两部分组成:调用次数和资源消耗(内存 × 执行时长)。按这个模型,一天跑一次、每次十几秒的清理任务,成本几乎可以忽略;反过来,一个被高频触发、每次跑满 300 秒的函数,费用可能比一台小规格 ECS 还高。上线前把"每月调用次数 × 平均时长"估一遍,比事后看账单靠谱。
六、踩坑清单
最后把这次迁移遇到的问题集中列一下,都是配置层面能避开的:
超时时间默认 3 秒,涉及批量列举或大文件处理一定要提前调大,否则表现为"偶发超时";Cron 表达式按 UTC 计算,本地时间要换算;函数的返回体必须能被 JSON 序列化,返回 SDK 的响应对象会直接报错;访问其他云服务需要在函数配置里绑定委托,只给 AK/SK 在临时凭证轮换后会失效;环境变量里的密钥明文可见,敏感信息走委托或密钥管理服务更稳妥。
整件事做下来,最大的收获不是省了那六十块钱,而是把"一台机器只为了跑一个脚本"这种结构性问题消掉了。这类任务在云上很常见,先看一眼它的资源利用率,再决定要不要为它留一台机器。
- 点赞
- 收藏
- 关注作者
评论(0)