Netlify Function 冷启动 10 秒超时 —— 排查与修复

Netlify Function 本地正常,但闲置后首次请求返回 502 报 'Task timed out after 10.00 seconds',几乎都是冷启动初始化太重或上游 DNS 问题。

某个 Netlify Function 本地跑 200 毫秒、热调用 300 毫秒,但闲置一段时间后首次请求返回 502,日志里写着 Task timed out after 10.00 seconds。一分钟内再请求,同样的代码秒回。函数本身并不慢,慢的是冷启动开销,而这个开销超过了 Netlify 同步函数默认的 10 秒上限。原因几乎都是模块顶层引入太重(庞大的 SDK、Prisma 客户端、OpenAI/Anthropic 客户端做 DNS 预热),顶层 await 卡在慢上游,或者依赖里被偷偷塞进了 aws-sdk v2。

常见原因

按 Node 18/20 同步 Netlify Function 的实际频率排序。

1. 顶层引入太重,冷启动全部执行

文件顶部每一个 import 都会在 init 阶段执行,handler 还没开始就先把这些跑完。一个 import { PrismaClient } from '@prisma/client'import OpenAI from 'openai' 就能把冷启动拖长 2-6 秒,因为这些 SDK 会立刻做 DNS 解析、加载大型 JSON、预热 TLS。

如何识别:在文件的第一行加 console.time('init'),导出 handler 之前加 console.timeEnd('init')。如果打印的时间 ≥ 4 秒,问题就在 init。

2. 顶层 await 卡在慢上游或不可达的远端

const config = await fetch(CONFIG_URL).then(r => r.json()) 这种放在模块作用域的 await 会阻塞 init,直到上游返回。如果上游跨区域或挂了,冷启动会把整 10 秒都耗在这里。

如何识别:搜索函数体之外的 await,注释掉重新部署。如果冷启动降到 2 秒以下,就是它。

3. @netlify/functions 同步上限 vs 后台函数

标准 Netlify Function 总执行(含 init)上限是 10 秒。如果你的函数热调用本来就要 12-20 秒,那它根本不该是同步函数,应该放到后台函数(文件名后缀 -background.ts,15 分钟上限)或边缘函数(init 50 毫秒以内)。

如何识别:函数做的就是真重活,热的时候也要 8-15 秒。这不是冷启动问题,是 runtime 选错了。

4. 依赖里夹带了 aws-sdk v2

某些老库(Mailgun SDK、部分分析 SDK)会牵连 aws-sdk v2 —— 一个 50 MB 的巨无霸,冷启动时仅解析就要 3-5 秒。Netlify 打包器并不一定能把它 tree-shake 掉。

如何识别ls -lah .netlify/functions-internal/<fn>/ 看打包后的体积,或者 du -sh node_modules/aws-sdk。打包后超过 10 MB 基本就是它。

5. SDK 内部 DNS 解析卡住

OpenAI、Anthropic、Stripe、Twilio 这些 SDK 第一次发请求时会建立 HTTPS 连接。如果函数所在区域出站 DNS 解析 api.openai.com 之类的域名比较慢(Netlify 某些区域偶发问题),冷启动后的第一次请求会卡 4-8 秒。

如何识别:在第一次调用 SDK 的地方包一层 console.time('first-api-call'),比较冷调用和热调用的差异。

6. 同步读取打进 bundle 的大文件

模块作用域里写 fs.readFileSync('./prompts.json'),文件 5 MB 以上,Lambda 冷缓存下要几百毫秒,叠加其他 init 开销就会把预算打穿。

如何识别:在 handler 之外 grep readFileSync。把大文件读移进 handler 并加模块级缓存。

7. 函数区域和数据上游不在同一区域

函数默认跑在 us-east-1,如果你的 Postgres / Redis / KV 在 eu-west-1,每次冷调用每条 init 查询都要付 100-150 毫秒的 RTT,几条下来就上 1 秒。

如何识别:对比 netlify.toml[functions]regions 和数据库所在区域。

开始排查前

  • 先确认是冷启动问题:请求一次,等 15-20 分钟,再请求。若只有间隔后那次首发超时,那就是冷启动。
  • 确认确切的 Node runtime(Node 18 还是 Node 20,冷启动相差 ~300 毫秒)。
  • 抓一条失败的函数日志,包含 Task timed out 行和 request ID。
  • 弄清楚函数类型:同步、定时、还是后台。各自上限不同。

需要收集的信息

  • netlify.toml[functions][build] 段。
  • 函数文件顶部的 import 列表。
  • 打包体积:ls -lah .netlify/functions-internal/<fn>/
  • package.json 依赖以及任何 peer/transitive 的 aws-sdk
  • 失败 request ID 附近的函数日志(init 时间 + handler 时间)。
  • 函数区域 vs 上游(DB、KV、API)区域。

分步修复

按性价比从高到低排序。

步骤 1:测量 init 阶段耗时

在函数文件最顶部插入:

const __init = Date.now();
import OpenAI from "openai";
// ...其他 import
console.log(`[init] imports done in ${Date.now() - __init}ms`);

export const handler = async (event) => {
  const __handler = Date.now();
  // ...
  console.log(`[handler] done in ${Date.now() - __handler}ms`);
};

部署后等 20 分钟再请求一次。[init] 那行如果超过 3 秒,问题就是 import 太重;如果 init 没问题但 handler 慢,那是另一类问题。

步骤 2:把重型 SDK 改成懒加载

把顶层 import 改成首次使用时再 import()

let _openai: import("openai").OpenAI | null = null;
async function getOpenAI() {
  if (!_openai) {
    const { default: OpenAI } = await import("openai");
    _openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
  }
  return _openai;
}

export const handler = async (event) => {
  const openai = await getOpenAI();
  // ...
};

handler 第一次会付 import 代价,但 init 从 4-5 秒降到 500 毫秒以内。热调用代价为零。

步骤 3:把 aws-sdk v2 从 bundle 里剔除

先找出谁拖进来的:

npm ls aws-sdk

替换成模块化的 v3 客户端,或换更轻量的库:

npm uninstall mailgun-js
npm install mailgun.js form-data

确认 bundle 缩小:

netlify build
du -sh .netlify/functions-internal/<fn>/

通常从 30-60 MB 降到 5 MB 以内,冷启动随之下降 2-3 秒。

步骤 4:把长任务搬到后台函数

如果真正的耗时在 handler 业务:

netlify/functions/process-upload.ts          → 10 秒上限
netlify/functions/process-upload-background.ts → 15 分钟上限

文件名带 -background 后缀就触发后台模式。调用立刻返回 202,函数继续跑。配一个状态接口 + KV 让客户端轮询。同类思路可参考 edge function timeout 在 Vercel 端的处理。

步骤 5:把函数区域对齐到数据上游

netlify.toml

[functions]
  node_bundler = "esbuild"

[functions."*"]
  # 与主 DB / KV 区域对齐
  preferred_region = "us-east-2"

重新部署。冷启动 handler 延迟会按 RTT * init 查询条数下降。

步骤 6:保持 SDK 客户端在模块作用域但避免构造时做活儿

如果 init 体积可以接受,但希望热调用极快,可以把客户端留在模块作用域,但不要在构造时调任何远端:

import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// 不要在顶层写 `await openai.models.list()` —— 每次冷启动都会跑。

export const handler = async () => {
  // 第一次真正发请求放这里,不要放 init。
  const r = await openai.chat.completions.create({ /* ... */ });
};

步骤 7:定时 ping 保温(最后手段)

如果是面向用户的接口、冷启动延迟无法接受,用 Netlify 定时函数每 5 分钟打一次:

// netlify/functions/warm-up.ts
import type { Config } from "@netlify/functions";
export default async () => {
  await fetch(`${process.env.URL}/.netlify/functions/<critical-fn>?warmup=1`);
};
export const config: Config = { schedule: "*/5 * * * *" };

目标函数里判断 event.queryStringParameters?.warmup === "1",立刻 200 返回,不做真实业务。

验证

  • 冷启动闭环:部署后等 20 分钟再请求,整体响应在 4 秒以内。
  • 日志里看到 [init] imports done in <1500ms,没有 Task timed out
  • 60 秒内连续请求(热调用)500 毫秒以内。
  • du -sh .netlify/functions-internal/<fn>/ 显示 bundle 在 10 MB 以下。

长期预防

  • serverless 函数严禁在模块顶层写 await
  • 任何 200 kB 以上的 SDK 默认走 import() 懒加载,init 阶段保持轻。
  • 每次依赖变更后跑 npm ls aws-sdk,出现 v2 当作发布阻塞。
  • 立一条硬规则:热调用就要 5 秒以上的函数走后台或边缘 runtime。
  • CI 加一道冷启动探针:部署 preview,等 15 分钟,请求接口,超过 5 秒就 fail。
  • 函数日志接到 log drain,便于历史性 grep Task timed out,不只是看实时。

常见坑

  • 以为是”代码慢”,结果一通重写 handler,但 90% 时间都耗在 init。
  • 提高函数内存以为能救冷启动;对 50 MB 的 bundle 来说只是缓解。
  • 用后台函数处理需要同步返回的请求 —— 客户端只收到 202,拿不到结果。
  • 忘了 netlify dev 是常驻进程,冷启动 bug 只在生产环境才会出现。
  • 把保温 ping 当作主要修复方案而忽视 40 MB bundle —— 账单翻倍,而 ping 一停问题立刻复现。

常见问答

Q: 能不能把同步 Netlify Function 的 10 秒上限调大?

不行,这是平台所有套餐共通的硬上限。出路是后台函数(15 分钟)、边缘函数(init 50 毫秒但执行也只有 50 毫秒),或者把慢任务从请求链路移走。

Q: 我函数 bundle 才 2 MB,为什么冷启动还要 6 秒?

体积只是一个因素。顶层 await、SDK 构造里的活儿、TLS 握手到上游往往才是大头。用 Date.now() 打点对比 import vs handler 时间,多数团队会发现 init 占了 80% 以上。

Q: Node 20 比 Node 18 冷启动快吗?

略快,新容器上一般快 100-300 毫秒。救不了 12 秒的 init。先把 init 重量降下来,runtime 版本只是误差。

Q: Vercel Functions 会不会有同样问题?

会,只是上限不一样。参考 Vercel build failedVercel 500 errors 看 Vercel 端的对应处理;懒加载和减小 bundle 这两招完全通用。

标签: #排查 #netlify #serverless #cold-start #timeout