Let's build a Real-time image analysis using AWS Step Functions and Amazon Rekognition.
The main parts of this article:
1- Architecture Overview (Terraform)
2- About AWS Services (Info)
3- Technical Part (Code)
4- Result
5- Conclusion
๐ Note: There is a simple article that might be helpful as well. Trigger Lambda Function When New Image is Uploaded to S3 - (Let's Build ๐๏ธ Series)
Architecture Overview
Bear with me this part will be a long one, but there is a lot to learn from it ๐
To make our IaC more organized I'll split the code into multiple files:
variables.tf
main.tf
lambda.tf
iam.lambda.tf
iam.step-function.tf
iam.eventbridge.tf
Before going through the code, let's discuss about our infra. So we are going to create a state machine as a target for an Amazon EventBridge rule, this rule will start a state machine execution when files are added to the S3 bucket. And inside the AWS Step Function (state machine) we will orchestrate two Lambda functions.
variables.tf (The variables that will be used in our IaC)
variable "aws_account_id" {
default = "<<YOUR_AWS_ACCOUNT_ID>>"
description = "AWS Account ID"
}
variable "region" {
default = "eu-west-1"
description = "AWS Region"
}
main.tf (We point to an existing bucket, create the AWS Step Functions, EventBridge rule to trigger when images are uploaded to S3, and EventBridge target to run our Step Function)
terraform {
required_version = "1.5.1"
required_providers {
aws = {
source = "hashicorp/aws"
version = "5.22.0"
}
}
}
data "aws_s3_bucket" "existing_bucket" {
bucket = "lets-build-1"
}
provider "aws" {
region = var.region
}
resource "aws_sfn_state_machine" "sfn_state_machine" {
name = "my-state-machine"
role_arn = aws_iam_role.step_function_role.arn
type = "STANDARD"
definition = <<EOF
{
"Comment": "AWS Step Functions",
"StartAt": "First",
"States": {
"First": {
"Type": "Task",
"Resource": "${aws_lambda_function.start_lambda.arn}",
"Next": "Second"
},
"Second": {
"Type": "Task",
"Resource": "${aws_lambda_function.end_lambda.arn}",
"Next": "success"
},
"success": {
"Type": "Succeed"
}
}
}
EOF
tags = {
Module = "my"
}
}
resource "aws_cloudwatch_event_rule" "s3_upload_rule" {
name = "s3-upload-event-rule"
description = "Trigger EventBridge for S3 Uploads"
event_pattern = jsonencode({
source = ["aws.s3"],
detail-type = ["Object Created"],
detail = {
bucket = {
name = ["lets-build-1"]
}
}
})
}
resource "aws_cloudwatch_event_target" "step-functions" {
rule = aws_cloudwatch_event_rule.s3_upload_rule.name
target_id = "SendToStepFunctions"
arn = aws_sfn_state_machine.sfn_state_machine.arn
role_arn = aws_iam_role.eventbridge_rule_role.arn
}
lambda.tf (We configure the log groups for our 2 Lambda functions, the IAM permissions that are needed, and we create the 2 Lambda functions, that can run go code. Also note that each one is pointing to a different source code in our case the func-1.zip
and func-2.zip
files which we will discuss in later stages of this article)
resource "aws_cloudwatch_log_group" "start_log_group" {
name = "/aws/lambda/${aws_lambda_function.start_lambda.function_name}"
retention_in_days = 7
lifecycle {
prevent_destroy = false
}
}
resource "aws_cloudwatch_log_group" "end_log_group" {
name = "/aws/lambda/${aws_lambda_function.end_lambda.function_name}"
retention_in_days = 7
lifecycle {
prevent_destroy = false
}
}
resource "aws_iam_policy" "function_logging_policy" {
name = "function-logging-policy"
policy = jsonencode({
"Version" : "2012-10-17",
"Statement" : [
{
Action: [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
Effect: "Allow",
Resource: "arn:aws:logs:*:*:*"
},
{
Effect: "Allow",
Action: "rekognition:DetectLabels",
Resource: "*"
},
{
Effect: "Allow",
Action: [
"s3:GetObject",
"s3:ListBucket"
],
Resource: [
"arn:aws:s3:::lets-build-1",
"arn:aws:s3:::lets-build-1/*"
]
}
]
})
}
resource "aws_iam_role_policy_attachment" "function_logging_policy_attachment" {
role = aws_iam_role.lambda_role.id
policy_arn = aws_iam_policy.function_logging_policy.arn
}
resource "aws_lambda_function" "start_lambda" {
filename = "./func-1.zip"
function_name = "startLambda"
handler = "main"
runtime = "go1.x"
role = aws_iam_role.lambda_role.arn
memory_size = "128"
timeout = "3"
source_code_hash = filebase64sha256("./func-1.zip")
environment {
variables = {
REGION = "${var.region}"
}
}
}
resource "aws_lambda_function" "end_lambda" {
filename = "./func-2.zip"
function_name = "endLambda"
handler = "main"
runtime = "go1.x"
role = aws_iam_role.lambda_role.arn
memory_size = "128"
timeout = "3"
source_code_hash = filebase64sha256("./func-2.zip")
environment {
variables = {
REGION = "${var.region}"
}
}
}
iam.lambda.tf (Here we create the STS assume role, so that our Lambda functions are allowed to assume role)
resource "aws_iam_role" "lambda_role" {
name = "lambda_role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [{
Action = "sts:AssumeRole",
Effect = "Allow",
Principal = {
Service = "lambda.amazonaws.com"
}
}]
})
}
iam.step-function.tf (Again first we create the assume role for AWS Step Functions, note we can combine this block of code with the previous one but I like to keep them separate in case different permissions was required. We also have the permission for our Step Function which should be able to invoke Lamdba functions)
resource "aws_iam_role" "step_function_role" {
name = "step_function_role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [{
Action = "sts:AssumeRole",
Effect = "Allow",
Principal : {
Service : "states.amazonaws.com"
},
}]
})
}
resource "aws_iam_policy" "step_function_policy" {
name = "step_function_policy"
description = "Policy for EventBridge target"
policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:${var.region}:${var.aws_account_id}:function:*"
}
]
}
EOF
}
resource "aws_iam_role_policy_attachment" "step_function_policy_attachment" {
policy_arn = aws_iam_policy.step_function_policy.arn
role = aws_iam_role.step_function_role.name
}
iam.eventbridge.tf (Finally in this part we allow the Amazon EventBridge to be able to start AWS Step Functions)
resource "aws_iam_role" "eventbridge_rule_role" {
name = "eventbridge_rule_role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Action = "sts:AssumeRole",
Effect = "Allow",
Principal = {
Service = "events.amazonaws.com"
}
}
]
})
}
resource "aws_iam_policy" "eventbridge_target_policy" {
name = "eventbridge_target_policy"
description = "Policy for EventBridge target"
policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "states:StartExecution",
"Resource": "arn:aws:states:${var.region}:${var.aws_account_id}:stateMachine:my-state-machine"
}
]
}
EOF
}
resource "aws_iam_role_policy_attachment" "eventbridge_target_policy_attachment" {
policy_arn = aws_iam_policy.eventbridge_target_policy.arn
role = aws_iam_role.eventbridge_rule_role.name
}
About AWS Services
1- Amazon S3: To upload the image
2- Amazon EventBridge: Rule and Target to trigger AWS Step Functions
3- AWS Step Functions: To orchestrate our two Lambda functions
4- AWS Lambda: Which holds the code and the business logic
4- AWS IAM: For all the permissions inside the AWS cloud
๐ Note: If you want to dive deep into AWS Step Functions, here is a link to one of my articles which can be really helpful
Technical Part
Now the code part ๐ as said before, we have two lambda functions. The first one will extract the image using Rekognition
and will pass the data to the second Lambda, and in order to keep things simple my second function will only print that data to CloudWatch, and then return an object holding SUCCESS
to end the AWS Step Functions successfully.
First, let's see our input that is being passed from Amazon EventBridge and received on the AWS Step Functions:
{
"version": "0",
"source": "aws.s3",
"region": "eu-west-1",
...
"resources": [
"arn:aws:s3:::lets-build-1"
],
"detail": {
"version": "0",
"bucket": {
"name": "lets-build-1"
},
"object": {
"key": "assets/golang-gopher.png",
...
},
...
}
}
As we can see the data is pretty accurate, and we are able to retrieve the object from the S3 bucket and run some execution on it.
Now the types inside our first Lambda function:
type S3EventDetail struct {
Bucket struct {
Name string `json:"name"`
} `json:"bucket"`
Object struct {
Key string `json:"key"`
Size int `json:"size"`
ETag string `json:"etag"`
Sequencer string `json:"sequencer"`
} `json:"object"`
}
type S3Event struct {
Version string `json:"version"`
ID string `json:"id"`
DetailType string `json:"detail-type"`
Source string `json:"source"`
Account string `json:"account"`
Time string `json:"time"`
Region string `json:"region"`
Resources []string `json:"resources"`
Detail S3EventDetail `json:"detail"`
}
Before building the image recognition part, as I like most of the time is to build the first simple version of my code, which will only retrieve the image from our bucket and then do some logging, after we pass from this step we are good to build and scale our code. ๐จโ๐ป
package main
import (
"context"
"log"
"os"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
func handler(ctx context.Context, event S3Event) error {
bucket := event.Detail.Bucket.Name
key := event.Detail.Object.Key
sess, err := session.NewSession(&aws.Config{
Region: aws.String(event.Region),
})
if err != nil {
log.Fatal("Failed to create AWS session:", err)
os.Exit(1)
}
s3Client := s3.New(sess)
output, err = s3Client.GetObjectWithContext(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
log.Fatal(err)
}
log.Println("output =>", output)
return nil
}
func main() {
lambda.Start(handler)
}
Let's give it a try, we are reaching the Rekognition part "The Fancy Section" ๐, but first we need to give it a try and make our code run as we expect before adding new components to it.
In order to build the code, we will run the following command:
GOARCH=amd64 GOOS=linux go build -o main
๐ Note: In case you have issues with the two parameters that I'm passing in the command, you can still save the params in your terminal by running the following,
set GOARCH=amd64
&set GOOS=linux
and in order to be sure if things are good you can print the value using this cmdecho %GOARCH%
Once I upload my built code as .zip
file, we can test it by uploading an image to my S3 bucket and seeing if the Step Function will be triggered and my Lambda will execute the code. As we can see below the output
from CloudWatch
Nice so we are all good. Let's proceed and finalize our solution. Now we will add the rekognition part, for this example I'm going to use DetectLabelsWithContext
, it's up to you to use any other function if you see it's better for your needs.
package main
import (
"context"
"log"
"os"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/rekognition"
"github.com/aws/aws-sdk-go/service/s3"
)
func handler(ctx context.Context, event S3Event) (interface{}, error) {
bucket := event.Detail.Bucket.Name
key := event.Detail.Object.Key
sess, err := session.NewSession(&aws.Config{
Region: aws.String(event.Region),
})
if err != nil {
log.Fatal("Failed to create AWS session:", err)
os.Exit(1)
}
s3Client := s3.New(sess)
rekognitionClient := rekognition.New(sess)
_, err = s3Client.GetObjectWithContext(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
log.Fatal(err)
}
detectLabelsOutput, err := rekognitionClient.DetectLabelsWithContext(ctx, &rekognition.DetectLabelsInput{
Image: &rekognition.Image{
S3Object: &rekognition.S3Object{
Bucket: aws.String(bucket),
Name: aws.String(key),
},
},
})
if err != nil {
log.Fatal(err)
}
return detectLabelsOutput.Labels, nil
}
func main() {
lambda.Start(handler)
}
Now let's add our second Lambda code as well. As I said it will be a simple logging function, however, in this part, you may store the data somewhere, or even you can send a notification or message.
package main
import (
"context"
"fmt"
"github.com/aws/aws-lambda-go/lambda"
)
type MyObject struct {
Status string `json:"status"`
}
func handler(ctx context.Context, event interface{}) (MyObject, error) {
fmt.Println("this is second function", event)
object := MyObject{
Status: "SUCCESS",
}
return object, nil
}
func main() {
lambda.Start(handler)
}
Result
To test our prototype let's upload an image to S3 bucket, once we upload the file, we can see the EventBridge has been triggered which will trigger our Step Function. Here are some screenshots from the AWS console.
Amazon EventBridge > Rules > s3-upload-event-rule > Monitoring
We can see the output ๐คฉ (it's a bit long object so I'm not going to share the whole output), but we can see already it was able to detect that there is a bike in the picture, and relates to hobbies.
Conclusion
This article helps you to build a nice architecture using very powerful services like Amazon EventBridge, Amazon Rekognition, and AWS Step Functions.
Let's imagine how many useful systems can be made in inspiration from this infrastructure. A simple example I can give; you can create a system that every time a user uploads a picture ๐ผ๏ธ of his car ๐ on a car-selling platform, you can run Amazon Rekognition to check if the content is valid or not, and based on that you can send notifications maybe or even save the data to an admin user to validate and go over it.
If you did like my content, and want to see more, feel free to connect with me on ๐คโก๏ธ Awedis LinkedIn, happy to guide or help anything that needs clarification ๐๐
Top comments (4)
great effort and a very good explanation ๐
It's my pleasure โบ๏ธ
A very clean and complete example of what you can do with serverless! Thanks for putting this together @awedis!
I'm glad that it was helpful :)