Real-time Image Analysis with AWS Step Functions and Amazon Rekognition - (Let's Build 🏗️ Series)

awedis

awedis

Posted on November 12, 2023

Real-time Image Analysis with AWS Step Functions and Amazon Rekognition - (Let's Build 🏗️ Series)

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 😉

Image description

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }]
  })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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",
      ...
    },
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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"`
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

📋 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 cmd echo %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

Image description

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

The image we upload
Image description

Amazon EventBridge > Rules > s3-upload-event-rule > Monitoring
Image description

AWS Step Functions
Image description

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 😊💁

💖 💪 🙅 🚩
awedis
awedis

Posted on November 12, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related