Сборка Lambda-функций и serverless

В средах serverless каждая миллисекунда запуска влияет на стоимость выполнения и пользовательский опыт. Для функций, работающих в таких платформах, как AWS Lambda, Azure Functions, Google Cloud Functions и других аналогичных сервисах, размер итогового пакета и скорость инициализации приобретают критическое значение.

Esbuild хорошо подходит для подобных задач благодаря нескольким характеристикам:

  • чрезвычайно высокой скорости сборки;
  • эффективному удалению неиспользуемого кода;
  • минимизации количества файлов развертывания;
  • поддержке современных стандартов JavaScript и TypeScript;
  • возможности объединения зависимостей в единый файл.

В отличие от клиентских приложений, где часто используются сложные пайплайны обработки ресурсов, серверные функции обычно требуют максимально компактного и быстрого пакета.


Почему размер Lambda-функции имеет значение

Каждый запуск функции проходит несколько этапов:

  1. Загрузка пакета.
  2. Распаковка файлов.
  3. Инициализация среды выполнения.
  4. Загрузка зависимостей.
  5. Выполнение пользовательского кода.

Чем больше размер проекта и количество подключаемых модулей, тем дольше выполняется этап инициализации.

Например, функция может использовать всего одну утилиту из большой библиотеки:

import { debounce } from "lodash";

Без оптимизации в пакет может попасть значительная часть библиотеки. При использовании Esbuild и корректно настроенного tree shaking в итоговую сборку войдет только реально используемый код.


Базовая сборка Lambda-функции

Пример простой функции:

export async function handler(event) {
    return {
        statusCode: 200,
        body: JSON.stringify({
            message: "Hello Lambda"
        })
    };
}

Файл:

src/handler.js

Конфигурация сборки:

const esbuild = require("esbuild");

esbuild.build({
    entryPoints: ["src/handler.js"],
    bundle: true,
    platform: "node",
    target: "node20",
    outfile: "dist/handler.js"
});

После выполнения:

node build.js

получается единый файл:

dist/
 └─ handler.js

Именно этот файл обычно загружается в облачную платформу.


Использование параметра platform

Для Lambda-функций необходимо указывать:

platform: "node"

Пример:

esbuild.build({
    entryPoints: ["src/index.ts"],
    bundle: true,
    platform: "node",
    outfile: "dist/index.js"
});

Это влияет на:

  • обработку встроенных модулей Node.js;
  • генерацию совместимого кода;
  • разрешение зависимостей;
  • tree shaking серверных библиотек.

Без данной настройки Esbuild может использовать правила, предназначенные для браузеров.


Настройка target для различных версий Node.js

Каждая среда выполнения Lambda поддерживает конкретную версию Node.js.

Для Node.js 18:

target: "node18"

Для Node.js 20:

target: "node20"

Полный пример:

esbuild.build({
    entryPoints: ["src/handler.ts"],
    bundle: true,
    platform: "node",
    target: "node20",
    outfile: "dist/handler.js"
});

Чем новее версия среды, тем меньше транспиляции требуется выполнять.


Объединение зависимостей в единый файл

Одной из главных возможностей Esbuild для serverless является bundling.

Исходный код:

import axios from "axios";
import { v4 as uuid } from "uuid";

export async function handler() {
    const id = uuid();

    const response = await axios.get(
        "https://api.example.com"
    );

    return {
        id,
        data: response.data
    };
}

Сборка:

esbuild.build({
    entryPoints: ["src/handler.js"],
    bundle: true,
    platform: "node",
    outfile: "dist/handler.js"
});

В результате:

src/
node_modules/

↓

dist/
 └─ handler.js

Все необходимые зависимости окажутся внутри итогового файла.


Tree Shaking для уменьшения размера функций

Рассмотрим библиотеку:

import {
    functionA,
    functionB,
    functionC
} from "./utils.js";

functionA();

Если используются только необходимые экспорты, Esbuild исключит лишний код:

functionB();
functionC();

не попадут в итоговую сборку.

Для Lambda-функций это особенно важно, поскольку уменьшает:

  • размер архива;
  • время загрузки;
  • время cold start.

Минификация serverless-кода

Дополнительное уменьшение размера достигается минификацией.

esbuild.build({
    entryPoints: ["src/handler.js"],
    bundle: true,
    minify: true,
    platform: "node",
    outfile: "dist/handler.js"
});

Минификация:

  • сокращает имена локальных переменных;
  • удаляет пробелы;
  • удаляет комментарии;
  • оптимизирует выражения.

Для production-развертываний практически всегда используется:

minify: true

Генерация source maps

Во время отладки полезно сохранять карты исходников.

esbuild.build({
    entryPoints: ["src/handler.ts"],
    bundle: true,
    sourcemap: true,
    outfile: "dist/handler.js"
});

Результат:

dist/
 ├─ handler.js
 └─ handler.js.map

Ошибки в облаке будут ссылаться на исходный TypeScript-код вместо сгенерированного JavaScript.


Работа с TypeScript в Lambda

Исходный код:

interface User {
    id: string;
    name: string;
}

export async function handler(): Promise<User> {
    return {
        id: "1",
        name: "Alex"
    };
}

Сборка:

esbuild.build({
    entryPoints: ["src/handler.ts"],
    bundle: true,
    platform: "node",
    target: "node20",
    outfile: "dist/handler.js"
});

Esbuild автоматически:

  • удаляет типы;
  • компилирует TypeScript;
  • объединяет зависимости;
  • генерирует исполняемый JavaScript.

Исключение внешних зависимостей

Иногда пакет не требуется включать в сборку.

Например:

external: ["aws-sdk"]

Конфигурация:

esbuild.build({
    entryPoints: ["src/handler.js"],
    bundle: true,
    external: ["aws-sdk"],
    outfile: "dist/handler.js"
});

Импорт останется внешним:

require("aws-sdk");

Такой подход полезен, когда библиотека уже присутствует в среде выполнения или поставляется через Lambda Layer.


Использование Lambda Layers

Крупные зависимости часто выносятся в отдельные слои.

Структура:

layer/
 └─ nodejs/
     └─ node_modules/
         └─ sharp/

function/
 └─ handler.js

Конфигурация:

external: ["sharp"]

В этом случае:

  • пакет sharp отсутствует в архиве функции;
  • библиотека загружается из слоя;
  • размер функции значительно уменьшается.

Создание нескольких Lambda-функций

Типичный проект может содержать десятки обработчиков.

Структура:

src/
 ├─ users.js
 ├─ orders.js
 └─ products.js

Сборка:

esbuild.build({
    entryPoints: [
        "src/users.js",
        "src/orders.js",
        "src/products.js"
    ],
    bundle: true,
    platform: "node",
    outdir: "dist"
});

Результат:

dist/
 ├─ users.js
 ├─ orders.js
 └─ products.js

Каждая функция собирается отдельно.


Разделение общих зависимостей

При большом количестве функций возникает проблема дублирования кода.

Пример:

import axios from "axios";

используется в десяти обработчиках.

Каждая сборка будет содержать собственную копию axios.

Для оптимизации применяются:

  • Lambda Layers;
  • монолитная функция;
  • внешние зависимости через external;
  • общие контейнерные образы.

Использование формата ESM

Современные версии Lambda поддерживают ES Modules.

Исходный код:

export async function handler() {
    return {
        statusCode: 200
    };
}

Сборка:

esbuild.build({
    entryPoints: ["src/handler.js"],
    bundle: true,
    format: "esm",
    platform: "node",
    outfile: "dist/handler.mjs"
});

Полученный файл:

export async function handler() {
    return {
        statusCode: 200
    };
}

Это позволяет использовать современный синтаксис модулей без преобразования в CommonJS.


Настройка имени обработчика

При использовании CommonJS:

exports.handler = async function () {
};

или

module.exports.handler = async function () {
};

Для ESM:

export async function handler() {
}

Настройка Lambda обычно выглядит так:

handler.handler

где:

  • первый handler — имя файла;
  • второй handler — экспортируемая функция.

Сборка через CLI

Вместо API можно использовать командную строку.

esbuild src/handler.ts \
  --bundle \
  --platform=node \
  --target=node20 \
  --outfile=dist/handler.js

С минификацией:

esbuild src/handler.ts \
  --bundle \
  --minify \
  --platform=node \
  --target=node20 \
  --outfile=dist/handler.js

Подобный подход часто используется в CI/CD-конвейерах.


Использование метафайлов для анализа сборки

Esbuild способен создавать подробную статистику.

esbuild.build({
    entryPoints: ["src/handler.js"],
    bundle: true,
    metafile: true,
    outfile: "dist/handler.js"
});

Получение данных:

const result = await esbuild.build({
    bundle: true,
    metafile: true,
    write: false,
    entryPoints: ["src/handler.js"]
});

console.log(result.metafile);

Метафайл помогает определить:

  • самые тяжелые зависимости;
  • дублирование кода;
  • причины увеличения размера сборки.

Оптимизация cold start

Cold start представляет собой первый запуск функции после создания нового контейнера.

На скорость cold start влияют:

  • объем зависимостей;
  • размер итогового файла;
  • количество импортов;
  • сложность инициализации приложения.

Практики оптимизации с использованием Esbuild:

esbuild.build({
    bundle: true,
    minify: true,
    treeShaking: true,
    platform: "node",
    target: "node20"
});

Дополнительно рекомендуется:

  • исключать неиспользуемые библиотеки;
  • избегать тяжелых ORM в простых функциях;
  • переносить большие пакеты в Layers;
  • использовать современные версии Node.js.

Интеграция с Serverless Framework

Пример конфигурации:

service: demo

provider:
  name: aws
  runtime: nodejs20.x

functions:
  users:
    handler: dist/users.handler

Сценарий сборки:

{
  "scripts": {
    "build": "node build.js",
    "deploy": "serverless deploy"
  }
}

Последовательность:

npm run build
npm run deploy

Esbuild формирует оптимизированные артефакты, которые затем загружаются через Serverless Framework.


Интеграция с AWS SAM

Структура проекта:

src/
template.yaml
build.js

Сборка:

node build.js

Развертывание:

sam deploy

Полученные файлы из каталога dist используются как исходники для публикации функции.


Использование Docker-образов для serverless

Некоторые функции поставляются как контейнеры.

Сборка Esbuild:

esbuild.build({
    entryPoints: ["src/index.ts"],
    bundle: true,
    minify: true,
    platform: "node",
    outfile: "dist/index.js"
});

Dockerfile:

FROM node:20

COPY dist/index.js .

CMD ["node", "index.js"]

Даже при использовании контейнеров Esbuild остается полезным инструментом, поскольку существенно сокращает размер приложения и ускоряет запуск контейнера.


Автоматизация сборки нескольких функций

Пример сценария:

const esbuild = require("esbuild");

const handlers = [
    "users",
    "orders",
    "products"
];

Promise.all(
    handlers.map(name =>
        esbuild.build({
            entryPoints: [`src/${name}.ts`],
            bundle: true,
            minify: true,
            platform: "node",
            target: "node20",
            outfile: `dist/${name}.js`
        })
    )
);

Подобный подход широко применяется в крупных serverless-проектах, содержащих десятки и сотни функций, каждая из которых получает отдельный оптимизированный пакет с минимальным размером и быстрым временем инициализации.