Огляд
Під час міграції одного з моїх додатків у Monorepo (за допомогою Turborepo) я стикнувся з багатьма проблемами, пов'язаними з міграцією мого AWS стеку через внутрішні пакети, які були залежностями для lambda функцій. Я не зміг знайти багато ресурсів в Інтернеті з цього питання, тому вирішив написати цю статтю, щоб допомогти тим, хто може потрапити в подібну ситуацію.
Моя основна мета полягала в наступному: Структура monorepo, де я можу мати більше ніж один додаток, у моєму випадку один із яких є SAM додатком. Я хочу написати lambda функцію з використанням TypeScript, а збірка повинна генерувати JavaScript. Моя lambda функція має внутрішню залежність, написану також на TypeScript, і збірка повинна генерувати JavaScript для цієї залежності. Я все ще хочу мати можливість встановлювати та будувати код локально. Я хочу мати змогу використовувати AWS CLI для збірки та деплою, без створення кастомних збірок або управління директорією .aws-sam. Коли я виконую команду sam local invoke
, я маю отримати наступний вихід:
Вимоги:
- Docker (я використовую Orbstack)
- aws-cli інструмент
- 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
. Ось параметри, які я використовував, усі інші були за замовчуванням:
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
, ви повинні побачити наступний результат:
Це чудово. Якщо ви хочете додати залежності, можна використовувати команду 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=buildpackage /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="$PNPMHOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate
Встановлення робочої директорії для програми
WORKDIR /var/task
Копіюємо побудовані залежності з попереднього етапу
COPY --from=buildpackage /test-dep/dist ./test-dep/dist
COPY --from=buildpackage /test-dep/package.json ./test-dep/
Копіюємо код програми
COPY --from=buildapp /app/hello-world/dist ./hello-world/
COPY --from=buildapp /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