Real-time Image Analysis with AWS Step Functions and Amazon Rekognition - (Let's Build 🏗️ Series)
Tags: serverless, aws, terraform, go
Published: 2023-11-12 20:24:30

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)](https://dev.to/aws-builders/trigger-lambda-function-when-new-image-is-uploaded-to-s3-lets-build-series-foa)

## Architecture Overview
Bear with me this part will be a long one, but there is a lot to learn from it 😉

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pz19n18iahnqgfb354or.png)

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)
```hcl
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)
```hcl
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)
```hcl
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)
```hcl
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)
```hcl
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)
```hcl
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 ](https://dev.to/aws-builders/aws-step-functions-in-depth-serverless-5e4i) 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:
```json
{
  "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:
```go
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. 👨‍💻

```go
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 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/27e8mgyrnz6pckkqlupq.png)

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.

```go
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.

```go
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.

**The image we upload**
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hzum59sy17t4md8tlkqm.jpg)

**Amazon EventBridge > Rules > s3-upload-event-rule > Monitoring**
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g48xuftj0x2reaglrz0v.png)

**AWS Step Functions**
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zhj4omtlmkpnq3iuqlf6.png)

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](https://www.linkedin.com/in/awedis), happy to guide or help anything that needs clarification 😊💁