Links to the Github repo and to the website I have developed
The product
First things first. Before diving into the infrastructure, let’s check how the final web application looks like when in action.
Check it out at is-this-movie-a-thriller.com!
Pretty neat! Ok, time to have fun with AWS.
The idea
In this previous post, I have discussed how to define, train and deploy an image classifier in AWS SageMaker. The idea consisted in having a model capable of identifying whether a movie is a thriller/crime or not, by looking at its poster. I had concluded the tutorial by calling the SageMaker endpoint from a Jupyter notebook, testing it out on the 2005 movie Hostage, starring Bruce Willis. Everything worked pretty nicely. We had basically ended the discussion there, with an `InService` endpoint looking like the following.
Now, the question is, how do we make this endpoint accessible to the world? Differently stated, how do we serve the model in production?
We expose the endpoint in a web application. Translated in plain English, this means building a website which gives users the possibility to upload a poster of a movie, and get back a model’s prediction. Let’s lay down a diagram of the project.
- Users hit is-this-movie-a-thriller.com.
- Amazon Route 53 redirects traffic to an S3 bucket containing a static website.
- Users upload the poster of a movie and ask the web application to infer the genre.
- This action triggers a POST request to API Gateway.
- API Gateway triggers AWS Lambda.
- Lambda calls Amazon SageMaker, invoking the endpoint and passing the image. The model replies back with a prediction and the information flows back from SageMaker to Lambda to API Gateway to Route 53 to the end users.
The plan
Now that we have a project’s structure let’s figure out an execution plan. We’ll implement the pipeline backwards. The backend first, the frontend last. I am not sure whether this is the recommended way of approaching this kind of projects, as it is one of the first ones I work on. Regardless, it seemed natural to me to address it this way and, eventually, it worked pretty well.
- Make sure we have the right permissions: IAM Role.
- We already have the SageMaker endpoint in place, so we’ll start implementing the previous step in the chain: Lambda.
- Once the backbone of Lambda ready, it is rather obvious to work on what triggers it: API Gateway.
- With SageMaker, Lambda and API Gateway up and running, our backend is ready.
- At this point, we can start focusing on the frontend. We’ll need a website (
index.html
plus javascript/CSS files). The website files are going to be stored in an S3 bucket. - Eventually, we’ll buy a web domain on Route 53 and redirect traffic to the S3 bucket.
Creating an IAM Role with the right permissions
First things first. Nothing can happen within the AWS ecosystem without proper permissions. To make sure Lambda had the right privileges to access all the resources we need it to interact with, I created the `movies_thrillers-dev` IAM Role. Here how it looks like.
I experimented a lot with different AWS infrastructures. For each one of those, I tweaked a little bit the original role. This is why the policies attached to it look a bit messy. In the end, I made sure the role had Lambda and API Gateway full access (not completely sure this is actually needed; I have to test stricter permissions). As for `is-this-movie-a-thriller` and `movies_thrillers-dev`, those inline policies take care of SageMaker’s endpoint invocation and CloudWatch’s logging. Everything could be easily merged into a single JSON for compactness. Didn’t take the time to do that. Here the two separate custom policies for completeness.
############################################### ## is-this-movie-a-thriller ############################################### { "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": "sagemaker:InvokeEndpoint", "Resource": "sagemaker endpoint arn" } ] } ############################################### ## movies_thrillers-dev ############################################### { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "arn:aws:logs:*:*:*" } ] }
Setting up Lambda
Now that we have a proper IAM Role we can go ahead with Lambda. Creating a function is very easy. Navigate to the Lambda console, select `Create function`, Author from scratch
, pick a name ( movies_thrillers
), `Python 3.6` as runtime, and `movies_thrillers-dev` as IAM Role.
New functions, by default, have no triggers and return a dummy JSON response. Below the code you will find within the IDE. Nothing exciting.
import json def lambda_handler(event, context): # TODO implement return { 'statusCode': 200, 'body': json.dumps('Hello from Lambda!') }
I will directly skip to the final code of my Lambda function, keeping all the testing and related thoughts to the API Gateway section. Here what I came up with (you can just replace the dummy code with the below).
import base64, os, boto3, ast, json endpoint = 'is-thriller-movie-ep--2019-02-21-18-30-19' def format_response(message, status_code): return { 'statusCode': str(status_code), 'body': json.dumps(message), 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } } def lambda_handler(event, context): try: body = json.loads(event['body']) image = base64.b64decode(body['data'].replace('data:image/png;base64,', '')) runtime = boto3.Session().client(service_name='sagemaker-runtime', region_name='eu-west-1') response = runtime.invoke_endpoint(EndpointName=endpoint, ContentType='application/x-image', Body=image) probs = response['Body'].read().decode() probs = ast.literal_eval(probs) pred = probs.index(max(probs)) if pred == 0: resp = 'This is not a thriller/crime movie! Not sure you will like it...' else: resp = 'This is a thriller/crime movie! Enjoy!' return format_response(resp, 200) except: return format_response('Ouch! Something went wrong on the server side! Sorry about that', 200)
The code is encapsulated into a try-except
statement to avoid the function failing and returning nothing to the end user. In case of error, the frontend will show a message indicating that something went wrong server side. Bare in mind that in a real production environment you wouldn’t want to do that. What you want is to gracefully handle the exception, catch it and log it somewhere (CloudWatch does that automatically). This is not happening here. Whatever goes wrong in the `try` section, gets hidden by the exception
part and the error is not recorded anywhere. I wanted to close the loop fast and I skipped this implementation detail.
This is what happens within the `try` statement:
- Line 17: Load `event[‘body’]` in JSON format.
event
is what API Gateway passes along with the POST request. It contains several fields. `body` is among those, carrying the contents of the request itself. In our case we’ll setup the frontend to send over a `body` in the following format: `{‘data’: ‘image encoded in base64 format’}`. - Line 18: given what the frontend passes over, what we need to do is access `event[‘body’][‘data’]` and decode the image. This is what
base64.b64decode
is for. There is just one additional detail to consider. When encoding the image, javascript adds the string `’data:image/png;base64,’` as a prefix. Hence thebody['data'].replace('data:image/png;base64,', '')
to get rid of it. - Lines 20-22: this is where we invoke the SageMaker endpoint, pinging the decoded image and getting a list of two probabilities (`probs`) back.
- Lines 24-30 take care of converting
probs
to a list of floats and finding the index of the highest element. This can be either `0` (not thriller/crime) or1
(thriller/crime). As soon as the index (pred
) is found, the `resp` variable string is defined. - Line 32 is of paramount importance. It calls the `format_response` function which wraps
resp
into the appropriate JSON format requested by API Gateway (recall we are sending the output of Lambda back to the frontend via API Gateway). Specifically, lines 8-10, i.e. `‘headers’: {‘Content-Type’: ‘application/json’, ‘Access-Control-Allow-Origin’: ‘*’}` are the trick which helped me solve an incredibly annoying CORS-related issue. Those headers, plus enabling CORS on the API Gateway console, actually.
What is CORS and why does it matter? CORS stands from Cross Origin Resource Sharing. This is a mechanism getting triggered as soon as two resources sitting on different domains try sending data to each other. The two resources are the frontend, i.e. the website sitting on http://is-this-movie-a-thriller.com
, and the backend API Gateway, sitting on https://myapi.execute-api.eu-west-1.amazonaws.com
. When the API returns the output of Lambda to the frontend, the browser checks whether this communication is allowed by looking at the headers of the resource being sent. Specifically, the header in question is `‘Access-Control-Allow-Origin’: ‘*’`. If this is missing, the browser blocks the incoming JSON. Initially, I had just enabled CORS (more details in the next section) in the API Gateway console. This turned out not to be sufficient though. The output of Lambda needs to explicitly contain that header too. This is the reason behind using the `format_response` function.
Setting up API Gateway
We have a Lambda function. Now we need something triggering it, so time to have fun with API Gateway. For illustration purposes, I have created a mock API (`blog_post_test_api`) and a mock Lambda function (blog_post_test_lambda
– create this one first!), with the first triggering the second. Here a series of screenshots showcasing the entire process of API creation, Lambda integration, testing, and deployment. I have added notes to pictures’ captions.
How to test Lambda + API Gateway from localhost
There are multiple ways to test Lambda-API Gateway integration. We saw above how to do it via the API Gateway console. Other viable options are to use Postman or to invoke the API via the AWS Python SDK.
Last but not least, there is this nice trick I learned from this Medium post by Julien Simon. The idea is to fire up a PHP localhost server (so, yes, on your laptop) and ping the API from there. Here the commands. Open up a terminal and type
## THIS FIRES A LOCALHOST SERVER LISTENING ON PORT 8000 $ php -S localhost:8000 ## OPEN UP A NEW TERMINAL TAB AND TYPE $ export URL='THE URL OF YOUR API' ## WE SUBMIT THE POSTER FROM THE THRILLER/CRIME MOVIE HOSTAGE $ export PIC='hostage.jpg' ## WE SEND THE IMAGE OVER TO THE API $ (echo -n '{"data": "'; base64 $PIC; echo '"}') | curl -H "Content-Type: application/json" -d @- $URL ## IF EVERYTHING GOES AS PLANNED, YOU SHOULD SEE THIS OUTPUT ON SCREEN "This is a thriller/crime movie! Enjoy!"
Nice, this works too!
Creating an S3 Bucket to host a website
We are all set with the backend, so let’s switch to the frontend. Now, let’s pretend that our entire website is just a dummy HTML page stored in a file called `index.html` which looks like this
<h1>Is this movie a thriller?</h1>
Where do I need to store this file to make it accessible to the world?
A Public S3 bucket would be a great starting point. As the name suggests, public buckets are buckets whose objects are made available to anyone. This is a critical condition to be met, as users need to be able to access the website. Before creating the bucket, there is one detail to be kept in mind. Later on, we will want to register a web domain on Route 53 and point it to S3. In order to achieve that, the name of the domain needs to match exactly the bucket name, i.e. we cannot buy `my-awesome-website.com` and point it to `my-awesome-website` S3 endpoint. The strings need to match exactly. As S3 buckets’ names are global and unique it would be really unfortunate to pay for a domain, then go to S3, try creating a bucket with the same name and figure out that it is already taken. You are better off doing the following instead:
- Navigate to the Route 53 console (more details in the Route 53 section below) and check whether the domain you want to register is still available for sale.
- If this is the case, navigate to the S3 console and check if you can create a bucket with the exact same name. If it is available, do it and then go back to Route 53 to buy your domain.
This is what I did, and created the `is-this-movie-a-thriller.com` S3 bucket.
To avoid having to manually make every object I would upload public, I edited the bucket policy as the following screenshot (and policy) show.
# S3 BUCKET POLICY TO AUTOMATICALLY MAKE PUBLIC ANY OBJECT WE UPLOAD INTO IT { "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::is-this-movie-a-thriller.com/*" } ] }
Last but not least, I edited the bucket’s properties, setting it as a static website host. As showed below, this means that navigating to the S3 endpoint would invoke the `index.html` file contained in it.
We are all set here. Now we need to focus on the actual `html`, `javascript` and `css` code to run the website.
Building the website
As a result of the previous section, we have a public S3 bucket hosting a dummy HTML page. Next step is make that page functional for our purposes. Here what we would like our website to do:
- Users select a poster image from their filesystem.
- The poster image gets uploaded and previewed in the frontend within a `canvas` HTML element.
- Users click a `Check if it’s a thriller` button which:
- gets the image from the
canvas
. - converts the image into `base64` format (i.e. a string).
- sends the encoded image over to the backend API in a POST request.
- gets the image from the
- The backend responds back with a prediction, which gets displayed on the frontend within a `span` HTML element.
Let’s get to work. Here the files we need.
HTML: `index.html`
<!DOCTYPE html>
<html>
<!-- Favicon -->
<link rel="icon" href="favicon.ico" type="image/x-icon">
<head>
<h1>Is this movie a thriller?</h1>
<link rel="stylesheet" href="styles.css">
<link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>
</head>
<body>
<h3>Upload the poster and check it out!</h3>
<div class="input-container">
<input type="file" value="Choose a movie poster" id="inp" class="buttons" accept="image/*">
</br></br>
<canvas id="canvas"></canvas>
</br></br>
<input type="submit" value="Check if it's a thriller!" id="genre" class="buttons">
</br></br>
<span id="genreReturned"></span>
</div>
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.0/jquery.min.js"></script>
<script src="movies_scripts.js"></script>
</body>
</html>
Javascript: `movies_scripts.js`
var API_ENDPOINT = "the API Gateway endpoint" document.getElementById('inp').onchange = function(e) { var img = new Image(); img.onload = draw; img.onerror = failed; img.src = URL.createObjectURL(this.files[0]); }; function draw() { var canvas = document.getElementById('canvas'); canvas.width = this.width; canvas.height = this.height; var ctx = canvas.getContext('2d'); ctx.drawImage(this, 0,0); } function failed() { alert("The provided file couldn't be loaded as an Image media"); }; document.getElementById("genre").onclick = function(){ var canvas = document.getElementById("canvas") var image = canvas.toDataURL() var inputData = {"data": image}; $.ajax({ url: API_ENDPOINT, type: 'POST', crossDomain: true, data: JSON.stringify(inputData), dataType: 'json', contentType: "application/json", success: function (response) { document.getElementById("genreReturned").textContent = response; }, }); }
CSS: `styles.css`
.buttons { border : solid 0px #e6b215; border-radius : 8px; moz-border-radius : 8px; font-size : 16px; color : #ffffff; padding : 5px 18px; background-color : #FF9900; cursor:pointer; } .buttons:hover { background-color:#ffc477; } .buttons:active { position:relative; top:1px; } span { font-size: 120%; font-weight: bold; } h1, canvas, h3 { text-align: center; } .input-container { text-align: center; } body { font-family: 'Lato'; }
Putting everything together, the website looks like the following screenshot. Now we are only missing the last piece of the puzzle. Register a domain on Route 53 and redirect traffic from it to S3 (you might notice that in the below screenshot I am already using the is-this-movie-a-thriller.com domain and not the S3 endpoint).
Buy a domain on Route 53 and redirect traffic to S3
After everything we have accomplished so far, this is going to look like a stroll in a park. It all boils down to:
- Navigating to the Route 53 console.
- Registering (buying) a web domain.
- Pointing the domain to the S3 bucket with the static website.
Here the 3 steps with screenshots.
Testing the web application
We are at the end of our journey. We are only left with checking if everything fits in place as expected. Let’s test the website once again submitting the poster of the movie Hostage.
Great! This is working! That was quite a lot of work but we finally made it. We now have a fully functional end-to-end serverless web application exposing a SageMaker Deep Learning model to the world. Enjoy!