LocalStack provides an easy-to-use test/mocking framework for developing AWS applications. In this post, I’ll demonstrate how to utilize LocalStack for development using a web service.
Specifically a simple web service built with Flask-RestPlus is used. It supports simple CRUD operations against a database table. It is set that SQS and Lambda are used for creating and updating a record. When a POST or PUT request is made, the service sends a message to a SQS queue and directly returns 204 reponse. Once a message is received, a Lambda function is invoked and a relevant database operation is performed.
The source of this post can be found here.
Web Service
As usual, the GET requests returns all records or a single record when an ID is provided as a path parameter. When an ID is not specified, it’ll create a new record (POST). Otherwise it’ll update an existing record (PUT). Note that both the POST and PUT method send a message and directly returns 204 response - the source can be found here.
1ns = Namespace("records")
2
3@ns.route("/")
4class Records(Resource):
5 parser = ns.parser()
6 parser.add_argument("message", type=str, required=True)
7
8 def get(self):
9 """
10 Get all records
11 """
12 conn = conn_db()
13 cur = conn.cursor(real_dict_cursor=True)
14 cur.execute(
15 """
16 SELECT * FROM records ORDER BY created_on DESC
17 """)
18
19 records = cur.fetchall()
20 return jsonify(records)
21
22 @ns.expect(parser)
23 def post(self):
24 """
25 Create a record via queue
26 """
27 try:
28 body = {
29 "id": None,
30 "message": self.parser.parse_args()["message"]
31 }
32 send_message(flask.current_app.config["QUEUE_NAME"], json.dumps(body))
33 return "", 204
34 except Exception as e:
35 return "", 500
36
37
38@ns.route("/<string:id>")
39class Record(Resource):
40 parser = ns.parser()
41 parser.add_argument("message", type=str, required=True)
42
43 def get(self, id):
44 """
45 Get a record given id
46 """
47 record = Record.get_record(id)
48 if record is None:
49 return {"message": "No record"}, 404
50 return jsonify(record)
51
52 @ns.expect(parser)
53 def put(self, id):
54 """
55 Update a record via queue
56 """
57 record = Record.get_record(id)
58 if record is None:
59 return {"message": "No record"}, 404
60
61 try:
62 message = {
63 "id": record["id"],
64 "message": self.parser.parse_args()["message"]
65 }
66 send_message(flask.current_app.config["QUEUE_NAME"], json.dumps(message))
67 return "", 204
68 except Exception as e:
69 return "", 500
70
71 @staticmethod
72 def get_record(id):
73 conn = conn_db()
74 cur = conn.cursor(real_dict_cursor=True)
75 cur.execute(
76 """
77 SELECT * FROM records WHERE id = %(id)s
78 """, {"id": id})
79
80 return cur.fetchone()
Lambda
The SQS queue that messages are sent by the web service is an event source of the following lambda function. It polls the queue and processes messages as shown below.
1import os
2import logging
3import json
4import psycopg2
5
6logger = logging.getLogger()
7logger.setLevel(logging.INFO)
8
9try:
10 conn = psycopg2.connect(os.environ["DB_CONNECT"], connect_timeout=5)
11except psycopg2.Error as e:
12 logger.error(e)
13 sys.exit()
14
15logger.info("SUCCESS: Connection to DB")
16
17def lambda_handler(event, context):
18 for r in event["Records"]:
19 body = json.loads(r["body"])
20 logger.info("Body: {0}".format(body))
21 with conn.cursor() as cur:
22 if body["id"] is None:
23 cur.execute(
24 """
25 INSERT INTO records (message) VALUES (%(message)s)
26 """, {k:v for k,v in body.items() if v is not None})
27 else:
28 cur.execute(
29 """
30 UPDATE records
31 SET message = %(message)s
32 WHERE id = %(id)s
33 """, body)
34 conn.commit()
35
36 logger.info("SUCCESS: Processing {0} records".format(len(event["Records"])))
Database
As RDS is not yet supported by LocalStack, a postgres db is created with Docker. The web service will do CRUD operations against the table named as records. The initialization SQL script is shown below.
1CREATE DATABASE testdb;
2\connect testdb;
3
4CREATE SCHEMA testschema;
5GRANT ALL ON SCHEMA testschema TO testuser;
6
7-- change search_path on a connection-level
8SET search_path TO testschema;
9
10-- change search_path on a database-level
11ALTER database "testdb" SET search_path TO testschema;
12
13CREATE TABLE testschema.records (
14 id serial NOT NULL,
15 message varchar(30) NOT NULL,
16 created_on timestamptz NOT NULL DEFAULT now(),
17 CONSTRAINT records_pkey PRIMARY KEY (id)
18);
19
20INSERT INTO testschema.records (message)
21VALUES ('foo'), ('bar'), ('baz');
Launch Services
Below shows the docker-compose file that creates local AWS services and postgres database.
1version: '3.7'
2services:
3 localstack:
4 image: localstack/localstack
5 ports:
6 - '4563-4584:4563-4584'
7 - '8080:8080'
8 privileged: true
9 environment:
10 - SERVICES=s3,sqs,lambda
11 - DEBUG=1
12 - DATA_DIR=/tmp/localstack/data
13 - DEFAULT_REGION=ap-southeast-2
14 - LAMBDA_EXECUTOR=docker-reuse
15 - LAMBDA_REMOTE_DOCKER=false
16 - LAMBDA_DOCKER_NETWORK=play-localstack_default
17 - AWS_ACCESS_KEY_ID=foobar
18 - AWS_SECRET_ACCESS_KEY=foobar
19 - AWS_DEFAULT_REGION=ap-southeast-2
20 - DB_CONNECT='postgresql://testuser:testpass@postgres:5432/testdb'
21 - TEST_QUEUE=test-queue
22 - TEST_LAMBDA=test-lambda
23 volumes:
24 - ./init/create-resources.sh:/docker-entrypoint-initaws.d/create-resources.sh
25 - ./init/lambda_package:/tmp/lambda_package
26 # - './.localstack:/tmp/localstack'
27 - '/var/run/docker.sock:/var/run/docker.sock'
28 postgres:
29 image: postgres
30 ports:
31 - 5432:5432
32 volumes:
33 - ./init/db:/docker-entrypoint-initdb.d
34 depends_on:
35 - localstack
36 environment:
37 - POSTGRES_USER=testuser
38 - POSTGRES_PASSWORD=testpass
For LocalStack, it’s easier to illustrate by the environment variables.
- SERVICES - S3, SQS and Lambda services are selected
- DEFAULT_REGION - Local AWS resources will be created in ap-southeast-2 by default
- LAMBDA_EXECUTOR - By selecting docker-reuse, Lambda function will be invoked by another container (based on lambci/lambda image). Once a container is created, it’ll be reused. Note that, in order to invoke a Lambda function in a separate Docker container, it should run in privileged mode (privileged: true)
- LAMBDA_REMOTE_DOCKER - It is set to false so that a Lambda function package can be added from a local path instead of a zip file.
- LAMBDA_DOCKER_NETWORK - Although the Lambda function is invoked in a separate container, it should be able to discover the database service (postgres). By default, Docker Compose creates a network (
<parent-folder>_default
) and, specifying the network name, the Lambda function can connect to the database with the DNS set by DB_CONNECT
Actual AWS resources is created by create-resources.sh, which will be executed at startup. A SQS queue and Lambda function are created and the queue is mapped to be an event source of the Lambda function.
1#!/bin/bash
2
3echo "Creating $TEST_QUEUE and $TEST_LAMBDA"
4
5aws --endpoint-url=http://localhost:4576 sqs create-queue \
6 --queue-name $TEST_QUEUE
7
8aws --endpoint-url=http://localhost:4574 lambda create-function \
9 --function-name $TEST_LAMBDA \
10 --code S3Bucket="__local__",S3Key="/tmp/lambda_package" \
11 --runtime python3.6 \
12 --environment Variables="{DB_CONNECT=$DB_CONNECT}" \
13 --role arn:aws:lambda:ap-southeast-2:000000000000:function:$TEST_LAMBDA \
14 --handler lambda_function.lambda_handler \
15
16aws --endpoint-url=http://localhost:4574 lambda create-event-source-mapping \
17 --function-name $TEST_LAMBDA \
18 --event-source-arn arn:aws:sqs:elasticmq:000000000000:$TEST_QUEUE
The services can be launched as following.
1docker-compose up -d
Test Web Service
Before testing the web service, it can be shown how the SQS and Lambda work by sending a message as following.
1aws --endpoint-url http://localhost:4576 sqs send-message \
2 --queue-url http://localhost:4576/queue/test-queue \
3 --message-body '{"id": null, "message": "test"}'
As shown in the image below, LocalStack invokes the Lambda function in a separate Docker container.
The web service can be started as following.
1FLASK_APP=api FLASK_ENV=development flask run
Using HttPie, the record created just before can be checked as following.
1http http://localhost:5000/api/records/4
1{
2 "created_on": "2019-07-20T04:26:33.048841+00:00",
3 "id": 4,
4 "message": "test"
5}
For updating it,
1echo '{"message": "test put"}' | \
2 http PUT http://localhost:5000/api/records/4
3
4http http://localhost:5000/api/records/4
1{
2 "created_on": "2019-07-20T04:26:33.048841+00:00",
3 "id": 4,
4 "message": "test put"
5}
Comments