Putting the database to sleep using Lambda - a Python developer’s first contact with Golang

Today’s blog is inspired by my AWS bill, my research list, one of Corey Quinns’ recent blog posts, and a talk by Uncle Bob I watched. While working with a customer, I set up a couple of RDS databases for performance tests. I shut them down after I was done with the intention of restarting them a few days later for additional tests.

Projects being projects, things got delayed a little bit, and after a while, I noticed that my monthly AWS bill was higher than usual. Sure enough, the database instances were running. I had forgotten that AWS will restart stopped RDS instances after 7 days to apply updates (or to mess with you). After shutting them down again, I decided to write a Lambda function to stop the DBs if they were started again. Since I was interested in learning Golang, I decided to use that for a change and take you with me on the journey.

I’m usually at home in the Python world, and here, I would have written something like this:

# Pseudocode
def lambda_handler(event, handler):
    rds_instances = get_rds_instances()
    for instance in rds_instances:
        if instanceShouldBeStopped(instance):
            stop_rds_instance(instance)

My goal was to replicate that in Go. I first needed to install the language on my Mac to get going. Note that I’m using a Mac with an ARM-based processor. This will become relevant later. Installing Go was a breeze using brew.

$ brew install golang

Having installed Go, I tried to write Hello World with code from go by examples:

package main

import "fmt"

func main() {
	fmt.Println("Hello World")
}

Writing the code worked, and running it was easy after I figured out that the .go suffix in the command was essential. If you omit it (like I did at first), you’ll see a package main is not in GOROOT error, which isn’t all too helpful.

$ go run main.go
Hello World

We can also compile the code to a binary and run it, but at this point, that’s just extra steps I’m not interested in. I want to build stuff. If we wanted to do that, this is how that works:

$ go build main.go
$ ./main
Hello World

First, I want to play around with the AWS SDK for Go and find a way to list my currently running RDS instances and their tags. Apparently, we should be able to just download it using the following command that’s documented on the SDK’s Github page. Well, that didn’t work. It wants a go.mod file, which is apparently used to track dependencies and their versions in a Go module.

$ go get github.com/aws/aws-sdk-go
go: go.mod file not found in current directory or any parent directory.
        'go get' is no longer supported outside a module.
        To build and install a command, use 'go install' with a version,
        like 'go install example.com/cmd@latest'
        For more information, see https://golang.org/doc/go-get-install-deprecation
        or run 'go help get' or 'go help install'.

In the official getting started docs, I found a command to do that: go mod init example/hello. Substituting the project name for my own allowed me to create a package and subsequently install the SDK:

$ go mod init mauricebrg/rds-sleep
go: creating new go.mod: module mauricebrg/rds-sleep
go: to add module requirements and sums:
        go mod tidy
$ go get github.com/aws/aws-sdk-go
go: downloading github.com/aws/aws-sdk-go v1.44.6
go: downloading github.com/jmespath/go-jmespath v0.4.0
go: added github.com/aws/aws-sdk-go v1.44.6
go: added github.com/jmespath/go-jmespath v0.4.0

Afterward, there are two more files in my directory. The go.mod seems to track the installed dependencies, and the go.sum appears to have checksums for each installed dependency. My next goal is to instantiate an RDS service client and list the database instances. After some playing around, I managed to do it:

package main

import (
	"fmt"
	"os"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/rds"
)

func main() {

	awsRegion := "eu-central-1"

	// Apparently, we need to create a session first
	// Must makes things crash if something goes wrong
	session := session.Must(session.NewSession(&aws.Config{Region: &awsRegion}))

	// We then use this session to get an rds client
	rdsClient := rds.New(session)

	response, err := rdsClient.DescribeDBInstances(&rds.DescribeDBInstancesInput{})
	if err != nil {
		// Do some error handling
		fmt.Println(err)
		os.Exit(1)
	}

	// The range does something like enumerate() in python
	for _, dbInstance := range response.DBInstances {

		instanceId := *dbInstance.DBInstanceIdentifier
		instanceStatus := *dbInstance.DBInstanceStatus

		fmt.Println(instanceId, "is in status", instanceStatus)

	}

}

I learned a few things during this process:

  • Pointers are fun (not really).
  • Go is particular about variable names and recommends you use camelCase aka. mixedCase for variable names.
  • Golang doesn’t have exceptions. The common way to handle errors is by returning them as the second return value. You need to check if the call was successful.

In order to use this in my future Lambda function, I encapsulated this API call in a function:

func listDBInstances() ([]*rds.DBInstance, error) {
	awsRegion := "eu-central-1"

	// Apparently we need to create a session first
	// Must makes things crash if something goes wrong
	session := session.Must(session.NewSession(&aws.Config{Region: &awsRegion}))

	// We then use this session to get an rds client
	rdsClient := rds.New(session)

	response, err := rdsClient.DescribeDBInstances(&rds.DescribeDBInstancesInput{})

	return response.DBInstances, err
}

In the same fashion, I also implemented stopDBInstance, which wraps the respective API call, and dbInstanceShouldBeStopped, which returns true for running instances also a tag that tells us to stop them. This allows me to implement my putDBInstancesToSleep function as follows:

func putDBInstancesToSleep() error {

	dbInstances, err := listDBInstances()
	if err != nil {
		return err
	}

	for _, dbInstance := range dbInstances {
		if dbInstanceShouldBeStopped(dbInstance) {
			err := stopDBInstance(dbInstance.DBInstanceIdentifier)
			if err != nil {
				return err
			}
		}
	}

	return nil

}

Aesthetically the code doesn’t look as pleasing to me as the pseudocode in Python - most likely because of all the error checking here. In Python, exceptions cause the function to crash unless they’re caught and handled, which is fine for my use case here. That’s probably not a good practice, though. I guess this is one of those things that takes some getting used to.

Now that I’ve got code that works locally, it’s time to get it into a Lambda function. Looking into the documentation, it seems that I’ll first need to install another package.

$ go get github.com/aws/aws-lambda-go/lambda
go: downloading github.com/aws/aws-lambda-go v1.31.1
go: added github.com/aws/aws-lambda-go v1.31.1

From Python, I’m used to having a simple lambda_handler function that receives the event as a dictionary and the context object (which I usually don’t care about). Golang is a bit more specific here and wants me to define the event’s structure that will be handed to the handler. Fortunately, there are pre-built structs available for common event sources. Since I want this to be invoked on a schedule via CloudWatch Events / EventBridge, I choose the adequate struct for the event. Also, we can choose to return nothing, an error, or response and an error. Since this code will only be triggered from CloudWatch events, we don’t need a response. That leads to the following implementation.

import (
	//...
	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

//...

func HandleLambdaEvent(event events.CloudWatchEvent) error {
	return putDBInstancesToSleep()
}

func main() {

	lambda.Start(HandleLambdaEvent)

}

Time to bundle everything up and create our deployment package according to the documentation!

$ # First, we build the go binary for Linux
$ GOOS=linux go build main.go
$ # Time to zip the binary
$ zip function.zip main

Next, I create a new Lambda function in the AWS Console and upload the archive.

Create Lambda View

Then I uploaded the ZIP archive we created earlier.

Upload ZIP Archive

Next, I define this test event:

{
  "id": "cdc73f9d-aea9-11e3-9d5a-835b769c0d9c",
  "detail-type": "Scheduled Event",
  "source": "aws.events",
  "account": "123456789012",
  "time": "1970-01-01T00:00:00Z",
  "region": "us-east-1",
  "resources": [
    "arn:aws:events:us-east-1:123456789012:rule/ExampleRule"
  ],
  "detail": {}
}

Running this Test event returns an error:

fork/exec no such file or directory PathError

Apparently, it expects the handler to be called hello when you create the function through the GUI. No problem, we can change that to main in the Runtime Settings.

Lambda Runtime Settings

After doing that, running the test event yields a different error:

{
  "errorMessage": "fork/exec /var/task/main: exec format error",
  "errorType": "PathError"
}

Strange. Fortunately, stackoverflow has an answer. I forgot I was running this on an M1-based Mac, so it was compiled for ARM. The Go runtime doesn’t (yet) support ARM-based Lambdas, so I had to recompile the Lambda and update it.

$ # First, we build the go binary for Linux
$ # This time, with the correct CPU architecture
$ GOARCH=amd64 GOOS=linux go build main.go
$ # Time to zip the binary
$ zip function.zip main

After updating the function code, I could finally run the Lambda function using my test event. Now I also learned how to compile binaries for different CPU instruction sets. Lambda Success

All that’s left is to create a trigger to run this every day at 7 pm.

Lambda Create CloudWatch trigger

Granted, this is not infrastructure as code or automated, but it’s a start. More things I want to add include logging and unit testing. It works, but I’d like to be more confident that it will continue to do so in the future.

I built a Lambda function in Go that shuts down RDS databases with a predefined Tag every day at 7 pm. This is nothing spectacular, but I learned a lot about Golang and may tackle optimizing the setup in a future blog post. If you’re interested, you can find the code I’ve shown you here.

I hope you learned something as well, and I’m looking forward to your feedback. Feel free to reach out to me via the channels mentioned in my bio.

— Maurice

(Photo by Chinmay Bhattar on Unsplash)

Similar Posts You Might Enjoy

Serverless Spy Vs. Spy Chapter 3: X-Ray vs Jaeger - Send Lambda traces with open telemetry

In modern architectures, Lambda functions co-exist with containers. Cloud Native Observability is achieved with open telemetry. I show you how to send open telemetry traces from Lambda to a Jaeger tracing server. Let’s see how this compares to the X-Ray tracing service. - by Gernot Glawe

Serverless Spy Vs. Spy Chapter 2: AWS Distro for OpenTelemetry Lambda vs X-Ray SDK

We know how to follow traces with the X-Ray SDK. Now there is AWS Distro for OpenTelemetry claiming to do this better. Let’s build CDK examples for Lambda with TypeScript/Python/Go and find out who is the better spy in this game. - by Gernot Glawe

Serverless Spy Vs Spy Chapter 1: X-ray

There are several ways to perform espionage activities in the life of a serverless app, which all battle for your attention. Time for the advent of counterintelligence: We want answers! - And CDK/Source examples of how to use it! Here we go, Serverless spy vs spy in four chapters, each post published after you light the next candle. - by Gernot Glawe