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:
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.)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)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.