Розгортання функції NodeJS Lambda з внутрішніми залежностями за допомогою Docker

Огляд

Під час міграції одного з моїх додатків у Monorepo (за допомогою Turborepo) я стикнувся з багатьма проблемами, пов'язаними з міграцією мого AWS стеку через внутрішні пакети, які були залежностями для lambda функцій. Я не зміг знайти багато ресурсів в Інтернеті з цього питання, тому вирішив написати цю статтю, щоб допомогти тим, хто може потрапити в подібну ситуацію.

Моя основна мета полягала в наступному: Структура monorepo, де я можу мати більше ніж один додаток, у моєму випадку один із яких є SAM додатком. Я хочу написати lambda функцію з використанням TypeScript, а збірка повинна генерувати JavaScript. Моя lambda функція має внутрішню залежність, написану також на TypeScript, і збірка повинна генерувати JavaScript для цієї залежності. Я все ще хочу мати можливість встановлювати та будувати код локально. Я хочу мати змогу використовувати AWS CLI для збірки та деплою, без створення кастомних збірок або управління директорією .aws-sam. Коли я виконую команду sam local invoke, я маю отримати наступний вихід:

pic

Вимоги:

  1. Docker (я використовую Orbstack)
  2. aws-cli інструмент
  3. aws-sam-cli інструмент

Огляд репозиторію

Мій репозиторій містить 2 папки: sam-app та test-dep. Нижче наведена структура директорій:

my-dir/  
 sam-app/   
 hello-world/  
 src/  
 index.ts  
 .dockerignore  
 Dockerfile  
 package.json  
 pnpm-lock.yaml  
 tsconfig.json  
 README.md  
 samconfig.toml  
 template.yaml  
 test-dep/  
 src/  
 index.ts  
 package.json  
 pnpm-lock.yaml  
 tsconfig.json

sam-app — це простий Serverless Application Model (SAM) додаток. Він не робить нічого, окрім стандартного коду для hello-world, за винятком виклику моєї кастомної функції. Я створив його за допомогою команди sam init. Ось параметри, які я використовував, усі інші були за замовчуванням:

pic

test-dep — це простий Javascript пакет, який виступає як моя внутрішня залежність для цього коду. Він містить лише один файл, який виконує просту команду console.log.

Початкова налаштування

SAM дозволяє створювати функції за допомогою Docker образу.

Шаблон template.yaml виглядатиме наступним чином відразу після виконання sam init:

AWSTemplateFormatVersion: '2010-09-09'  
Transform: AWS::Serverless-2016-10-31  
Description: >  
 sam-app  

 Шаблон SAM для sam-app  

# Більше інформації про Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst  
Globals:  
 Function:  
 Timeout: 3  

Resources:  
 HelloWorldFunction:  
 Type: AWS::Serverless::Function  
 Properties:  
 PackageType: Image  
 Architectures:  
 - x86_64  
 Events:  
 HelloWorld:  
 Type: Api  
 Properties:  
 Path: /hello  
 Method: get  
 Metadata:  
 DockerTag: nodejs22.x-v1  
 DockerContext: ./hello-world  
 Dockerfile: Dockerfile  

Outputs:  
 # ServerlessRestApi — це імпліцитний API, створений із ключа Events у Serverless::Function  
 # Дізнайтесь більше про інші імпліцитні ресурси, на які ви можете посилатися в SAM  
 # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api  
 HelloWorldApi:  
 Description: "API Gateway endpoint URL для Prod stage для функції Hello World"  
 Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"  
 HelloWorldFunction:  
 Description: "ARN функції Hello World Lambda"  
 Value: !GetAtt HelloWorldFunction.Arn  
 HelloWorldFunctionIamRole:  
 Description: "Імпліцитна IAM роль, створена для функції Hello World"  
 Value: !GetAtt HelloWorldFunctionRole.Arn

За замовчуванням Dockerfile, розташований у директорії hello-world, виглядає так:

FROM public.ecr.aws/lambda/nodejs:22  

COPY app.mjs package*.json ./  

RUN npm install  
# Якщо ви будуєте код для продакшн, замість цього додайте файл package-lock.json в цю директорію та використовуйте:  
# RUN npm ci --production  

# Команду можна перезаписати, вказавши іншу команду безпосередньо в шаблоні.  
CMD ["app.lambdaHandler"]

Після виконання sam local build, а потім sam local invoke, ви повинні побачити наступний результат:

pic

Це чудово. Якщо ви хочете додати залежності, можна використовувати команду pnpm add whatever/whatever. Але що робити, якщо ви хочете включити локальну залежність? Припустимо, ми оновили наш app.mjs, щоб він містив наступне:

/**  
 *  
 * Документація для події: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format  
 * @param {Object} event - API Gateway Lambda Proxy Input Format  
 *  
 * Документація для Context: https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html   
 * @param {Object} context  
 *  
 * Документація для Return: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html  
 * @returns {Object} object - API Gateway Lambda Proxy Output Format  
 *   
 */  

import testing123 from "@test-dep/test";  

export const lambdaHandler = async (event, context) => {  
 testing123();  
 const response = {  
 statusCode: 200,  
 body: JSON.stringify({  
 message: 'hello world',  
 })  
 };  

 return response;  
 };

Ми хочемо викликати функцію testing123() з нашого локального пакету: @test-dep/test. Зазвичай ми додавали б цей пакет до нашого package.json і вказували шлях до публічного/приватного реєстру або локальний шлях типу file:../some-dir/. Але це працює не зовсім так...

Додавання локальної Javascript залежності

Перше, що я хотів зробити, це зробити локальну залежність доступною для моєї lambda функції. У кореневій папці я створив директорію test-dep/.
Додав index.js та package.json.

export default function testing123() {  
 console.log("Це працює!");  
}
{  
 "name": "@test-dep/test",  
 "version": "0.1.0",  
 "description": "",  
 "main": "index.js",  
 "scripts": {  
 "test": "echo \"Error: no test specified\" && exit 1"  
 },  
 "type": "module",  
 "keywords": [],  
 "author": "",  
 "license": "ISC"  
}

Docker Context

Як тепер включити це? Ви можете спробувати зайти в sam-app/hello-world/package.json і додати @test-dep/test: “file:../test-dep”, і це спрацює, якщо ви виконаєте pnpm install локально, але це не спрацює в Docker. Це тому, що Docker не має концепції цього шляху. Нам потрібно покращити DockerContext, який ми використовуємо. Контекст у цьому випадку визначається в sam-app/template.yaml:

Metadata:  
 DockerTag: nodejs22.x-v1  
 DockerContext: ./hello-world # Це наразі вказує на ./sam-app  
 Dockerfile: Dockerfile

Однак, ви помітите, що Dockerfile більше не знаходиться за цією адресою. Тому нам потрібно оновити місце розташування Dockerfile, оскільки наш контекст тепер змінився. Це може бути трохи заплутано, особливо коли ви перескакуєте між різними папками і включаєте відносні пакети.

Важливо зауважити, що DockerContext, заданий у SAM, визначає корінь для всього, що виконується в Docker контейнері.

Тепер місце розташування Dockerfile виглядає наступним чином:

Metadata:  
 DockerTag: nodejs22.x-v1  
 DockerContext: ../ # Тепер це вказує на ./my-dir  
 Dockerfile: sam-app/hello-world/Dockerfile

DockerContext тепер охоплює весь репозиторій, і він бачить папку test-dep.

Крок побудови Dockerfile

Наступне, що нам потрібно зробити, це додати нашу залежність до контейнера. Docker контейнери не відображають ваші локальні папки, вам потрібно переносити кожен необхідний елемент в контейнер за допомогою COPY. Зробимо кілька змін у нашому Dockerfile:

# Етап 1: Будуємо залежність test-dep  
FROM node:22 AS build  

# Налаштовуємо pnpm — це необов’язково, але я віддаю перевагу цьому  
ENV PNPM_HOME="/pnpm"  
ENV PATH="$PNPM_HOME:$PATH"  
RUN corepack enable  
RUN corepack prepare pnpm@latest --activate  

# Копіюємо пакет test-dep  
WORKDIR /test-dep  
COPY test-dep/ ./  
RUN pnpm install

Мені подобається тримати свої Docker контейнери акуратними і чистими. Для цього я розділяю їх на різні етапи. Перший етап — це побудова нашого пакету. Якщо ви виконаєте це локально, ви повинні побачити файли в /test-dep і переконатись, що папка test-dep скопійована.

Примітка: sam build триває довше і за замовчуванням не показує помилок. Якщо ви хочете тестувати швидше, використовуйте просто docker build. Переконайтеся, що ви правильно налаштували контекст, інакше виникнуть ті ж проблеми.

docker buildx build -f sam-app/hello-world/Dockerfile -t my-image-test:latest . — no-cache

Примітка: Працюйте маленькими частинами, щоб швидше знаходити помилки. Помилки можуть бути з різних місць (javascript, компілятор TypeScript, проблеми з Docker, Lambda і т.д.). Коли я будував Dockerfile, я постійно коментував частини і працював із невеликими змінами.

Якщо все гаразд і тепер ви бачите /test-dep/index.js у вашому Docker образі, ви на правильному шляху.

Створення середовища виконання Lambda

Тепер, коли у нас є /test-dep в Docker, ми можемо включити його в нашу Lambda функцію. Щоб додати його до нашого package.json всередині my-dir/sam-app/hello-world/package.json, він повинен бути доступний в контейнері.
Давайте змінімо наш Dockerfile, щоб додати це:

# Етап 1: Будуємо залежність test-dep  
FROM node:22 AS build  

# Налаштовуємо pnpm  
ENV PNPM_HOME="/pnpm"  
ENV PATH="$PNPM_HOME:$PATH"  
RUN corepack enable  
RUN corepack prepare pnpm@latest --activate  

# Копіюємо та будуємо пакет test-dep  
WORKDIR /test-dep  
COPY test-dep/ ./  
RUN pnpm install  

# Етап 2: Створення середовища виконання Lambda  
FROM public.ecr.aws/lambda/nodejs:22 AS runtime  

# Налаштовуємо pnpm  
ENV PNPM_HOME="/pnpm"  
ENV PATH="$PNPM_HOME:$PATH"  
RUN corepack enable  
RUN corepack prepare pnpm@latest --activate  

# Налаштовуємо додаток  
COPY sam-app/hello-world/app.mjs .  
COPY sam-app/hello-world/package*.json ./  

COPY --from=build /test-dep ./test-dep  

# Встановлюємо залежності  
RUN pnpm install  

# Встановлюємо команду обробника Lambda  
CMD ["app.lambdaHandler"]

Ви помітите, що ми можемо використовувати COPY з іншого етапу в Docker. Ми копіюємо з /test-dep до поточної директорії (стандартна директорія Lambda — /var/task). Це поміщає його в контейнер Lambda за адресою /var/task/test-dep. Тепер у нас є структура контейнера, як показано нижче:

var/  
 task/  
 test-dep/  
 index.js  
 package.json  
 app.mjs  
 package.json

Далі нам потрібно додати нашу залежність до package.json.

{  
 "name": "hello_world",  
 "version": "1.0.0",  
 "description": "hello world приклад для NodeJS",  
 "type": "module",  
 "main": "app.js",  
 "repository": "https://github.com/awslabs/aws-sam-cli/tree/develop/samcli/local/init/templates/cookiecutter-aws-sam-hello-nodejs",  
 "author": "SAM CLI",  
 "license": "MIT",  
 "dependencies": {  
 "@test-dep/test": "file:./test-dep"  
 },  
 "scripts": {  
 "test": "mocha tests/unit/"  
 },  
 "devDependencies": {  
 "chai": "^4.3.6",  
 "mocha": "^10.2.0"  
 }  
}

Ви помітите, що цей шлях до файлу не має сенсу, принаймні локально. Якщо ви виконаєте pnpm install локально, це не спрацює. Це тому, що test-dep не існує в вашій директорії sam-app/hello-world, але він є в Docker контейнері.

Тепер ви маєте змогу виконати sam build та sam local invoke. Іноді мені потрібно виконати sam local invoke --force-image-build, щоб переконатися, що образ дійсно перезбирається.

Якщо все буде добре, ви побачите правильний вивід:

Invoking Container created from helloworldfunction:nodejs22.x-v1   
Local image was not found.   
Removing rapid images for repo helloworldfunction   
Building image..................  
Using local image: helloworldfunction:rapid-x86_64.   

START RequestId: f1a65095-c992-48c0-8787-89db90f609f6 Version: $LATEST  
2025-01-17T15:10:23.693Z 59690e02-e33b-476b-b058-41f0ebd2bc64 INFOThis is working!  
END RequestId: 59690e02-e33b-476b-b058-41f0ebd2bc64  
REPORT RequestId: 59690e02-e33b-476b-b058-41f0ebd2bc64 Init Duration: 0.33 ms Duration: 748.51 ms Billed Duration: 749 ms Memory Size: 128 MBMax Memory Used: 128 MB  
{"statusCode": 200, "body": "{\"message\":\"hello world\"}"}

Посилання на цей код з працюючим Javascript можна знайти тут.

Підтримка TypeScript

Вищезазначене чудово працює для функцій Lambda на Javascript, але що, якщо ви хочете працювати з TypeScript локально та деплоїти Javascript? Це трохи складніше, але не набагато.

По-перше, давайте зробимо деякі організаційні кроки. Я хочу, щоб всі мої файли коду знаходились у директорії src. Я хочу, щоб скомпільований Javascript знаходився в dist/. Я хочу підтримку ESM, тому для CommonJS можливо вам доведеться налаштувати деякі речі.

Пакет test-dep

Ми спочатку вирішимо питання з пакетом test-dep.
Додайте файл tsconfig.json, використовуючи або tsc init, або просто скопіюйте цей код:

{  
 "$schema": "https://json.schemastore.org/tsconfig",  
 "compilerOptions": {  
 "target": "es2022",  
 "lib": ["dom", "dom.iterable", "esnext"],  
 "outDir": "./dist",  
 "declaration": true,  
 "rootDir": "./src",  
 "allowJs": true,  
 "checkJs": true,  
 "skipLibCheck": true,  
 "strict": true,  
 "forceConsistentCasingInFileNames": true,  
 "noEmit": false,  
 "esModuleInterop": true,  
 "module": "esnext",  
 "moduleResolution": "bundler",  
 "resolveJsonModule": true,  
 "isolatedModules": true,  
 "jsx": "preserve",  
 "incremental": true,  
 "noUncheckedIndexedAccess": true  
 },  
 "exclude": ["node_modules", "dist"]  
}

Додайте typescript та @types/node до вашого package.json і додайте крок для побудови TypeScript. Також оновимо файл, щоб прибрати основну точку входу та обрати більш конкретний експорт (це необов'язково, ви можете залишити main: dist/index.js, якщо хочете, просто не забудьте додати dist).

{  
 "name": "@test-dep/test",  
 "version": "0.1.0",  
 "description": "",  
 "exports": {  
 ".":"./dist/index.js"  
 },  
 "scripts": {  
 "test": "echo \"Error: no test specified\" && exit 1",  
 "build": "tsc"  
 },  
 "type": "module",  
 "keywords": [],  
 "author": "",  
 "license": "ISC",  
 "devDependencies": {  
 "@types/node": "^22.10.7",  
 "typescript": "^5.7.3"  
 }  
}

Я також створив файл .dockerignore, який виключає node_modules/ з нашого додатку.

Нарешті, додайте директорію src і створіть файл index.ts:

export function testing123() {  
 console.log('This is working!');  
}

Тепер структура директорії test-dep повинна виглядати так:

test-dep/  
 src/  
 index.ts  
 package.json  
 pnpm-lock.yaml  
 tsconfig.json  
 .dockerignore

Якщо ви виконаєте pnpm install && pnpm build, ви побачите створену папку dist, в якій буде один файл index.js, що містить наш console.log.

Додаток sam-app

Тепер, коли наш test-dep генерує Javascript у dist, ми можемо підключити його до нашої Lambda-функції. Тут також потрібно зробити кілька змін.

Жоден з основних файлів у sam-app не був змінений. Тому залишаємо template.yaml та samconfig.toml такими ж.

У директорії hello-world/ зробимо подібні організаційні кроки. Створимо директорію src/, додамо файл tsconfig.json, а також .dockerignore, і потім оновимо файли package.json та Dockerfile.

У директорії src/ створимо файл index.ts. Зверніть увагу, що тепер ми змінюємо файл з app.mjs на index.ts.
Нам також потрібно оновити обробник Lambda у Dockerfile.

/**  
 *  
 * Документація події: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format  
 * @param {Object} event - Вхідний формат API Gateway Lambda Proxy  
 *  
 * Документація контексту: https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html   
 * @param {Object} context  
 *  
 * Документація повернення: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html  
 * @returns {Object} object - Вихідний формат API Gateway Lambda Proxy  
 *   
 */  

import {testing123} from "@test-dep/test";  
import { Handler } from "aws-lambda";  


export const lambdaHandler:Handler = async (event, context) => {  
 testing123();  
 const response = {  
 statusCode: 200,  
 body: JSON.stringify({  
 message: 'hello world',  
 })  
 };  

 return response;  
 };

Наш tsconfig.json такий самий, як і у пакеті test-dep:

{  
 "$schema": "https://json.schemastore.org/tsconfig",  
 "compilerOptions": {  
 "target": "es2022",  
 "lib": ["dom", "dom.iterable", "esnext"],  
 "outDir": "./dist",  
 "rootDir": "./src",  
 "allowJs": true,  
 "checkJs": true,  
 "skipLibCheck": true,  
 "strict": true,  
 "forceConsistentCasingInFileNames": true,  
 "noEmit": false,  
 "esModuleInterop": true,  
 "module": "esnext",  
 "moduleResolution": "bundler",  
 "resolveJsonModule": true,  
 "isolatedModules": true,  
 "jsx": "preserve",  
 "incremental": true,  
 "noUncheckedIndexedAccess": true  
 },  
 "exclude": ["node_modules", "dist"]  
}

.dockerignore також включає node_modules/.

Оновлений package.json:

{  
 "name": "hello_world",  
 "version": "1.0.0",  
 "description": "hello world sample for NodeJS",  
 "type": "module",  
 "main": "./dist/index.js",  
 "repository": "https://github.com/awslabs/aws-sam-cli/tree/develop/samcli/local/init/templates/cookiecutter-aws-sam-hello-nodejs",  
 "author": "SAM CLI",  
 "license": "MIT",  
 "dependencies": {  
 "@test-dep/test": "../../test-dep"  
 },  
 "scripts": {  
 "test": "mocha tests/unit/",  
 "build": "tsc"  
 },  
 "devDependencies": {  
 "@types/aws-lambda": "^8.10.147",  
 "@types/node": "^22.10.7",  
 "chai": "^4.3.6",  
 "mocha": "^10.2.0",  
 "typescript": "^5.7.3"  
 }  
}

Ми змінили main, щоб він вказував на наш файл index.js, який буде створено під час виконання побудови. Ми додали скрипт побудови, а також оновили шлях до залежностей (тепер він правильний для роботи локально, але не працюватиме всередині Docker. Це буде виправлено далі). Також були додані dev-залежності.

Тепер ви можете виконати pnpm install та pnpm build локально, і повинні побачити, що ваша залежність test-dep включена.

Нарешті, зміни в Dockerfile:

# Базовий образ для налаштування pnpm  
FROM node:22 AS base  

# Налаштування pnpm  
ENV PNPM_HOME="/pnpm"  
ENV PATH="$PNPM_HOME:$PATH"  
RUN corepack enable && corepack prepare pnpm@latest --activate  

# Stage 1: Створення залежності test-dep  
FROM base AS build_package  

# Встановлення робочої директорії для побудови  
WORKDIR /test-dep  

# Копіюємо лише необхідні файли для встановлення залежностей, щоб скористатись Docker  
COPY test-dep/package*.json test-dep/pnpm-lock.yaml ./  
RUN pnpm install --frozen-lockfile  

# Копіюємо решту файлів та будуємо  
COPY test-dep/ ./  

# Генеруємо папку dist.

Тепер потрібно оновити наш Dockerfile для обробки Lambda.

RUN pnpm build

Stage 2: Побудова програми

FROM base AS build_app
WORKDIR /app

Отримання залежностей пакету

COPY --from=buildpackage /test-dep/dist ./test-dep/dist
COPY --from=build
package /test-dep/package.json ./test-dep/

COPY sam-app/hello-world/package*.json sam-app/hello-world/tsconfig.json ./hello-world/

RUN sed -i 's|"@test-dep/test": ".|"@test-dep/test": "file:../test-dep"|g' ./hello-world/package.json

RUN cd hello-world && pnpm install
COPY sam-app/hello-world/src ./hello-world/src/
RUN cd hello-world && pnpm build

Stage 3: Створення середовища виконання

FROM public.ecr.aws/lambda/nodejs:22 AS runtime

Налаштування pnpm

ENV PNPMHOME="/pnpm"
ENV PATH="$PNPM
HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate

Встановлення робочої директорії для програми

WORKDIR /var/task

Копіюємо побудовані залежності з попереднього етапу

COPY --from=buildpackage /test-dep/dist ./test-dep/dist
COPY --from=build
package /test-dep/package.json ./test-dep/

Копіюємо код програми

COPY --from=buildapp /app/hello-world/dist ./hello-world/
COPY --from=build
app /app/hello-world/package*.json ./hello-world/

Встановлення залежностей для програми

RUN cd hello-world && pnpm install --prod

Налаштування команди обробника Lambda

CMD ["hello-world/index.lambdaHandler"]
```

Тут є кілька змін. Деякі з них зроблені за моїм бажанням, але ви можете змінити їх на свій розсуд.

Я створив базову стадію, щоб не повторювати інструкції для pnpm (поки не дійдемо до Lambda).

Ми зменшуємо кількість файлів для test-dep, але вони все одно залишаються майже тими ж. Вони будуть розташовані в директорії /test-dep на етапі build_package.

Додано нову стадію build_app. Це для кращої організації. Робоча директорія для цієї стадії - /app. Ми копіюємо з нашої роботи в build_package, так само, як робили раніше. Різниця в тому, що ми також будемо робити подібну "побудову" для TypeScript функції Lambda.

Ви помітите, що я додав команду sed, яка переписує рядок включення пакета. Це зроблено, щоб ми могли розробляти локально, і Docker все одно правильно посилався на нього.

Зараз у нас є наступне, готове до використання:

/test-dep # все необхідне для пакету test-dep, скомпільоване в JS  
/app/hello-world # все необхідне для програми hello-world, скомпільоване в JS

Нарешті, ми оновлюємо нашу стадію runtime, щоб включити як активи build_package, так і нові активи build_app.

Примітка: вам потрібно оновити CMD, щоб вказувати на новий hello-world/index.lambdaHandler, якщо ви слідували моїм інструкціям.

Якщо ви зараз запустите sam local build, ви повинні побачити наступне в директорії /var/task в образі:

var/  
 task/  
 hello-world/  
 index.js  
 node_modules/  
 @test-dep/  
 package.json  
 pnpm-lock.yaml  
 test-dep/  
 dist/  
 index.js  
 index.d.ts  
 package.json

Висновок

Сподіваюся, ці інструкції будуть корисними. Я не знайшов нічого в Інтернеті, що б пояснювало, як будувати типізовані Lambda функції в Docker. В кінцевому підсумку, це більше питання Docker, ніж AWS, але додаткові контейнери/шари ускладнювали розуміння.

Повний репозиторій з робочими версіями на JS та TS можна знайти тут (посилання веде до версії на TS, перевірте гілки для інших): https://gitlab.com/Tigatok/docker-test-lambda/-/tree/typescript-build-lambda?ref_type=heads

Перекладено з: Deploying NodeJS Lambda Function With Internal Dependencies Using Docker

Leave a Reply

Your email address will not be published. Required fields are marked *