Цель данной статьи - создать GraphQL приложение, построенное на фреймворке NestJS. А также загрузить его в Лямбда-функцию при помощи Terraform. Надеюсь данный пример поможет многим сэкономить много времени.

Приложение будет работать с реляционной базой данных PostgreSQL. Для локального использования возьмем docker-compose:

version: '3.1'

services:
  db:
    image: 'postgres:14.1'
    restart: unless-stopped
    volumes:
      - ./volumes/postgresql/data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: example
      POSTGRES_DB: nest
    ports:
      - 5432:5432
    networks:
      - postgres

networks:
  postgres:
    driver: bridge

Создадим новый проект (для этого необходимо установить Nest CLI):

nest new app

Добавим модуль и сервис пользователя:

nest generate module user
nest generate service user

Добавим модель пользователя, которая будет одновременно и моделью базы и описанием объекта для GraphQL:

@Entity()
@ObjectType()
export class User {
    @PrimaryGeneratedColumn()
    @Field(type => Int)
    id: number;

    @Column({nullable: false})
    @Field({nullable: false})
    name: string;

    @Column({nullable: true})
    @Field({nullable: true})
    dob: Date;

    @Column({nullable: true})
    @Field({nullable: true})
    address: string;

    @Column({nullable: true})
    @Field({nullable: true})
    description: string;

    @Column({nullable: true})
    @Field({nullable: true})
    imageUrl: string;

    @Column({nullable: true, default: new Date()})
    @Field({nullable: true})
    createdAt: Date;

    @Column({nullable: true, default: new Date()})
    @Field({nullable: true})
    updatedAt: Date;
}

Опишем сервис пользователя так, чтобы он решал стандартные задачи CRUD, а также поиск по имени пользователя с пагинацией

@Injectable()
export class UserService {
    constructor(
        @Inject(USER_REPOSITORY)
        private userRepository: Repository<User>
    ) {
    }

    create(data: TUserCreate): Promise<User> {
        const user = this.userRepository.create(data)
        return this.userRepository.save(user)
    }

    findById(id: number): Promise<User> {
        return this.userRepository.findOne(id)
    }

    async findAll(searchText: string = '', take: number = 10, skip: number = 0): Promise<UserSearchResult> {

        const query = searchText ? {
            where: [
                {name: ILike('%'+searchText+'%')}
            ]
        } : {}

        const getQuery = {
            ...query,
            take,
            skip,
            order: {
                name: "ASC",
            }
        }
        const [total, list] = await Promise.all([this.userRepository.count(query), this.userRepository.find(getQuery as FindManyOptions)])
        return {
            total, list
        } as UserSearchResult
    }

    async updateById(id: number, data: TUserUpdate): Promise<User> {
        await this.userRepository.update({id}, data)
        return this.findById(id)
    }

    async deleteById(id: number): Promise<boolean> {
        return !!(await this.userRepository.delete({id}))
    }
}

И теперь соединим их при помощи класса-резолвера:

@Resolver(of => User)
export class UsersResolver {
    constructor(
        private userService: UserService,
    ) {
    }

    @Mutation(returns => User)
    async createUser(
        @Args('name', {type: () => String}) name: string,
        @Args('address', {type: () => String}) address: string = '',
        @Args('description', {type: () => String}) description: string = '',
        @Args('imageUrl', {type: () => String}) imageUrl: string = '',
        @Args('dob', {type: () => String}) dob: string = null,
    ) {
        return this.userService.create({
            name,
            address,
            description,
            imageUrl,
            dob: dob ? new Date(dob) : null
        } as TUserCreate);
    }

    @Query(returns => User)
    async getUser(@Args('id', {type: () => Int}) id: number) {
        return this.userService.findById(id);
    }

    @Query(returns => UserSearchResult)
    async getAllUsers(
        @Args('searchText', {type: () => String}) searchText: string,
        @Args('take', {type: () => Int}) take: number=10,
        @Args('skip', {type: () => Int}) skip: number=0,
    ) {
        return this.userService.findAll(searchText, take, skip)
    }

    @Mutation(returns => User)
    async updateUser(
        @Args('id', {type: () => Int}) id: number,
        @Args('name', {type: () => String}) name: string,
        @Args('address', {type: () => String}) address: string = '',
        @Args('description', {type: () => String}) description: string = '',
        @Args('imageUrl', {type: () => String}) imageUrl: string = '',
        @Args('dob', {type: () => String}) dob: string = null,
    ) {
        return this.userService.updateById(id, {
            name,
            address,
            description,
            imageUrl,
            dob: dob ? new Date(dob) : null
        } as TUserUpdate);
    }

    @Mutation(returns => Boolean)
    async deleteUser(@Args('id', {type: () => Int}) id: number) {
        return this.userService.deleteById(id);
    }

}

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

По умолчанию GraphQL Playground запускается по пути /graphql. Мы будем деплоить наше приложение в Lambda через ApiGateway, который должен иметь stage с каким-то именем, что дает префикс к любому пути, например /api. Поэтому нужно перенести путь для GraphQL Playground с /graphql в /api/graphql. Для этого используем параметр useGlobalPrefix:true. А также при инициализации express добавим app.setGlobalPrefix('api');

@Module({
    imports: [
        DatabaseModule,
        GraphQLModule.forRootAsync({
            useFactory: () => {
                const schemaModuleOptions: Partial<GqlModuleOptions> = {};

                // If we are in development, we want to generate the schema.graphql
                if (process.env.NODE_ENV !== 'production' || process.env.IS_OFFLINE) {
                    schemaModuleOptions.autoSchemaFile = 'src/user/user.schema.gql';
                } else {
                    // For production, the file should be generated
                    schemaModuleOptions.typePaths = ['*.gql'];
                }

                return {
                    context: ({req}) => ({req}),
                    useGlobalPrefix:true, // <==
                    playground: true, // Allow playground in production
                    introspection: true, // Allow introspection in production
                    ...schemaModuleOptions,
                };
            }
        } as GqlModuleAsyncOptions),
    ],
    providers: [
        ...userProviders,
        UserService,
        UsersResolver
    ]
})

Запустим Playground локально:

Создание пользователя
Создание пользователя
Постраничный поиск
Постраничный поиск

Для запуска в Lambda необходимо подменить создание сервера express на aws-serverless-express

Создадим app.ts:

import {NestFactory} from '@nestjs/core';
import {ExpressAdapter} from '@nestjs/platform-express';
import {INestApplication} from '@nestjs/common';
import {AppModule} from './app.module';
import * as express from 'express';
import {Express} from 'express';
import {Server} from "http";
import {createServer} from 'aws-serverless-express';

export async function createApp(
    expressApp: Express,
): Promise<INestApplication> {
    const app = await NestFactory.create(
        AppModule,
        new ExpressAdapter(expressApp),
    );
    app.setGlobalPrefix('api');
    return app;
}

export async function bootstrap(): Promise<Server> {
    const expressApp = express();
    const app = await createApp(expressApp);
    await app.init();
    return createServer(expressApp);
}

А также файл с handler функцией лямбды:

import {Server} from 'http';
import {Context} from 'aws-lambda';
import {proxy, Response} from 'aws-serverless-express';
import {bootstrap} from './app';

let cachedServer: Server;

export async function handler(event: any, context: Context): Promise<Response> {
    if (!cachedServer) {
        cachedServer = await bootstrap();
    }
    return proxy(cachedServer, event, context, 'PROMISE').promise;
}

Осталось задеплоить это все в AWS. Для этого воспользуемся Terraform. Создадим папку terraform, а в ней файл main.tf . Дальше кидаю готовый конфиг с комментариями по каждому действию:

# Зададим регион по умолчанию
provider "aws" {
  region = "us-east-1"
}

# Деплоить лямбду будем через zip архив. Поэтому необходимо положить наш код в архив
data "archive_file" "app_zip" {
  type        = "zip"
  source_dir  = "../app/dist"
  output_path = "./app.zip"
}

# Создадим API GW
resource "aws_apigatewayv2_api" "app" {
  name          = "api"
  protocol_type = "HTTP"
}

# И добавим в него stage. 
resource "aws_apigatewayv2_stage" "app" {
  api_id = aws_apigatewayv2_api.app.id

  name        = "api"
  auto_deploy = true

  # добавим логирования API GW в CloudWatch
  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.api_gw.arn

    format = jsonencode({
      requestId               = "$context.requestId"
      sourceIp                = "$context.identity.sourceIp"
      requestTime             = "$context.requestTime"
      protocol                = "$context.protocol"
      httpMethod              = "$context.httpMethod"
      resourcePath            = "$context.resourcePath"
      routeKey                = "$context.routeKey"
      status                  = "$context.status"
      responseLength          = "$context.responseLength"
      integrationErrorMessage = "$context.integrationErrorMessage"
    }
    )
  }
}

# Создадим интеграцию Lambda в API GW
resource "aws_apigatewayv2_integration" "app" {
  api_id = aws_apigatewayv2_api.app.id

  integration_uri    = aws_lambda_function.app.invoke_arn
  integration_type   = "AWS_PROXY"
  integration_method = "POST"
}
# Добавим Route - любой route должен вызывать нашу лямбду
resource "aws_apigatewayv2_route" "app" {
  api_id = aws_apigatewayv2_api.app.id

  route_key = "ANY /{proxy+}"
  target    = "integrations/${aws_apigatewayv2_integration.app.id}"
}
# Добавим лог группу в Cloud Watch для API GW
resource "aws_cloudwatch_log_group" "api_gw" {
  name = "/aws/api_gw/${aws_apigatewayv2_api.app.name}"
  retention_in_days = 30
}

# Добавим достум API GW вызывать лямбда функцию
resource "aws_lambda_permission" "api_gw" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.app.function_name
  principal     = "apigateway.amazonaws.com"

  source_arn = "${aws_apigatewayv2_api.app.execution_arn}/*/*"
}
# Создадим Security Group для базы данных и настроем ее так, чтоб можно было достучаться до нее из вне
# Внимание это настройка только для демо. для продакшн так делать нельзя.
resource "aws_security_group" "allow_db" {
  name        = "allow_db"
  description = "Allow DB"

  ingress {
    from_port        = 5430
    to_port          = 5440
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
}
# Создадим рандомный пароль для базы
resource "random_password" "password" {
  length           = 20
  special          = false
  override_special = "_%@"
}
# Создадим инстанс базы
resource "aws_db_instance" "default" {
  allocated_storage      = 20
  db_subnet_group_name   = aws_db_subnet_group.db_subnet_group.name
  engine                 = "postgres"
  identifier             = "dev-db"
  engine_version         = "13"
  instance_class         = "db.t3.micro"
  name                   = "nest"
  username               = "postgres"
  password               = random_password.password.result
  skip_final_snapshot    = true
  publicly_accessible    = true
  vpc_security_group_ids = [aws_security_group.allow_db.id]

}

# Настроим подсеть 'a' для региона us-east-1
resource "aws_default_subnet" "db_subnet_a" {
  availability_zone = "us-east-1a"
  tags = {
    Name = "Default subnet for us-east-1a"
  }
}

# Настроим подсеть 'b' для региона us-east-1
resource "aws_default_subnet" "db_subnet_b" {
  availability_zone = "us-east-1b"

  tags = {
    Name = "Default subnet for us-east-1b"
  }
}

# Объеденим подсети в группу
resource "aws_db_subnet_group" "db_subnet_group" {
  name       = "db_subnet_group"
  subnet_ids = [aws_default_subnet.db_subnet_a.id, aws_default_subnet.db_subnet_b.id]
}

# Создать лямбда функцию, используя архив с кодом
resource "aws_lambda_function" "app" {
  filename         = data.archive_file.app_zip.output_path
  source_code_hash = data.archive_file.app_zip.output_base64sha256
  function_name    = "app"
  handler          = "serverless.handler"
  runtime          = "nodejs14.x"
  memory_size      = 1024
  role             = aws_iam_role.lambda_exec.arn
  timeout          = 30
  # зададим перенные окружения, указав доступ к базе
  environment {
    variables = {
      POSTGRES_HOST     = aws_db_instance.default.address
      POSTGRES_PORT     = aws_db_instance.default.port
      POSTGRES_USER     = aws_db_instance.default.username
      POSTGRES_PASSWORD = random_password.password.result
      POSTGRES_DATABASE = aws_db_instance.default.name
      NODE_ENV          = "production"
    }
  }
}

# Добавим лог группу в CloudWatch для лямбда-функции
resource "aws_cloudwatch_log_group" "app" {
  name = "/aws/lambda/${aws_lambda_function.app.function_name}"
  retention_in_days = 30
}

# Создать роль для лямбды
resource "aws_iam_role" "lambda_exec" {
  name = "serverless_lambda"

  assume_role_policy = jsonencode({
    Version   = "2012-10-17"
    Statement = [
      {
        Action    = "sts:AssumeRole"
        Effect    = "Allow"
        Sid       = ""
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

# Присоединим стандартный полиси к роли с доступ к VPC
resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role       = aws_iam_role.lambda_exec.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}
terraform apply

После чего в вашем AWS аккаунте создадутся нужные ресуры:

Как видим Terraform очень удобен для создания и менеджмента ресурсов в облаке. Можно легко поменять аккаунт AWS и развернуть все в нем, а также уничтожить все ресурсы одной командой terraform destroy.

Теперь запустим GraphQL Playground в лямбде:

В итоге у нас получилась лямбда функция с GraphQL основанная на фреймворке NestJS и задеплоенная при помощи Terraform. Используя данный пример вы сможете реалзиовать свои проекты на схожих технологиях. Полный код можно глянуть тут.

Комментарии (4)


  1. Shatun
    10.01.2022 02:11
    +8

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

    Во-первых вопрос холодных стартов. Интересно услышать какие у вас получаются цифры-но с нестом и постгресом он может быть >1с(очень зависит от конфигов), что для апи часто неприемлимо. Это стоит учитывать.

    Во-вторых при большой нагрузке у сервиса будут проблемы. Для каждого новой лямбды будет создаваться отдельное соедиенение к базе. Если у вас может быть >1000 рпс то это будет большая проблема. Я бы рекомендовал сделать нагрзуочное тестирование и посмотреть результат.

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

    Есть еще потенциальные проблемы, но тут надо тестить, в особоенности поведение конкретной постгресовской либы при фризе лямбды.

    Если вы не очень знакомы с нюансами то этот же сервис, практически с таким же кодом на EC2/fargate может оказаться значительно дешевле по деньгам, с большей производительностью и часто проще масштабируемым. Это незначит что подход нерабочий, но следует дважды подумать перед тем как выбрать этот подход.


    1. okosynskyi Автор
      10.01.2022 12:59
      +2

      Абсолютно согласен. На счет того когда стоит использовать лямбду, а когда нет можно написать отдельную статью. Но если использовать в проектах где первую секунду можно и подождать (админка например). И где количество запросов не велико. Можно не выходить за free tier AWS и получить полностью бесплатный хостинг


  1. niyaho8778
    10.01.2022 12:54

    Косвенный вопрос с помощью какой программы и с какими настройками вы смогли создать такиг гифки ?

    ps terraform это настоящий ад хрен пойми какого объема конфигураций... я думал такое только в ci cd системах аткое есть ...


    1. okosynskyi Автор
      10.01.2022 12:56
      +3

      ffmpeg умеет ковертировать видео в гифки.

      ffmpeg -i some.mov -vf "fps=10" -pix_fmt rgb24 -f gif - | gifsicle --optimize=3 --delay=3 > some.gif