Sytuacja kobiet w IT w 2024 roku
3.02.202110 min
Jarosław Zaczyk

Jarosław ZaczykSenior Software EngineerEPAM Systems Poland

LocalStack – chmura AWS lokalnie

Poznaj Localstack, czyli narzędzie służące do symulowania usług chmurowych AWS w środowisku lokalnym.

LocalStack – chmura AWS lokalnie

W dzisiejszych czasach często słyszy się o wykorzystaniu chmury w procesach developerskich, o wdrażaniu aplikacji w chmurze, o ‘cloud computingu’ i wielu innych aspektach pracy programisty związanych z „chmurą”.

W tym artykule skupimy się na chmurze AWS, choć istnieją również inne (Google cloud czy ms azure). Istnieje zatem całkiem spore prawdopodobieństwo, że niektórzy czytelnicy już zetknęli się z tym problemem: „jak przetestować lokalnie zasoby chmurowe?”, „czy koniecznie muszę posiadać płatną instancję usługi chmurowej, żeby przetestować moją aplikację?”, „czy istnieje szybsze, tańsze rozwiązanie?”. 

W niniejszym artykule postaram się odpowiedzieć na w/w pytania i przedstawić narzędzie służące niejako do symulowania usług chmurowych AWS w środowisku lokalnym. Narzędzie, o którym mowa to LocalStack.

LocalStack dostarcza w pełni funkcjonalne lokalne API, które można wykorzystać w procesach developerskich/testerskich aplikacji chmurowych. Pełną listę usług LocalStack można znaleźć pod tym linkiem. lub na wcześniej wymienionej stronie repozytorium GitHub. W niniejszym artykule postaram się pokazać, jak zainstalować LocalStack przy użyciu obrazu dockera i pliku docker-compose oraz jak wykorzystać kolejki SQS (Amazon Simple Queue Service) w aplikacji Spring boot – konfiguracja aplikacji do współpracy z LocalStack, wysyłanie wiadomości do kolejki oraz pobieranie wiadomości z kolejki zarówno w aplikacji jak i przy użyciu poleceń linii komend dostarczanej przez AWS.

Instalacja docker-compose

W niniejszym artykule skupiono się na uruchomieniu LocalStack w obrębie kontenera dockerowego przy wykorzystaniu pliku docker-compose.yml. Konieczna jest więc instalacja docker-compose – narzędzia służącego do definiowania i uruchamiania wielokontenerowych aplikacji Dockera. Aby zainstalować docker-compose dla systemu Linux/MacOS należy wykonać następujące polecenia terminala:

sudo curl -L "https://github.com/docker/compose/releases/download/1.27.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose


Następnie wykonujemy polecenie docker-compose -v w celu walidacji instalacji. Powinniśmy uzyskać podobny rezultat (zależy on oczywiście od wesji docker-compose, którą zainstalowaliśmy):

docker-compose version 1.27.4, build 1110ad01

Przygotowanie pliku docker-compose.yml

Kolejnym krokiem niezbędnym do uruchomienia LocalStack jest przygotowanie odpowiedniego pliku docker-compose.yml. Plik ten powinien mieć następującą zawartość:

version: '3.7'
services:
  localstack:
    image: localstack/localstack
    restart: always
    container_name: localstack
    environment:
      - SERVICES=sqs
      - DATA_DIR=/tmp/localstack/data
      - DOCKER_HOST=unix:///var/run/docker.sock
    volumes:
      - "~/development/localstack:/~/development/localstack"
    ports:
      - "4566:4566"


W w/w pliku wykorzystujemy najnowszy obraz LocalStack. Polityka restartu ustawiona na „always” umożliwia automatyczny start kontenera przy starcie systemu (o ile docker startuje wraz z systemem) lub bezpośrednio po uruchomieniu silnika dockera. Nasz kontener nazywa się LocalStack i wykorzystuje kilka zmiennych środowiskowych (sekcja environment). Są to:

  1. SERVICES– zmienna ta przechowuje informację o tym, jakie usługi LocalStack mają zostać uruchomione (w naszym przypadku jest to sqs, ale można uruchamiać ich wiele, oddzielając ich nazwy przecinkami: sqs,sns,ssm itp.)
  2. DATA_DIR– katalog służący do persystencji danych (m. in. umożliwia zapis stworzonych kolejek, aby po restarcie kontenera nie trzeba było ich tworzyć na nowo)
  3. DOCKER_HOST– ścieżka do pliku docker.sock. Jest to gniazdo Unix, na którym domyślnie nasłuchuje deamon Dockera i które może być używane do komunikacji z demonem
    z poziomu kontenera


Ponieważ od wersji 0.11.0, dzięki tzw. edge service, LocalStack udostępnia wszystkie serwisy na jednym porcie (domyślnie 4566), przekierujemy sobie lokalny port 4566 na port kontenera – sekcja ports.

Tak przygotowany plik docker-compose.yml zapisujemy w wybranej przez nas lokalizacji, następnie uruchamiamy za pomocą polecenia docker-compose up. W oknie terminala zobaczymy logi produkowane przez kontener. Najważniejszym jest dla nas: localstack    | Ready

Wskazuje on na to, że wszystkie wybrane przez nas usługi zostały uruchomione i są gotowe do wykorzystania. Tak przygotowany kontener jest więc gotowy do działania. Aby się jednak upewnić, należy uruchomić drugi terminal i uruchomić polecenie curl localhost:4566 lub przejść pod ten adres w przeglądarce. Oczekiwanym rezultatem jest: {"status": "running"}

Tworzenie kolejek

Wykorzystajmy więc LocalStack w naszej prostej aplikacji Springowej. Wykorzystamy aws cli (do pobrania stąd), aby przy jego pomocy utworzyć kolejki, które wykorzystamy w naszej aplikacji. Na potrzeby artykułu stworzymy dwie – incoming_messages_queue, która będzie przechowywać wiadomości przychodzące z www oraz outcoming_messages_queue, która będzie służyć do przechowywania wiadomości, które zostaną pobrane przez www. Do stworzenia kolejek służy polecenie:

aws sqs create-queue --queue-name <queue_name> --region default --endpoint-url http://localhost:4566


W naszym przypadku wykonamy więc je dwa razy:

aws sqs create-queue --queue-name outcoming_messages_queue --region default --endpoint-url http://localhost:4566


oraz:

aws sqs create-queue --queue-name incoming_messages_queue --region default --endpoint-url http://localhost:4566


Po pomyślnym utworzeniu kolejki zobaczymy jej adres url:

{
    "QueueUrl": "http://localhost:4566/000000000000/<queue_name>"
}


Jest on niezbędny, aby operować na kolejce przy użyciu linii poleceń (co wykorzystamy w późniejszej części artykułu). Aby wyświetlić URL wszystkich kolejek wykonajmy polecenie:

aws sqs list-queues --region default --endpoint-url http://localhost:4566

Przygotowanie aplikacji Spring

Po utworzeniu kolejek możemy zająć się przygotowaniem aplikacji springowej. W tej wersji będziemy wykorzystywać JMS jako interfejs do komunikacji z kolejkami. Stwórzmy więc standardowy projekt Spring Boot z następującymi dodatkowymi zależnościami:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
   <groupId>org.springframework</groupId>
   <artifactId>spring-jms</artifactId>
   <version>5.3.3</version>
</dependency>

<dependency>
   <groupId>com.fasterxml.jackson.core</groupId>
   <artifactId>jackson-databind</artifactId>
   <version>2.12.1</version>
</dependency>

<dependency>
   <groupId>com.fasterxml.jackson.core</groupId>
   <artifactId>jackson-core</artifactId>
   <version>2.12.1</version>
</dependency>

<dependency>
   <groupId>com.fasterxml.jackson.core</groupId>
   <artifactId>jackson-annotations</artifactId>
   <version>2.12.1</version>
</dependency>

<dependency>
   <groupId>com.amazonaws</groupId>
   <artifactId>aws-java-sdk</artifactId>
   <version>1.11.939</version>
</dependency>

<dependency>
   <groupId>com.amazonaws</groupId>
   <artifactId>amazon-sqs-java-messaging-lib</artifactId>
   <version>1.0.8</version>
</dependency>


Spring web posłuży nam do ekspozycji endpointów REST, biblioteki jackson – do konwersji naszego obiektu domentowego do JSON-a, natomiast biblioteki aws dostarczą klas niezbędnych do nawiązania połączenia z LocalStack.

Kolejnym krokiem będzie przygotowanie kontrolera do wysyłania wiadomości i sprawdzania ostatniej wiadomości otrzymanej:

package pl.jz.localstack_jms.webaccess;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import pl.jz.localstack_jms.domain.model.Message;
import pl.jz.localstack_jms.domain.MessageConsumer;
import pl.jz.localstack_jms.domain.MessageProducer;

@RestController
public class MessageController {

    private final MessageConsumer messageConsumer;
    private final MessageProducer messageProducer;

    public MessageController(MessageConsumer messageConsumer, MessageProducer messageProducer) {
        this.messageConsumer = messageConsumer;
        this.messageProducer = messageProducer;
    }

    @GetMapping("/last-message")
    Message getLastMessage() {
        return messageConsumer.getLastReceivedMessage();
    }

    @PostMapping("/send-message/{text}")
    String sendMessage(@PathVariable String text) {
        messageProducer.sendMessage(text);
        return "message " + text + " sent";
    }
}


Następnie stwórzmy naszą klasę obiektu domenowego: 

package pl.jz.localstack_jms.domain.model;

public class Message {

    private final String text;

    public Message(String text) {
        this.text = text;
    }

    public String getText() {
        return text;
    }

}


Klasę tę będziemy wykorzystywać jako nośnik wiadomości oraz przykład konwersji obiektu do postaci JSON-a.

Klasa MessageConsumer jest wykorzystywana do przechowywania ostatniej wiadomości wysłanej do kolejki incoming_messages_queue. Zawartość tej klasy wygląda następująco: 

package pl.jz.localstack_jms.domain;

import pl.jz.localstack_jms.domain.model.Message;

public class MessageConsumer {

    private Message lastReceivedMessage = new Message("empty");

    public Message getLastReceivedMessage() {
        return lastReceivedMessage;
    }

    public void setLastReceivedMessage(Message lastReceivedMessage) {
        this.lastReceivedMessage = lastReceivedMessage;
    }
}


Klasa MessageProducer tworzy nowy obiekt domenowy i za pomocą interfejsu MessageSender wysyła go na kolejkę. Zawartość klasy MessageProducer

package pl.jz.localstack_jms.domain;

import pl.jz.localstack_jms.domain.model.Message;

public class MessageProducer {

    private final MessageSender messageSender;

    public MessageProducer(MessageSender messageSender) {
        this.messageSender = messageSender;
    }

    public void sendMessage(String text) {
        Message message = new Message(text);
        messageSender.send(message);
    }
}


MessageSender jest interfejsem zawierającym jedną metodę send. Wprowadzając go uzyskujemy odwrócenie zależności pomiędzy komponentami i możemy dowolnie zmieniać jego implementację bez ingerencji w kod klasy MessageProducer, która jest klasą zawierającą naszą logikę biznesową. W ten sposób komponent niższego rzędu nie ma wpływu na komponent wyższy.

package pl.jz.localstack_jms.domain;

import pl.jz.localstack_jms.domain.model.Message;

public interface MessageSender {
    void send(Message message);
}


W implementacji tego interfejsu będziemy wykorzystywać już konkretną bibliotekę JMS służącą do wysyłania wiadomości. Implementacja interfejsu MessageSender

package pl.jz.localstack_jms.senders;

import org.springframework.jms.core.JmsTemplate;
import pl.jz.localstack_jms.domain.MessageSender;
import pl.jz.localstack_jms.domain.model.Message;

public class JMSMessageSender implements MessageSender {

    private final JmsTemplate template;
    private final String queueName;

    public JMSMessageSender(JmsTemplate template, String queueName) {
        this.template = template;
        this.queueName = queueName;
    }

    @Override
    public void send(Message message) {
        template.convertAndSend(queueName, message);
    }

}


Dostarcza ona implementacji metody send oraz przy wykorzystaniu obiektu klasy JmsTemplate dokonuje konwersji obiektu do JSON-a i wysyła go do kolejki.

Nasłuch na kolejce inbound_messages_queue odbywa się przy wykorzystaniu dodatkowej klasy JMSMessageListener. Służy ona do pobrania wiadomości z kolejki, skonwertowania jej na nasz obiekt biznesowy oraz ustawienia jej w klasie MessageConsumer, skąd można ją pobrać za pomocą REST API (patrz kontroler). Zawartość klasy JMSMessageListener wygląda następująco:

package pl.jz.localstack_jms.receivers;

import org.springframework.jms.annotation.JmsListener;
import pl.jz.localstack_jms.domain.MessageConsumer;
import pl.jz.localstack_jms.domain.model.Message;

public class JMSMessageListener {

    private final MessageConsumer messageConsumer;

    public JMSMessageListener(MessageConsumer messageConsumer) {
        this.messageConsumer = messageConsumer;
    }

    @JmsListener(destination = "${incoming.queue.name}")
    void receiveMessage(Message message) {
        messageConsumer.setLastReceivedMessage(message);
    }
}


Mając tak przygotowane klasy możemy przystąpić do tworzenia klasy konfiguracyjnej, która zbierze wszystkie komponenty w całość i wstrzyknie odpowiednie zależności:

package pl.jz.localstack_jms.config;

import com.amazon.sqs.javamessaging.ProviderConfiguration;
import com.amazon.sqs.javamessaging.SQSConnectionFactory;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.client.builder.AwsClientBuilder;
import com.amazonaws.services.sqs.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jms.annotation.EnableJms;
import org.springframework.jms.config.DefaultJmsListenerContainerFactory;
import org.springframework.jms.core.JmsTemplate;
import org.springframework.jms.support.converter.MappingJackson2MessageConverter;
import org.springframework.jms.support.converter.MessageConverter;
import org.springframework.jms.support.converter.MessageType;
import org.springframework.jms.support.destination.DynamicDestinationResolver;
import pl.jz.localstack_jms.domain.MessageConsumer;
import pl.jz.localstack_jms.domain.MessageProducer;
import pl.jz.localstack_jms.receivers.JMSMessageListener;
import pl.jz.localstack_jms.senders.JMSMessageSender;

import javax.jms.Session;

@Configuration
@EnableJms
public class AppConfig {

    @Value("${outcoming.queue.name}")
    private String outcomingQueueName;

    @Bean
    public SQSConnectionFactory connectionFactory() {
        AmazonSQSAsync build = AmazonSQSAsyncClientBuilder.standard()
                .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials("x", "y")))
                .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration("http://localhost:4566", "default"))
                .build();

        return new SQSConnectionFactory(new ProviderConfiguration(), build);
    }

    @Bean
    public MessageConverter mappingJackson2MessageConverter() {
        MappingJackson2MessageConverter jackson2MessageConverter = new MappingJackson2MessageConverter();
        jackson2MessageConverter.setTargetType(MessageType.TEXT);
        jackson2MessageConverter.setTypeIdPropertyName("_type");
        return jackson2MessageConverter;
    }

    @Bean
    public DefaultJmsListenerContainerFactory jmsListenerContainerFactory(SQSConnectionFactory connectionFactory) {
        DefaultJmsListenerContainerFactory factory =
                new DefaultJmsListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        factory.setDestinationResolver(new DynamicDestinationResolver());
        factory.setConcurrency("3-10");
        factory.setSessionAcknowledgeMode(Session.CLIENT_ACKNOWLEDGE);
        return factory;
    }

    @Bean
    public JmsTemplate jmsTemplate(SQSConnectionFactory connectionFactory) {
        JmsTemplate jmsTemplate = new JmsTemplate(connectionFactory);
        jmsTemplate.setMessageConverter(mappingJackson2MessageConverter());
        return jmsTemplate;
    }

    @Bean
    public MessageProducer messageProducer(JmsTemplate jmsTemplate) {
        return new MessageProducer(new JMSMessageSender(jmsTemplate, outcomingQueueName));
    }

    @Bean
    public MessageConsumer messageConsumer() {
        return new MessageConsumer();
    }

    @Bean
    public JMSMessageListener jmsMessageListener() {
        return new JMSMessageListener(messageConsumer());
    }
}


Warto wspomnieć o tym, że w przypadku tworzenia beana klasy SQSConnectionFactory podanie kluczy dostępu w konstruktorze klasy BasicAWSCredentials jest wymagane, jednak podane wartości mogą być dowolne – są ignorowane przez LocalStack.

Należy również dodać do pliku application.properties nazwy kolejek: 

outcoming.queue.name=outcoming_messages_queue
incoming.queue.name=incoming_messages_queue


Możemy zatem uruchomić naszą aplikację i przetestować jej działanie. Na początku, za pomocą postmana pobierzmy domyślną wiadomość. W tym celu wykonamy zapytanie GET wykorzystując adres localhost:8080/last-message. Powinniśmy otrzymać następującą odpowiedź:

Następnie, wykorzystując wspomniane już wcześniej aws cli wyślijmy wiadomość do tej kolejki. W terminalu wykonujemy więc polecenie (tutaj niezbędna jest znajomość adresu url kolejki): 

aws sqs send-message --message-body "test" --queue-url "http://localhost:4566/000000000000/incoming_messages_queue" --region default --endpoint-url http://localhost:4566


Jako rezultat w konsoli powinniśmy otrzymać identyfikator wiadomości oraz jej sumę kontrolną MD5: 

{
    "MD5OfMessageBody": "c63a5cfce0a1819ebbe4a19e074356b6",
    "MessageId": "d89942ee-07a4-da92-0a40-6fb69f6034a0"
}


Następnie pobierzmy jeszcze raz ostatnio otrzymaną wiadomość. Rezultat powinien być następujący:

Sprawdźmy teraz funkcjonalność wysyłania wiadomości. Upewnijmy się najpierw, czy kolejka jest pusta. W tym celu wykonujemy następujące polecenie w terminalu:

aws sqs receive-message --queue-url "http://localhost:4566/000000000000/outcoming_messages_queue" --region default --endpoint-url http://localhost:4566    


Powinniśmy otrzymać pustą odpowiedź świadczącą o tym, że kolejka nie zawiera żadnych wiadomości. Wykonajmy zapytnie POST używając adresu http://localhost:8080/send/<text>, aby wysłać wiadomość.

Następnie wykonajmy ponownie polecenie sprawdzające zawartość kolejki. Tym razem powinniśmy otrzymać odpowiedź z wiadomością. Przykład wiadomości: 

{
    "Messages": [
        {
            "MessageId": "b9311346-c2ba-d768-afbd-9dd0611d1ce0",
            "ReceiptHandle": "nsexwdnngtwqknqtpqttflwankuxcuctbicsopgwqhrmuiqwxluudxlcmrndlpdwgflaeiujwmkbuqziobxnbflvlbbbfhumglpfuaanomlcjhvvzvrdsgkhmuzybqgziksnzgxzyytmibtborqgkqptapgleypmhvrgiocaebcpustxcamkecfix",
            "MD5OfBody": "9580fa7edff768c07e1a4965d82b2a78",
            "Body": "{\"text\":\"foobar\"}",
            "Attributes": {
                "SenderId": "AIDAIT2UOQQY3AUEKVGXU",
                "SentTimestamp": "1610977431920",
                "ApproximateReceiveCount": "1",
                "ApproximateFirstReceiveTimestamp": "1610977557669"
            }
        }
    ]
}

Podsumowanie

LocalStack umożliwia nam lokalne testowanie usług AWS bez konieczności łączenia się z serwerami Amazonu, co czyni proces tworzenia oprogramowania szybszym i niezależnym od stanu dostarczanej infrastruktury. LocalStack w prezentowanej wersji jest narzędziem darmowym.

<p>Loading...</p>