Using serverless Ruby on AWS Lambda to resize images

At the last AWS ReInvent, it was announced that AWS Lambda would support Ruby as a runtime language. I was eager to try this out, Ruby’s powerful syntax and features are a joy to work with and coupling this with AWS Lambda I figured it could be leveraged for some easy image resizing Lambda.

I started off with the serverless framework as this is an easy way provision Lambda functions. The goal is that when an image is uploaded to an S3 bucket, a Lambda is started, it resizes the image, and then uploads it to another bucket.

The first step is to install serverless

npm install -g serverless
serverless create -t aws-ruby -p image_resizer

This creates the basic boilerplate of a serverless framework ruby Lambda function. It will create two files, a handler.rb, the file with the actual function the Lambda will call, and a serverless.yml a file that the serverless framework uses to configure and provision the lambda and affiliated services such as AWS S3 and AWS API Gateway.

We will first change the serverless.yml configuration. We add a function handle_resize that calls the ImageHandler.process function in handler.rb whenever an object is created in the your-images S3 bucket. Furthermore, under the provider key, we grant the function access to S3 and use the ruby2.5 runtime.

We also add an S3 resource resized-your-images, this will be the bucket where we moved the resized images to.

service: image-resizer 

provider:
  name: aws
  runtime: ruby2.5
  iamRoleStatements:
    - Effect: Allow
      Action:
        - s3:*
      Resource: "*"

functions:
   handle_resize:
     handler: handler.ImageHandler.process
     events:
       - s3:
           bucket: your-images
           event: s3:ObjectCreated:*

resources:
  Resources:
    ResizedImages:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: resized-your-images

Now we can implement the ImageHandler.process function.

Writing the Ruby lambda

First we’ll add a Gemfile because of two dependencies we need to use. The AWS S3 SDK to retrieve and upload files to S3, and the mini_magick gem, a wrapper for imagemagick.

#Gemfile
source 'https://rubygems.org'

gem 'aws-sdk-s3', '~> 1.30.0'
gem "mini_magick", '~> 4.9.0'

Next we update the handler.rb file.

#handler.rb
require 'uploaded_file'

class ImageHandler

  def self.process(event:, context:)
    event = event["Records"].first
    bucket_name = event["s3"]["bucket"]["name"]
    object_name = event["s3"]["object"]["key"]

    file = UploadedFile.from_s3(bucket_name, object_name)
    file.resize "100x100"
    file.upload_file("resized-your-images", "resized_" + event["s3"]["object"]["key"] )
  end
end

The ImageHandler.process is the function called by the lambda and has two arguments, the event, in our case an event sent by S3 about newly created files, and the context, basically metadata about the function and its environment.

The process function retrieves the bucket and key of the newly created object from the event object. Then it calls into the not yet implemented UploadedFile class. This class retrieves the s3 object, resizes it and then uploads it to another bucket. This class is independent from the lambda, and in general all business logic should be. This makes it easier to use the code in other contexts and also allows easier testing. E.g. I can test the UploadedFile.from_s3(bucket_name, object_name) without having to mock the entire S3 Lambda event.

Now we’ll implement the UploadedFile class.

# uploaded_file.rb
require "aws-sdk-s3"
require "mini_magick"

class UploadedFile
    def self.from_s3(bucket_name, object_name)
        s3 = Aws::S3::Resource.new()
        object = s3.bucket(bucket_name).object(object_name)

        tmp_file_name = "/tmp/#{object_name}"
        object.get(response_target: tmp_file_name)
        
        UploadedFile.new(tmp_file_name)
    end

    def initialize(tmp_file)
        @tmp_file = tmp_file
    end

    def resize(params)
        image = MiniMagick::Image.open(@tmp_file)
        image.resize params
        @resized_tmp_file = "/tmp/resized.jpg"
        image.write @resized_tmp_file
    end

    def upload_file(target_bucket, target_object)
        s3 = Aws::S3::Resource.new()
        object = s3.bucket(target_bucket).object(target_object).upload_file(@resized_tmp_file)
    end
end

The uploaded_file.rb is typical ruby. It retrieves the S3 file, stores it in a tmp-file. We call mini_magick to resize the image, and then uploads the file again.

Deployment

When deploying we need to make sure that the Lambda function has access to the dependencies. Serverless framework will build a zip-file with the code and all dependencies locally and then deploy this to AWS. Therefore it is important that we run the same version of ruby locally as on AWS, so that would be ruby 2.5. If you use rbenv, you can use rbenv install 2.5.0 and rbenv local 2.5.0 to set the ruby version for this project.

Next we need to vendorize the dependencies so they are included in the package. Run bundle install --path vendor/bundle, now the dependencies are in the vendor directory.

Before we can deploy we still need to set the credentials for AWS. See Serverless Framework documentation for more information.

Now that is done you can invoke sls deploy and the lambda function will be deployed and the S3 buckets will be created. Upload an image to the your-images bucket and moments later a resized version will appear in the resized-your-images bucket. If anything goes wrong you can follow that in the log files in AWS Cloudwatch. There will be a log stream for the lambda function. You can also call sls logs -f handle_resize to tail the logs.

Find the code on github

Photo by Paulius Dragunas on Unsplash