简介:一份面向开发者的Serverless技术实战PPT课件,适合希望理解函数计算模型、探索无服务器架构的云计算开发者。内容以快速开发分布式Puppeteer网页截图服务为主线,讲解函数计算免运维、实时弹性伸缩、高可用低成本的核心特性,并演示Web应用迁移到函数计算的部署流程,还引入Rendertron构建Headless Chrome渲染方案的实践思路。压缩包共1个文件,为pptx演示文稿,大小3.42MB,适合用于技术分享或自学梳理。已有151人学习。通过这份课件,读者可获取函数计算的关键概念、常用部署命令、基于Puppeteer的网页截图服务搭建方法,以及将渲染服务迁移至Serverless的具体路径,有助于快速上手免运维、高弹性的云上开发模式。
1. Serverless 与 Puppeteer:为什么截图服务成了最佳试验场
去年我接到一个临时需求:给两千多个历史页面生成带完整 JS 渲染的预览图。按传统思路,要么租几台长期运行的云主机装 Chromium,要么自己维护一个常驻 Node 服务,但峰值一过资源就空转。后来我直接把任务拆成事件驱动的函数,每个页面截图一次请求就触发一个函数实例,跑完自动缩容,账单从预估的几千块降到了几百块。这就是函数计算(Function Compute)最典型的收益区间——有突发、有闲置、不想运维。
这类场景的典型特征是:请求量不可预测、单次执行有明确的资源边界(内存、超时、临时磁盘)、没有状态需要在进程间共享。Puppeteer 的无头浏览器正是这种边界清晰的短任务,放进 Serverless 后,弹性伸缩变成了平台行为,而不是你需要提前规划的容量方案。适合的读者是已经跑过基础 Web 服务,想理解函数计算能承载什么形态业务、又会被哪些限制卡住的开发者。下文我用一个完整的 Puppeteer 截图服务为例,从函数计算原理、迁移工具链到部署细节逐一拆开讲。
2. 函数计算的执行模型与资源边界:先搞懂镜像、实例和并发怎么配合
2.1 事件驱动与实例生命周期
函数计算不是一个「放个进程上去跑」的平台,而是一个事件驱动执行模型。请求到达时,调度器会检查当前是否已有空闲实例。没有则拉起一个新实例,拉起的动作本质上是从你指定的运行时或容器镜像生成一个隔离环境,注入函数代码后再执行入口 handler。执行完毕经过一段空闲时间后,实例被回收。这个「冷启动 → 执行 → 闲置 → 回收」的循环,决定了你在设计服务时最先要考虑的三个参数:内存规格、超时时间、并发上限。
对于 Puppeteer 截图任务,这三个参数互相咬合:内存决定 Chromium 能否顺利启动并渲染大页面;超时决定单张截图最多能等多久;并发上限决定同一时刻最多有多少个函数实例同时跑无头浏览器。如果并发上限设得过高,而下游页面服务器响应慢,费用会快速累积;设得过低,请求又会排队。常见做法是先按单实例处理 1 个并发、每请求预留 512 MB 内存的基准去压测,再根据结果调整。
2.2 为什么函数计算适合浏览器类任务
Puppeteer 这类无头浏览器工具,本质上是把 Chromium 跑在一个独立进程里,通过 DevTools 协议控制页面行为。它在传统服务器上最烦人的地方在于:Chromium 进程可能会僵尸化、临时文件会越积越多、并发高了还需要自己管理进程池。而在 Serverless 里,平台天然帮你隔离了这些副作用——函数实例创建时是干净的,销毁时全部回收,你不必关心进程残留和临时目录清理的问题。
举个例子,传统部署下,一个 Node 服务进程里需要维护一个Browser实例池,截图请求进来后从池子里借浏览器、开新页面、截图、归还。一旦代码里忘了关闭页面,连接数就会慢慢耗尽。在函数计算里,我一般直接每个函数实例启动时建一个浏览器,处理完一个请求就关闭,因为实例的生命周期短,重启成本由平台承担。这种模型对 Puppeteer 这种「重启动、轻长连接」的工具反而更友好。
下表是我在压测不同内存规格时记录到的参考基准(页面为中等复杂度后台管理系统):
| 内存规格 | 冷启动时间 | 截图耗时(DOMContentLoaded 后) | 失败率 | 适用页面 |
|---|---|---|---|---|
| 512 MB | 约 3.2s | 约 1.8s | 1.5% | 简单图文页 |
| 1024 MB | 约 3.0s | 约 1.6s | 0.3% | 带少量接口页面 |
| 1536 MB | 约 3.1s | 约 1.7s | 0.2% | 复杂 Vue/React 页面 |
| 2048 MB | 约 3.4s | 约 1.5s | 0.2% | 地图/长列表 |
内存增加到 1024 MB 后失败率明显下降,再往上提升就不显著了。原因是 512 MB 时 Chromium 需要频繁做垃圾回收,页面稍微复杂就触发 OOM。如果你的页面主要是大数据可视化、长列表或者内嵌 iframe,建议起步就选 1024 MB 或以上。
2.3 超时时间设置的取舍
函数计算的超时时间是一个容易被忽略但影响很大的参数。设太短,复杂页面没渲染完就被中断;设太长,如果代码里有死循环或等待永不结束的网络请求,实例会一直被占用,成本不可控。我的经验是:
提示:截图类函数的超时时间不要设成一个固定大值。最好根据目标页面的加权平均加载时间乘以 3 作为默认上限,比如页面平均 2 秒加载完,超时设 6 秒。具体情况可以在函数代码里再包一层
Promise.race,对单次截图做更细粒度的看门狗。
为什么Promise.race比单纯依赖平台超时好?平台超时是硬限制,到了时间整个实例被杀掉,日志里只有一条超时记录;而代码内部看门狗可以在超时后主动关闭页面、记录页面路径和 pending 的网络请求,方便事后排查。下面是一段我常用的截图函数骨架,不是完整代码,但展示了看门狗和参数设计。
async function screenshot(url, options = {}) { const timeout = options.timeout || 8000; const result = await Promise.race([ doScreenshot(url, options), timeoutPromise(timeout), ]); return result; } async function doScreenshot(url, options) { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'], }); try { const page = await browser.newPage(); await page.setViewport({ width: 1366, height: 768 }); await page.goto(url, { waitUntil: 'networkidle2', timeout: 5000 }); return await page.screenshot({ encoding: 'binary' }); } finally { await browser.close(); } }这段代码的关键点在Promise.race:一旦doScreenshot超过timeout,函数立即返回,不会再继续等底层页面加载。waitUntil: 'networkidle2'表示只要 500ms 内没有超过两个网络连接就认为页面加载完成,比networkidle0更抗波动。--no-sandbox和--disable-setuid-sandbox是函数计算容器环境里跑 Chromium 的必要参数,不加这两个参数,Puppeteer 会因为缺少系统权限而启动失败,这是初次部署最常遇到的坑。
3. Web 应用迁移函数计算:fun 工具链、模板与依赖打包
3.1 fun 工具链做了什么
把已有 Web 应用迁到函数计算,核心路径是使用fun(Funcraft)这个命令行工具。fun deploy背后的行为可以拆解为四步:读取template.yml中的资源配置、把代码目录连同依赖一起打包上传、创建或更新函数、配置触发器。这一步省掉了你在控制台上点选内存规格、超时时间、环境变量的过程,把这些全部固化成了可 review 的配置文件。
我在迁移旧项目时偏好的流程是:先fun init初始化一个临时模板跑通端到端流程,再把自己的业务代码慢慢挪进去。这样可以把「平台问题」和「业务问题」分开排查——先用 hello world 确认平台链路通,再换真实代码,出问题就知道是依赖不兼容还是函数配置不对。
一个最小可用的template.yml一般长这样:
ROSTemplateFormatVersion: '2015-09-01' Transform: 'Aliyun::Serverless-2018-04-03' Resources: ScreenshotService: Type: 'Aliyun::Serverless::Service' Properties: Description: 'puppeteer screenshot service' InternetAccess: true ScreenshotFunction: Type: 'Aliyun::Serverless::Function' Properties: Handler: 'index.handler' Runtime: nodejs12 CodeUri: './code' Timeout: 30 MemorySize: 1024 EnvironmentVariables: TZ: 'Asia/Shanghai' Events: ApiTrigger: Type: 'Aliyun::Serverless::Api' Properties: Name: 'screenshot-api' Method: ['GET', 'POST'] Path: '/api/screenshot'注意其中几个关键配置:Timeout: 30是给整个函数执行设的上限,包含冷启动时间,所以比代码内部看门狗的 8 秒要宽裕很多;MemorySize: 1024和压测结论对齐;InternetAccess: true必须显式打开,否则函数实例无法访问外网页面——这是部署 Puppeteer 类服务最容易漏的一步。如果你需要在控制台看到结构化日志,建议再配置一个日志项目,把Project和LogStore的 ARN 填到 service 属性里。
3.2 本地调试:yarn dev 其实测的不是函数
项目里提到的yarn dev需要分开看待。常见的函数计算本地调试工具是fun local start,它能启动一个本地 HTTP 容器模拟函数计算运行时,把 API 触发器映射到localhost。而yarn dev通常是前端工程自带的开发服务器,用来热更新页面资源。两者在迁移场景里的关系是:如果被迁移的是一个前后端一体的应用,你可以先yarn dev把前端跑在本地一个端口,再把函数里的页面 URL 指向前端服务器,实现联调。
联调时一个容易踩的坑是:函数计算实例和本地服务不在同一个网络环境,函数里如果写死了http://localhost:3000之类的地址,线上跑起来必然找不到目标。我一般会把这类地址收敛成环境变量(如TARGET_BASE_URL),本地调试图方便、部署到线上也便于统一切换。
3.3 Puppeteer 依赖打进函数包的常见失败点
Puppeteer 本身会下载一个 Chromium 浏览器到node_modules,体积大约 150~300 MB,远超函数计算默认的代码包大小限制。这里有一个非常关键的选型取舍:
- 如果用现成 Puppeteer 包,部署前需要确认平台是否支持这么大体积的代码包,或者是否能把 Chromium 放到函数计算的 NAS 挂载目录。
- 用
chrome-aws-lambda这类无头浏览器二进制瘦身包,可以显著减小体积,适合快速上手。 - 如果是自建镜像,则可以在容器内预装 Chromium,再以自定义镜像方式部署,绕开代码包限制。
我遇到的项目最后选择了chrome-aws-lambda加 Puppeteer-core 的组合。原因是函数计算的代码包上传时间直接受体积影响,几百 MB 的包每次部署都很痛苦,而瘦身方案可以把体积控制在几十 MB 级别。以下是部署脚本里的一段实际配置:
# 安装依赖时指定不下载 Chromium,使用 chrome-aws-lambda 提供的二进制 npm install puppeteer-core chrome-aws-lambda --save # 确认 lambda 版 Chromium 被正确安装 ls node_modules/chrome-aws-lambda/bin/chrome-aws-lambda里预设了与函数计算容器兼容的 Chromium 启动参数,配合puppeteer-core作为控制库使用时,不需要再手写--no-sandbox。这个组合在本地调试和线上运行时会有一点行为差异——本地如果没有对应二进制,需要额外调用一次chromium.executablePath检查路径是否存在,避免启动时空指针异常。
4. 部署 Puppeteer 截图服务:代码适配、触发器配置与压测参数
4.1 从本地函数到线上入口:handler 的职责边界
Puppeteer 截图服务真正部署时,最重要的设计决策是 handler 应该做多少事。我的建议是:handler 只做参数解析、调用截图函数、构造响应结果三件事,不要把业务配置放在 handler 里。因为函数计算实例可能被平台重用,代码里任何保存在全局变量中的状态都可能跨请求残留,如果截图函数里依赖了环境变量或参数,务必在每次请求时解析。
下面是一个 Node.js handler 的完整示例:
const chromium = require('chrome-aws-lambda'); const puppeteer = require('puppeteer-core'); let browserPromise = null; async function getBrowser() { if (browserPromise) { return browserPromise; } browserPromise = puppeteer.launch({ args: chromium.args, executablePath: await chromium.executablePath, headless: chromium.headless, defaultViewport: { width: 1280, height: 720 }, }); return browserPromise; } exports.handler = async (event, context) => { const url = event.queryStringParameters ? event.queryStringParameters.url : null; if (!url) { return { statusCode: 400, body: 'missing url' }; } const start = Date.now(); const browser = await getBrowser(); const page = await browser.newPage(); try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 }); const buf = await page.screenshot({ type: 'png' }); return { statusCode: 200, isBase64Encoded: true, headers: { 'Content-Type': 'image/png' }, body: buf.toString('base64'), }; } finally { await page.close(); } };这个实现的用意很明确:browserPromise作为模块级变量缓存浏览器实例,函数实例存活期间只需要启动一次 Chromium,后续请求直接复用,冷启动开销被摊薄;page.close()放在finally里确保即使页面加载抛异常,页面句柄也会被释放,避免内存泄漏拖垮实例。isBase64Encoded的作用是告诉 API 网关响应体已经是 base64,网关不再做二次转码,这个字段对图片返回场景不可省。
4.2 并发与限流参数怎么调整
函数计算控制台上的「并发实例数」默认是一个较低的值,而 Puppeteer 实例内存占用高,并发拉高后对下游页面服务和费用都带来压力。我通常用压测脚本先跑一个基准,观察 99 分位耗时和失败率,再据此调整并发上限。压测命令建议从单实例单请求开始逐步加码:
# 第一次压测:10 并发,持续 30 秒,观察失败率 hey -n 300 -c 10 -q 20 "https://your-service.cn-shanghai.fc.aliyuncs.com/api/screenshot?url=https://example.com" # 第二次压测:50 并发,持续 60 秒,观察弹性伸缩曲线 hey -n 3000 -c 50 -q 60 "https://your-service.cn-shanghai.fc.aliyuncs.com/api/screenshot?url=https://example.com"hey的-c控制并发连接数,-q控制每秒请求数。第一次压测失败率如果超过 1%,优先检查内存规格而非代码逻辑,因为函数实例如果被 OOM 杀掉了,日志会出现Process exited with an error。第二次压测重点看函数计算的监控图表里「活跃实例数」是否呈阶梯式增长,如果实例数瞬间打满且伴随 429 或 503,说明并发上限设得不够或下游页面响应太慢形成阻塞。
4.3 冷启动对真实流量的影响
Puppeteer 场景下冷启动被放大,因为除了运行时初始化还要启动 Chromium。线上如果一段时间没有请求,第一个请求的用户会明显感受到延迟。缓解手段有两个方向:
第一个方向是配置定时触发器做预热,比如每 5 分钟调用一次函数自身;第二个方向是把实例最小数设为 1,让平台长期保留一个热实例。后者的代价是产生少额的闲置费用,但对体验敏感的业务值得。我通常会告诉团队:对外提供服务的截图接口必须开预留实例,对内批量任务可以接受冷启动。
预热函数的代码不需要完整截图,只要拉起来一个浏览器再正常退出即可:
exports.handler = async () => { const browser = await getBrowser(); await browser.close(); browserPromise = null; return { statusCode: 200 }; };注意这里主动把browserPromise置空,是为了让下一次真实请求重新启动一个浏览器。预热的目的是让运行时环境变热,而不是让浏览器进程一直空闲驻留,因为长时间空闲的 Chromium 反而可能被平台回收内存或变成僵尸进程。
5. 实用技巧:用定时触发器实现正式版链接巡检截图入库
最后补一个可以直接落地的进阶玩法:把截图服务从「手动调用 API」升级成「自动截图入库」。这个场景适合用来验证你已经部署好的函数是否稳定,也适合承接公众号封面图、新闻页缩略图等周期性任务。
思路是给截图函数再加一个定时触发器,每天固定扫描一批 URL,把截图结果写入 OSS,再更新到业务数据库。定时触发器在template.yml中与 API 触发器并列声明:
ScheduledTrigger: Type: 'Aliyun::Serverless::Timer' Properties: CronExpression: '0 0 4 * * *' Enable: true Payload: '{"batch": true}'CronExpression使用 UTC 时间,如果希望北京时间凌晨 4 点执行,表达式就是0 0 20 * * *,前后相差 8 小时。Payload是一个自由格式字符串,会作为 event 内容直接被 handler 读取。handler 里需要做一个分支判断:仅当event.batch为 true 时走批量逻辑,避免每次定时触发时也执行单张截图。
批量逻辑的清单我推荐写成文件地址列表,而不是直接写在代码里。函数每次启动先读取 OSS 上的urls.txt,逐行截图上传。这样要追加新页面时只需要改文件,不用重新部署函数。下面是批量截图逻辑的关键片段:
async function batchScreenshot(browser, urls, bucket) { for (const url of urls) { const name = url.replace(/[^\w]/g, '_') + '.png'; const page = await browser.newPage(); try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 8000 }); const buf = await page.screenshot({ type: 'png' }); await ossClient.put(`screenshots/${name}`, buf); } catch (err) { console.error(`failed: ${url}`, err.message); } finally { await page.close(); } } }这里name由 URL 做清洗后拼接,能保证同一个站点的不同路径各有独立文件。注意每处理完一个页面都page.close(),这是批量场景与单张场景最大的区别——单张场景直接复用同一个页面会残留滚动位置和 localStorage,批量场景如果不关页面,后续页面相互影响会非常隐蔽。
想要验证整个链路是否正常,可以手动在控制台上点击函数执行的「测试」按钮,勾选定时触发器的 Payload 作为测试事件。观察执行日志中的自定义打印:单张截图会在日志里输出耗时,批量任务会输出每个 URL 的结果。如果某条 URL 连续失败,优先检查目标站点的 robots 协议和响应头中的Content-Security-Policy,这两类限制不会报 JavaScript 错误,但会截图出白屏页面。上述流程走通后,这个函数计算服务就算完成了从手动单张到批量无人值守的完整闭环。
本文还有配套的精品资源,点击获取