How To Create AWS Lambda Functions Using Pulumi And Golang

How To Create AWS Lambda Functions Using Pulumi And Golang

I’ve looked at Pulumi to do a bunch of things, including creating subnets in a VPC, building EKS clusters, and DynamoDB tables. The one thing I hadn’t explored yet was how to deploy AWS Lambda functions using Pulumi, so that’s exactly what this blog is about.

The complete project is available on GitHub.

My Lambda

The Lambda function for this post is a rather simple one, it says “Hello” to whatever the value of an environment variable will be.

package main

import (
	"fmt"
	"os"

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

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	val := os.Getenv("NAME")
	return events.APIGatewayProxyResponse{
		Body:       fmt.Sprintf("Hello, %s", val),
		StatusCode: 200,
	}, nil
}

func main() {
	lambda.Start(handler)
}

That code is in a file called hello-world.go, which is in a folder called hello-world. The Pulumi project is inside the folder called Pulumi so it will be a separate folder structure and not collide with the other code of your Lambda functions. The folder structure, including all the files looks like

├── README.md
├── go.mod
├── go.sum
└── hello-world
│    └── main.go
├── pulumi
│   ├── Pulumi.lambdastack.yaml
│   ├── Pulumi.yaml
    └── main.go

Building and uploading your Lambda code

In order to deploy a Lambda function, the code needs to be uploaded as well. While Pulumi has the concept of an Archive to create the zip file, the Go implementation has a known issue which makes it impossible to use that feature. Rather than manually building, zipping, and uploading the code, you can extend the Pulumi program a little to do all of that before the program runs.

const (
	shell      = "sh"
	shellFlag  = "-c"
	rootFolder = "/rootfolder/of/your/lambdaapp"
)

func runCmd(args string) error {
	cmd := exec.Command(shell, shellFlag, args)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.Dir = rootFolder
	return cmd.Run()
}

The runCmd method will run a specific command and will return either an error or a nil object. To build, zip, and upload your Go code to S3 you can add the below snippets to your Pulumi project. It uses the above function to run the three commands you would normally script as part of your CI/CD or test framework. These commands should be pasted before pulumi.Run() gets invoked.

if err := runCmd("GOOS=linux GOARCH=amd64 go build -o hello-world/hello-world ./hello-world"); err != nil {
    fmt.Printf("Error building code: %s", err.Error())
    os.Exit(1)
}

if err := runCmd("zip -r -j ./hello-world/hello-world.zip ./hello-world/hello-world"); err != nil {
    fmt.Printf("Error creating zipfile: %s", err.Error())
    os.Exit(1)
}

if err := runCmd("aws s3 cp ./hello-world/hello-world.zip s3://<your-bucket>/hello-world.zip"); err != nil {
    fmt.Printf("Error creating zipfile: %s", err.Error())
    os.Exit(1)
}

If these commands fail, you’ll be able to see the output and the error message as part of the “diagnostics” section in your terminal.

Creating an IAM role

Every Lambda function, and most other AWS resources as well, need an IAM role to be able to work. The IAM role gives the resources the permissions to act on your behalf. This Lambda function doesn’t have a lot of specifics it needs to do, other than be able to run. To create an IAM role with that permission, you can use the below code. The ARN (Amazon Resource Name), is exported so that it is visible from within the Pulumi console.

// The policy description of the IAM role, in this case only the sts:AssumeRole is needed
roleArgs := &iam.RoleArgs{
    AssumeRolePolicy: `{
        "Version": "2012-10-17",
        "Statement": [
        {
            "Action": "sts:AssumeRole",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Effect": "Allow",
            "Sid": ""
        }
        ]
    }`,
}

// Create a new role called HelloWorldIAMRole
role, err := iam.NewRole(ctx, "HelloWorldIAMRole", roleArgs)
if err != nil {
    fmt.Printf("role error: %s\n", err.Error())
    return err
}

// Export the role ARN as an output of the Pulumi stack
ctx.Export("Role ARN", role.Arn())

Setting environment variables

Just like in CloudFormation, the Pulumi SDK allows you to create a set of environment variables. That’s a good thing, because the Lambda function relies on an environment variable called “NAME” to know who to greet. The environment variables are a map[string]interface{} inside a map[string]interface{}. For this Lambda function, that would be the below snippet.

environment := make(map[string]interface{})
variables := make(map[string]interface{})
variables["NAME"] = "WORLD"
environment["variables"] = variables

Creating the function

The last step. at least for this code, is to actually create the Lambda function. The S3Bucket and S3Key are the name of the bucket and the name of the file you’ve uploaded earlier in the process. The role.Arn() is the ARN of the role that was created in the previous step.

// The set of arguments for constructing a Function resource.
functionArgs := &lambda.FunctionArgs{
    Description: "My Lambda function",
    Runtime:     "go1.x",
    Name:        "HelloWorldFunction",
    MemorySize:  256,
    Timeout:     10,
    Handler:     "hello-world",
    Environment: environment,
    S3Bucket:    "<your-bucket>",
    S3Key:       "hello-world.zip",
    Role:        role.Arn(),
}

// NewFunction registers a new resource with the given unique name, arguments, and options.
function, err := lambda.NewFunction(ctx, "HelloWorldFunction", functionArgs)
if err != nil {
    fmt.Println(err.Error())
    return err
}

// Export the function ARN as an output of the Pulumi stack
ctx.Export("Function", function.Arn())

Running Pulumi up

With all the code ready, it’s time to run pulumi up and deploy the Lambda function to AWS. If you need more details on how to create a Go project for Pulumi, check out this post.

$ pulumi up
Previewing update (lambda):

     Type                    Name                Plan       Info
 +   pulumi:pulumi:Stack     lambda-lambda       create     2 messages
 +   ├─ aws:iam:Role         HelloWorldIAMRole   create     
 +   └─ aws:lambda:Function  HelloWorldFunction  create     
 
Diagnostics:
  pulumi:pulumi:Stack (lambda-lambda):
    updating: hello-world/hello-world (deflated 49%)
upload: hello-world/hello-world.zip to s3://<your-bucket>/hello-world.zip
 
Resources:
    + 3 to create

Do you want to perform this update? yes
Updating (lambda):

     Type                    Name                Status      Info
 +   pulumi:pulumi:Stack     lambda-lambda       created     2 messages
 +   ├─ aws:iam:Role         HelloWorldIAMRole   created     
 +   └─ aws:lambda:Function  HelloWorldFunction  created     
 
Diagnostics:
  pulumi:pulumi:Stack (lambda-lambda):
    updating: hello-world/hello-world (deflated 49%)
upload: hello-world/hello-world.zip to s3://<your-bucket>/hello-world.zip
 
Outputs:
    Function: "arn:aws:lambda:us-west-2:ACCOUNTID:function:HelloWorldFunction"
    Role ARN: "arn:aws:iam::ACCOUNTID:role/HelloWorldIAMRole-7532034"

Resources:
    + 3 created

Duration: 44s

Permalink: https://app.pulumi.com/retgits/lambda/lambda/updates/1

Testing with the AWS Console

Inside the Pulumi console, you’ll be able to see the resources that have been created.

The Pulumi console also has really useful links to the AWS console to see the resources.

The Pulumi console also has really useful links to the AWS console to see the resources.

Within the AWS Lambda console, you’ll be able to test the function and see that it indeed responds with the greeting “Hello, WORLD”.

The AWS Lambda console.

The AWS Lambda console.

Let's connect

If you have any questions or comments, feel free to drop me a note on Twitter!

Cover image by Kevin Horvat on Unsplash