DEV Community

joserafaelSH
joserafaelSH

Posted on

Caso de uso: LocalStack

Um pouco sobre o LocalStack

O LocalStack é um emulador de serviços AWS que abrange seus principais serviços, alguns de forma gratuita e outros não. O objetivo dessa ferramenta é facilitar o desenvolvimento de aplicações que utilizam serviços da AWS, aumentando a segurança em relação a custos de desenvolvimento, melhorando a experiência do desenvolvedor em relação a problemas de configurações de permissões com o IAM e permitindo a existência de um ambiente de testes, visando tanto a parte de aprendizado e experimentação dos serviços da AWS, quanto processos como CI com integrações com o Github Actions.

O LocalStack também possui integrações com outras ferramentas incríveis como Pulumi, Serverless, Terraform e Testcontainers.

Leia mais sobre: https://docs.localstack.cloud/getting-started/

O cenário

Suponha uma aplicação simples que realiza todas as operações de um CRUD utilizando uma tabela no DynamoDB. Independentemente da forma com que o desenvolvedor vai construir essa aplicação, cedo ou tarde ele vai ter que acessar a tabela de produção para validar o que foi feito, seja com testes manuais que todos nós fazemos ou com testes de integração e e2e, e é aí que o problema começa a aparecer.

O problema

O DynamoDB cobra por operações na tabela, ou seja, antes de realmente finalizar e disponibilizar a aplicação, já vão ter sido gerados custos. Adicione um pouco mais de complexidade nesse sistema, integrando um processamento assíncrono com SQS, eventos com EventBridge e notificações com SNS e SES, e pronto, sua fatura da AWS já vai estar rodando antes do dia 0 da sua aplicação.

A solução

Nesse cenário, podemos utilizar em nosso ambiente de desenvolvimento o LocalStack, um emulador de serviços cloud AWS que tem como objetivo agilizar e simplificar o desenvolvimento e testes de aplicações que utilizem serviços da cloud AWS.

Utilizando o Docker, docker-compose e AWS SDK da linguagem de programação utilizada, conseguimos subir um container do LocalStack e, através da configuração de URL do SDK e de variáveis de ambiente, conseguimos manipular os ambientes para que, em desenvolvimento e testes, as chamadas apontem para o LocalStack, minimizando os custos durante o desenvolvimento.

No caso apresentado acima, conseguiríamos executá-lo totalmente dentro do LocalStack utilizando os serviços do DynamoDB e os serviços extras como SQS, EventBridge, SNS e SES (de forma simulada). Isso garantiria um custo zero de serviços cloud durante o desenvolvimento e em pipelines de CI/CD, uma vez que o LocalStack também possui integração com GitHub Actions.

Implementação

Afim de exemplificar o uso e, embasado na aplicação apresentada acima, implementei parcialmente um CRUD simples de produtos com apenas duas operações: criar um item e ler todos os itens da tabela.

A implementação foi feita utilizando apenas recursos do Node 22, inclusive seu próprio test runner. A aplicação é uma API normal com duas rotas: uma para criar um item e outra para ler todos os itens de uma tabela do DynamoDB.

Para configurar meu ambiente de desenvolvimento, utilizei um arquivo .env para guardar minhas variáveis de acesso e endpoint da AWS. Esse arquivo será utilizado para que possamos alterar de forma rápida, sem ter que de fato abrir o código, o ambiente em que nossa aplicação vai rodar.

NODE_ENV="dev"
PORT=3000
AWS_ENDPOINT=http://localhost:4566
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=fake_id
AWS_SECRET_ACCESS_KEY=fake_secret
ITEMS_TABLE_NAME="items_table"
Enter fullscreen mode Exit fullscreen mode

Também foi utilizado o LocalStack com Docker e docker-compose.

services:
  localstack:
    container_name: "localstack"
    image: localstack/localstack
    ports:
      - "127.0.0.1:4566:4566"            # LocalStack Gateway
      - "127.0.0.1:4510-4559:4510-4559"  # external services port range
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock" #required for some services
      - ./setup.sh:/etc/localstack/init/ready.d/start-localstack.sh

Enter fullscreen mode Exit fullscreen mode

No docker-compose, é importante ressaltar o último volume utilizado. Ele é um script .sh que será copiado para dentro do container do LocalStack e será executado junto com a inicialização do container. Esse arquivo contém o comando para criar uma tabela no DynamoDB.

#!/bin/bash

awslocal dynamodb create-table     --table-name items_table     --key-schema AttributeName=id,KeyType=HASH     --attribute-definitions AttributeName=id,AttributeType=S     --billing-mode PAY_PER_REQUEST     --region us-east-1

Enter fullscreen mode Exit fullscreen mode

Todos os comandos para lidar com os serviços da AWS no LocalStack podem ser encontrados na documentação da ferramenta.

Com o container rodando e o arquivo .env configurado, o próximo passo é configurar via código o cliente do serviço que será utilizado. Nesse caso, o serviço será o DynamoDBClient.

Vale ressaltar que foi utilizado o SDK v3 para o NodeJs.

O DynamoDBClient recebe como parâmetro as seguintes configurações:

const awsConfig = {
  endpoint: process.env.AWS_ENDPOINT,
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
};
Enter fullscreen mode Exit fullscreen mode

Como passamos toda a nossa configuração via variáveis de ambiente, não precisamos alterar nenhuma parte do código para trocar entre nosso ambiente de desenvolvimento e o ambiente de produção.

A configuração do cliente do DynamoDB da nossa aplicação ficou da seguinte forma:

import {
  CreateTableCommand,
  DeleteTableCommand,
  DynamoDBClient,
  PutItemCommand,
  ScanCommand,
} from "@aws-sdk/client-dynamodb";
import { PutCommand } from "@aws-sdk/lib-dynamodb";

const awsConfig = {
  endpoint: process.env.AWS_ENDPOINT,
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
};

const dynamoClient = new DynamoDBClient(awsConfig);
export const Dynamo = {
  getAllItems: (tableName) => {
    return dynamoClient.send(
      new ScanCommand({
        TableName: tableName,
      })
    );
  },

  createItem: (item, tableName) => {
    return dynamoClient.send(
      new PutCommand({
        TableName: tableName,
        Item: {
          ...item,
        },
      })
    );
  },

  createTable: (tableName) => {
    return dynamoClient.send(
      new CreateTableCommand({
        TableName: tableName,
        KeySchema: [
          {
            AttributeName: "id",
            KeyType: "HASH",
          },
        ],
        AttributeDefinitions: [
          {
            AttributeName: "id",
            AttributeType: "S",
          },
        ],
        ProvisionedThroughput: {
          ReadCapacityUnits: 1,
          WriteCapacityUnits: 1,
        },
      })
    );
  },

  deleteTable: (tableName) => {
    return dynamoClient.send(
      new DeleteTableCommand({
        TableName: tableName,
      })
    );
  },
};
Enter fullscreen mode Exit fullscreen mode

Como um dos intuitos do LocalStack é permitir o teste local de serviços AWS, criei uma rotina simples de teste, apenas para garantir que conseguimos executar as duas operações que a nossa aplicação se propõe a fazer.

import { describe, it } from "node:test";
import { Dynamo } from "../dynamo-db.js";
import assert from "node:assert/strict";

describe("Integrations tests with DynamoDB and LocalStack", () => {
  const database = Dynamo;
  const testTableName = "items_table_test";
  it("it should create the items table", async () => {
    const response = await database.createTable(testTableName);
    const status = response["$metadata"].httpStatusCode;
    assert.equal(response != undefined, true);
    assert.equal(status, 200);
  });

  it("it should create a item", async () => {
    const response = await database.createItem(
      {
        id: "123",
        name: "teste",
        price: 100,
      },
      testTableName
    );

    const status = response["$metadata"].httpStatusCode;
    assert.equal(response != undefined, true);
    assert.equal(status, 200);
  });

  it("it should get all items", async () => {
    const response = await database.getAllItems(testTableName);
    const status = response["$metadata"].httpStatusCode;
    assert.equal(response != undefined, true);
    assert.equal(status, 200);
    assert.equal(response.Count > 0, true);
    assert.equal(response.ScannedCount > 0, true);
    assert.equal(response.Items.length > 0, true);
  });

  it("it should delete the items table", async () => {
    const response = await database.deleteTable(testTableName);
    assert.equal(response != undefined, true);
  });
});
Enter fullscreen mode Exit fullscreen mode

Ao final do desenvolvimento, criei uma action no GitHub para que possamos rodar nossos testes em um pipeline de CI/CD.

name: CI using localstack

on: push

jobs:
  continuos-integration:
    runs-on: ubuntu-latest
    environment: poc-node-js-localstack-env

    steps:
      - uses: actions/checkout@v3
      - name: Using Node.js
        uses: actions/setup-node@v2
        with:
          node-version: 22.

      - name: Start LocalStack
        uses: LocalStack/setup-localstack@v0.2.0
        with:
          image-tag: 'latest'
          install-awslocal: 'true'

      - name: Create .env file
        run: |
          touch .env
          echo "AWS_ACCESS_KEY_ID=${{vars.AWS_ACCESS_KEY_ID}}" >> .env
          echo "AWS_ENDPOINT=${{vars.AWS_ENDPOINT}}" >> .env
          echo "AWS_REGION=${{vars.AWS_REGION}}" >> .env
          echo "AWS_SECRET_ACCESS_KEY=${{vars.AWS_SECRET_ACCESS_KEY}}" >> .env
          echo "ITEMS_TABLE_NAME=${{vars.ITEMS_TABLE_NAME}}" >> .env
          cat .env

      - name: run install, build and test
        run: |
          npm install
          npm run test

Enter fullscreen mode Exit fullscreen mode

Aqui está o link para mais informações sobre a integração do LocalStack com GitHub Actions: https://docs.localstack.cloud/user-guide/ci/github-actions/ .Esse recurso pode ajudar a configurar pipelines de CI/CD que utilizam o LocalStack para testes locais de serviços AWS.

Limitações

Nem todos os serviços que podem ser emulados via LocalStack estão inteiramente implementados e estáveis. Pegando o DynamoDB e o SES, podemos notar que a maioria das funcionalidades do DynamoDB estão implementadas parcialmente e, para o SES, a maioria de seus serviços estão instáveis.

Com isso, podemos concluir que precisamos nos atentar aos serviços e suas funcionalidades para que não haja divergências bruscas entre nosso ambiente de desenvolvimento, testes e o de produção.

Na documentação do LocalStack, podemos encontrar todos os serviços e seus respectivos níveis de cobertura.

Alguns exemplos

  • docker-compose
services:
  localstack:
    container_name: "localstack"
    image: localstack/localstack
    ports:
      - "127.0.0.1:4566:4566"            # LocalStack Gateway
      - "127.0.0.1:4510-4559:4510-4559"  # external services port range
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock" #required for some services
Enter fullscreen mode Exit fullscreen mode
  • NodeJS DynamoDB example
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";

const dynamodbConfig = {
  region: "us-east-1",
};
const isLocal = IS_OFFLINE === "true";

if (isLocal) {
  const host = LOCALSTACK_HOST || "localhost";
  dynamodbConfig["endpoint"] = `http://${host}:4566`;
}

const client = new DynamoDBClient(dynamodbConfig);
Enter fullscreen mode Exit fullscreen mode
  • NodeJS SQS NestJS example
@Injectable()
export class SqsService {
  private readonly client: SQSClient = new SQSClient({
    endpoint:
      this.envConfigService.getAwsEndpoint() || process.env.AWS_ENDPOINT,
    region: this.envConfigService.getAwsRegion(),
    credentials: {
      accessKeyId: this.envConfigService.getAwsAccessKeyId(),
      secretAccessKey: this.envConfigService.getAwsSecretAccessKey(),
    },
  });

  constructor() {}
}
Enter fullscreen mode Exit fullscreen mode
NODE_ENV=prod
AWS_ENDPOINT=protocol://service-code.region-code.amazonaws.com
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=real_id
AWS_SECRET_ACCESS_KEY=real_secret
Enter fullscreen mode Exit fullscreen mode
NODE_ENV=dev
AWS_ENDPOINT=http://localhost:4566
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=fake_id
AWS_SECRET_ACCESS_KEY=fake_secret
Enter fullscreen mode Exit fullscreen mode

O endpoint é formado pelo seguinte padrão: “protocol://service-code.region-code.amazonaws.com”. Um exemplo de endpoint é “https://dynamodb.us-west-2.amazonaws.com”. Para o ambiente de desenvolvimento, o endpoint vai apontar para a porta que está rodando o container do LocalStack.

Vale ressaltar que as chaves e IDs de acesso podem ser simplesmente um “teste” para rodar de forma local.

Recursos extras

Top comments (1)

Collapse
 
manbomb profile image
Sérgio Avilla

Super útil, mesmo com a instabilidade de outros serviços, somente a parcialidade do funcionamento do DDB já salva a maioria dos projetos que eu tenho. Eu antes tinha que ficar utilizando duas tabelas, dois ambientes, mas direto em nuvem, pagando para testar. Solução super útil. Muito obrigado.