AWS na lokalnej maszynie? To możliwe z localstack

Czym jest localstack?

Localstack jest aplikacją (albo też zbiorem kilku), która pozwala na symulowanie serwisów AWS na lokalnej maszynie. Oczywiście nie ma wsparcia dla wszystkich serwisów, ale z tymi najpopularniejszymi nie będzie problemu, pełna lista aktualnie wspieranych usług jest poniżej (na dzień 1.06.2019):

  • API Gateway: http://localhost:4567
  • Kinesis: http://localhost:4568
  • DynamoDB: http://localhost:4569
  • DynamoDB Streams: http://localhost:4570
  • Elasticsearch: http://localhost:4571
  • S3: http://localhost:4572
  • Firehose: http://localhost:4573
  • Lambda: http://localhost:4574
  • SNS: http://localhost:4575
  • SQS: http://localhost:4576
  • Redshift: http://localhost:4577
  • ES (Elasticsearch Service): http://localhost:4578
  • SES: http://localhost:4579
  • Route53: http://localhost:4580
  • CloudFormation: http://localhost:4581
  • CloudWatch: http://localhost:4582
  • SSM: http://localhost:4583
  • SecretsManager: http://localhost:4584
  • StepFunctions: http://localhost:4585
  • CloudWatch Logs: http://localhost:4586
  • STS: http://localhost:4592
  • IAM: http://localhost:4593

Powyższa lista zawiera nazwy serwisów oraz ich domyślny adres. Po uruchomieniu localstacka na lokalnej maszynie, każdy serwis AWS ma przypisany osobny port który pozwala na dostęp do niego. No dobrze, ale jak to uruchomić i czemu ma mi się to właściwie przydać?

Po co mi localstack?

W zależności od tego w jaki sposób wykorzystujemy usługi AWS, localstack może się okazać kompletnie nieprzydatny lub wręcz przeciwnie – może znacznie przyspieszyć nasz development. W najprostszym scenariuszu możemy dzięki niemu zapoznać się z API wspieranych serwisów, czy to przez aws-cli czy aws-sdk dla różnych języków (np. Java, Golang, NodeJS). Innym zastosowaniem jest odpalanie testów w kompletnej izolacji, to znaczy modyfikując dane w DynamoDB nie musimy martwić się że przez przypadek usuniemy/zmodyfikujemy więcej niż byśmy chcieliśmy, możemy też oprzeć na nim nasze testy integracyjne, bez generowania kosztów na AWS.

Z reguły jeśli chcemy uruchomić testy integracyjne lub akceptacyjne to musimy najpierw naszą aplikację wypuścić na któreś ze środowisk (dev/stage/prod) lub też odpalić na lokalnej maszynie z potrzebnymi uprawnieniami i konfiguracją oraz mieć nadzieję, że nie zepsujemy wspólnego środowiska dla innych. Problem nie występuje, jeśli każdy developer ma swoje prywatne środowisko developerskie w chmurze, ale w dalszym ciągu musimy czekać aż nasz pipeline wypuści nową wersję (o ile oczywiście korzystasz z automatyzacji wdrażania zmian).

Localstack pozwala nam ominąć te problemy, ale kosztem jest utrzymywanie jego konfiguracji, nie wspiera uprawnień z IAM, oraz nie przetestujemy czy nasze nowe IAM policy pozwoli nam wysłać wiadomość na SQS. Daje nam jednak możliwość przetestowania czy dobrze wywołaliśmy funkcję z SDK, a także umożliwi uruchamianie testów integracyjnych w kompletnej izolacji na lokalnej maszynie, bez marnowania czasu na deploymenty, z opcją hot reloadu kodu (np. w Node.JS poprzez odpalenie naszego serwisu za pomocą nodemon) lub debuggera.

Jak zacząć?

Najłatwiejszym sposobem na uruchomienie localstacka jest skorzystanie z oficjalnego obrazu dockera. W poniższym przykładzie spróbujemy uruchomić localstack ze wsparciem dla usługi S3, stworzymy bucket i wyślemy do niego plik.

docker run  -p 4572:4572 -p 8080:8080 -p 4569:4569 -e SERVICES=s3,dynamodb localstack/localstack

Uruchomiliśmy nowy kontener dockera z udostępnionymi trzema portami 4572, 4569, 8080. Dodatkowo ustawiliśmy zmienną o nazwie SERVICES z wartościami s3 i dynamodb, które oznaczają mniej więcej tyle że uruchomiony kontener powinien umożliwić dostęp do serwisu s3 i dynamodb. Nie użyliśmy flagi -d, także aktualny terminal jest zablokowany, ale za to możemy przejrzeć logi które są na bieżąco generowane przez localstack (co w początkowych fazach zabawy może być przydatne).

Aktualnie mamy działający kontener z S3 i DynamoDB. Dynamo nam się nie przyda (ale dobrze wiedzieć jak wystartować więcej niż 1 serwis), za to wykorzystamy S3 do stworzenia bucketa i załadowania tam pliku. W nowym oknie terminala wpisz:

aws s3api create-bucket --endpoint http://localhost:4572 --bucket test
echo "test" > test.txt
aws s3api put-object --endpoint http://localhost:4572 --bucket test --key test.txt --body test.txt
aws s3api list-objects --endpoint http://localhost:4572 --bucket test

Właściwie powyższy snippet wygląda dokładnie tak samo jakbyśmy działali na prawdziwym serwisie AWS. Jedyną różnicą jest argument --endpoint http://localhost:4572, który powoduje że zapytania które normalnie powinny być wysłane do amazona, lądują u nas na lokalnej maszynie na porcie 4572, na tym porcie działa wcześniej uruchomiona usługa S3 z dockerowego kontenera. Ogólne wnioski są takie że z localstack możemy korzystać tak jak ze standardowych serwisów AWS, z tą różnicą że musimy zawsze nadpisać argument --endpoint odpowiednim hostem i portem. Jeśli chcemy sprawdzić aktualny stan localstacka, to możemy w przeglądarce wejść na adres http://localhost:8080 powinniśmy tam zobaczyć nasz bucket o nazwie test.

Jeśli nie chcemy za każdym razem nadpisywać endpointu serwisu, z którego chcemy skorzystać, to możemy zainstalować małego wrappera na aws-cli który zrobi to za nas awscli-local.

A jak użyć tego w kodzie?

Na podobnej zasadzie możemy używać localstacka w naszym kodzie, aws-sdk pozwala na nadpisanie zmiennej endpoint.

Przykład dla S3 w javascript (pamiętaj o wywołaniu npm i aws-sdk, przed uruchomieniem poniższego kodu):

'use strict';

const AWS = require('aws-sdk');

const s3Client = new AWS.S3({
    apiVersion: '2006-03-01',
    s3ForcePathStyle: true,
    endpoint: 'http://localhost:4572',
});

s3Client.putObject({
    Bucket: 'test',
    Key: 'test.txt',
    Body: 'Test string',
}, err => {
    if (err) {
        return console.error('Something went wrong', err);
    }

    console.log('File saved');
});

Powyższy snippet robi mniej więcej to samo co bash, który wywołaliśmy wcześniej, Jedyną ciekawostką jest parameter s3ForcePathStyle: true. Okazuje się, że w przypadku S3 localstack nie działa 1 do 1 jak AWS i potrzebuje tego argumentu żeby poprawnie zapisać pliki.

Localstack z docker-compose oraz testy

W tym przykładzie pójdziemy o krok dalej. Zautomatyzujemy uruchamianie localstacka oraz konfigurację jego serwisów, ale zacznijmy od kodu, stwórzmy plik index.js:

'use strict';

const { DynamoDB } = require('aws-sdk');
const app = require('express')();
const bodyParser = require('body-parser');

const docClient = new DynamoDB.DocumentClient({
    region: 'eu-central-1',
    endpoint: 'http://localstack:4569', // Endpoint pod ktorym jest dynamodb, localstack to nazwa hosta w docker-compose
});

app.use(bodyParser.json());
app.post('/', (req, res) => {
    save(req.body, (err) => {
        if ( err ) {
            console.error(err);
            res.status(500);
            return res.json({ message: err.message });
        }

        return res.json({ status: 'saved' });
    });
});

const port = 3000;
app.listen(port, () => console.log(`App listening on port ${port}!`));

function save(data, cb) {
    console.log('Saving item ');
    return docClient.put({
        TableName: 'items',
        Item: data,
    }, cb);
}

Powyższy kod ma za zadanie uruchomić aplikację słuchającą na porcie 3000. Ma ona tylko jeden endpoint z metodą POST, a zadaniem tego endpointu jest zapisanie otrzymanego payloadu z requesta do dynamodb. Potrzebujemy jeszcze uproszczonego Dockerfile by uruchomić naszą aplikację w kontenerze:

# ---- Base Node ----
FROM node:10.12.0-alpine AS base

WORKDIR /home/node/app

RUN apk update && apk add --no-cache nodejs-current tini

COPY . .
RUN npm install

USER node

ENV PORT 3000
EXPOSE 3000

ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/node" ]
CMD ["index.js"]

Kolejnym krokiem będzie dodanie docker-compose.yml:

version: '3.7'
services:
  items-app:
    build:
      context: .
    depends_on:
      - setup-localstack
    ports:
      - '3000:3000'
    environment:
    # Ustawiamy poniższe zmienne zeby aws-sdk nie miało do nas pretensji ze ich nie podaliśmy
    # nie są one walidowane w żaden sposób przez localstack
      - "AWS_ACCESS_KEY_ID=dummy-key-id"
      - "AWS_SECRET_ACCESS_KEY=dummy-secret-key"
    volumes:
      - .:/home/node/app/
      - /home/node/app/node_modules/

  localstack:
    image: localstack/localstack
    ports:
      - "4567-4584:4567-4584"
      - "8080:8080"
    environment:
      - SERVICES=dynamodb
      - PORT_WEB_UI=8080
      - DOCKER_HOST=unix:///var/run/docker.sock
      - DEFAULT_REGION=eu-central-1
    volumes:
      - "~/tmp/localstack:/tmp/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

  setup-localstack:
    image: cgswong/aws
    command: /init-localstack.sh
    environment:
    # Ustawiamy poniższe zmienne zeby aws-cli nie miało do nas pretensji ze ich nie podaliśmy
    # nie są one walidowane w żaden sposób przez localstack
      - "AWS_ACCESS_KEY_ID=dummy-key-id"
      - "AWS_SECRET_ACCESS_KEY=dummy-secret-key"
    depends_on:
      - localstack
    volumes:
      - "./init-localstack.sh:/init-localstack.sh"

Tu już dzieję się troszkę więcej, uruchamiamy 3 kontenery items-app, localstack, setup-localstack.

  • items-app to nasza aplikacja którą przed chwilą stworzyliśmy
  • localstack to jak nazwa wskazuje kontener localstacka, skonfigurowany by udostępnić endpoint z web-ui oraz dynamodb
  • setup-localstack jest kontenerem który będzie miał za zadanie skonfigurować naszego localstacka, podpinamy pod niego skrypt init-localstack.sh który będzie miał za zadanie stworzenie dynamodb

Poniżej jest zawartość skryptu init-localstack.sh:

#!/bin/bash

sleep 5 # brzydki sleep, musimy chwilke odczekac nim kontener z localstack wstanie

DYNAMO_ENDPOINT="http://localstack:4569"
DYNAMO_TABLE="items"

echo "Creating dynamodb: ${DYNAMO_TABLE}"
aws --endpoint-url=${DYNAMO_ENDPOINT} --region eu-central-1 dynamodb create-table --table-name ${DYNAMO_TABLE} \
    --attribute-definitions AttributeName=id,AttributeType=S \
    --key-schema AttributeName=id,KeyType=HASH \
    --provisioned-throughput ReadCapacityUnits=1000,WriteCapacityUnits=1000

echo "Localstack initialization finished"

Powyższy skrypt konfiguruje aws-cli oraz tworzy tabelę w DynamoDB o nazwie items. Tabela ta ma wymagany atrybut id, który też jest jej indeksem.

Zainicjalizujmy jeszcze nasz projekt (dla npm init wystarczą nam wartości domyślne):

npm init
npm i aws-sdk express body-parser

A więc mamy już wszystkie składniki, także pora odpalić naszą aplikację i zobaczyć jak ona działa (pamiętajmy o wyłączeniu kontenera, który wystartowaliśmy wcześniej):

docker-compose build
docker-compose up

Po paru sekundach wszystko powinno wystartować bez problemów. Możemy to zweryfikować otwierając przeglądarkę i wchodząc na adres http://localhost:8080 – zobaczymy web UI localstacka i naszą tabele w DynamoDB.

Kolejnym krokiem będzie zapisanie prostego obiektu w naszej bazie:

curl -X POST http://localhost:3000 -H "Content-type: application/json" -d '{"id": "test-id"}'

Powinniśmy zobaczyć wiadomość że operacja się udała. Ale jak sprawdzić czy coś rzeczywiście jest w naszej bazie? Możemy użyć do tego aws-cli:

aws dynamodb scan --table-name items --endpoint-url http://localhost:4569 --output json

Jako ćwiczenie zachęcam do dodania endpointu z metodą GET.

Nasza aplikacja działa w kompletnej izolacji. Możemy teraz napisać do niej przykładowy test tests-integration/index.spec.js:

'use strict';

const {DynamoDB} = require('aws-sdk');
const axios = require('axios');
const {expect} = require('chai');

const {hasItem} = createDynamoClient();

describe('items http endpoint should', () => {
    const id = 'test-id';

    const item = {
        id,
        some: 'value',
    };

    it('create new item', async () => {
        const {status} = await axios.post('http://localhost:3000', item);

        expect(status).to.equal(200);
        expect(await hasItem(id)).to.equal(true);
    });
});

function createDynamoClient() {
    const docClient = new DynamoDB.DocumentClient({
        region: 'eu-central-1',
        endpoint: 'http://localhost:4569', // !! tests are hitting localhost, not localstack!
    });

    async function hasItem(id) {
        return docClient.get({
            TableName: 'items',
            Key: {
                id,
            },
        }).promise().then(res => !!res);
    }

    return {
        hasItem,
    };
}

Zapiszmy powyższy plik w folderze test-integration, teraz możemy odpalić powyższy kod np. za pomocą mocha:

npm i --save-dev mocha axios chai
mocha ./tests-integration

Kompletny kod powyższego przykładu możemy znaleźc w repozytorium localstack-sample-micro.

Bardziej rozbudowany przykład znajduje się w repozytorium localstack-sample Jest tam aplikacja z kilkoma endpointami zapisującymi do DynamoDB, a także kolejka SQS, z której wiadomości są także umieszczane w bazie. Testy i konfiguracja aplikacji też jest zdecydowanie bardziej rozbudowana.

Podsumowanie

To by było na tyle. Początkowe kroki z localstackiem mogą być irytujące i można by uznać że nakład pracy włożony w konfigurację się nie zwróci, ale z chwilą kiedy zapoznamy się lepiej z tym narzędziem może ono nam naprawdę ułatwić życie i przyspieszyć naszą pracę.

Programista z domieszką DevOps, lubię wszystko co związane z technologiami ułatwiającymi nam pracę. Głównie zajmuję się pisaniem microserviców oraz ich procesem deploymentu do clouda. A pozatym to motocykl, planszówki, seriale i dobra książka.
PODZIEL SIĘ