DEV Community

k.goto for AWS Community Builders

Posted on

Build Golang Managed App Runner with CDK for Go

Overview

On 10/28/2022, PHP, Go, .Net, and Ruby are finally supported in AWS App Runner's managed runtime (source code can be deployed directly from GitHub without creating a container image), see this page.

So, if you are developing backend in Go language, I am sure you would like to use AWS App Runner with Go language as a managed runtime.

And I thought it would be great if I could build both apps and infrastructure in Go using AWS CDK with Go language.

Also, as of June 2023, App Runner's L2 Construct is in alpha version on the CDK, but I tried to build it both ways, "L1 Construct" and "L2 Construct in alpha version".


Assumptions

The version of Go that is supported by App Runner is from 1.18.

Therefore, the code presented here uses 1.18.7.

Also, the AWS CDK version is 2.52.0.


GitHub

All code is available on GitHub.


Directory Structure

The following directory structure is used. (Files not related to the main topic are omitted.)

  • app
    • Directory for applications to be published by App Runner
  • cdk
    • Directory for CDK
  • custom
    • Directory for custom resource Lambda used in this CDK
  • create_connection.sh
    • Script to create a GitHub connection
  • go.work, go.work.sum
    • Files by Go Workspaces mode
├── app
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── cdk
│   ├── Makefile
│   ├── cdk.context.json
│   ├── cdk.json
│   ├── go-cdk-go-managed-apprunner.go
│   ├── go-cdk-go-managed-apprunner_test.go
│   ├── go.mod
│   ├── go.sum
│   └── input
│       └── input.go
├── create_connection.sh
├── custom
│   ├── custom.go
│   ├── go.mod
│   └── go.sum
├── go.work
└── go.work.sum
Enter fullscreen mode Exit fullscreen mode

Workspaces mode

This time, as described above, three groups of application code will appear: "App Runner code", "CDK code", and "code for custom resource Lambda".

It is possible to prepare those directories in one repository and build them by sharing go.mod in the root directory, but I would like to prepare a go.mod that summarizes what is needed in each of them (multi-module).

Therefore, I used a feature called Workspaces mode, introduced in Go 1.18.

This allows you to prepare go.mod in a subdirectory and develop and build with multi-modules.

You can enter Workspaces mode by hitting the following command.

go work init app cdk custom
Enter fullscreen mode Exit fullscreen mode

The following go.work file will then be created.

go 1.18

use (
    ./app
    ./cdk
    ./custom
)
Enter fullscreen mode Exit fullscreen mode

CDK Code

When building App Runner (AppRunner Service, VPC Connector, etc.) with AWS CDK, L2 Construct is not "yet" provided.

However, it is provided in the alpha version.

Therefore, in this article, I will show you how to build App Runner with "L1" and "L2 alpha version" respectively.

go-cdk-go-managed-apprunner.go (stack definition)

Entire stack

Create a structure for external parameters called AppRunnerStackProps and create a stack with the NewAppRunnerStack function.

For the AppRunnerStackInputProps of AppRunnerStackProps, a file named input.go manages the type and setting values of the parameters passed to the stack.

type AppRunnerStackProps struct {
    awscdk.StackProps
    AppRunnerStackInputProps *input.AppRunnerStackInputProps
}

func NewAppRunnerStack(scope constructs.Construct, id string, props *AppRunnerStackProps) awscdk.Stack {
    var sprops awscdk.StackProps
    if props != nil {
        sprops = props.StackProps
    }
    stack := awscdk.NewStack(scope, &id, &sprops)

    // Resource definition from here
Enter fullscreen mode Exit fullscreen mode

See below for input.go (AppRunnerStackInputProps).

Custom Resource Lambda (for AutoScalingConfiguration)

This is suddenly a Lambda for a custom resource.

Why so suddenly? In fact, some of App Runner's resources are not "yet" CloudFormation-compatible.

Since the CDK cannot create resources that are not CloudFormation-supported, I am using custom resources to build them in the CDK.

In addition, there is a module for building custom resources with CDK that allows you to build simple things that call AWS APIs without having to build Lambda on your own (see doc).

This time, I will create a custom resource called "AutoScalingConfiguration", but if I just want to create it (onCreate), I can do so by calling the create API in the above module, but for deletion, I need its own ARN. The ARN itself is a random character that is created at the time of resource creation.

The ARN itself is generated as a random string when the resource is created, so at the time of defining the custom resource, I do not yet know what string it will be, and I cannot specify the ARN in the onDelete definition.

Since it was not a good idea to define only creation, I used Lambda to enable not only creation but also deletion by "dynamically listing, retrieving, and specifying the ARN " when deleting. (It is also possible to update.)

Long story short, here is the CDK definition of the Lambda creation part for the custom resource.

    customResourceLambda := awslambda.NewFunction(stack, jsii.String("CustomResourceLambda"), &awslambda.FunctionProps{
        Runtime: awslambda.Runtime_GO_1_X(),
        Handler: jsii.String("main"),
        Code: awslambda.AssetCode_FromAsset(jsii.String("../"), &awss3assets.AssetOptions{
            Bundling: &awscdk.BundlingOptions{
                Image:   awslambda.Runtime_GO_1_X().BundlingImage(),
                Command: jsii.Strings("bash", "-c", "GOOS=linux GOARCH=amd64 go build -o /asset-output/main custom/custom.go"),
                User:    jsii.String("root"),
            },
        }),
        Timeout: awscdk.Duration_Seconds(jsii.Number(900)),
        InitialPolicy: &[]awsiam.PolicyStatement{
            awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
                Actions: &[]*string{
                    jsii.String("apprunner:*AutoScalingConfiguration*"),
                    jsii.String("apprunner:UpdateService"),
                    jsii.String("apprunner:ListOperations"),
                },
                Resources: &[]*string{
                    jsii.String("*"),
                },
            }),
            awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
                Actions: &[]*string{
                    jsii.String("cloudformation:DescribeStacks"),
                },
                Resources: &[]*string{
                    stack.StackId(),
                },
            }),
        },
    })
Enter fullscreen mode Exit fullscreen mode

This is bundling by Docker, but you can specify the build command at Code.Bundling.Command.

Starting from the cdk directory, specify the parent directory path by AssetCode_FromAsset(jsii.String("../"), to specify the parent directory path, and the build command (Command) to specify custom/custom.go to specify a file for custom resource Lambda, and Handler: jsii.String("main") to specify a function (handler) name.

jsii.Xxx is a function that only returns a pointer, and the reason is omitted, but since the Go version of CDK requires parameters to be specified as pointers, it is a little troublesome to specify it anyway.

Also, with InitialPolicy, you can grant the custom resource Lambda permission to manipulate App Runner's AutoScalingConfiguration.

The custom resource Lambda that actually creates this AutoScalingConfiguration is also written in Go, but this code is also long and not directly related to the main topic.

If you are interested, you can find it here.

(I wrote a Create, Update, and Delete process with a Create and Delete branch, respectively, and returns the ARN of the created resource when Create and Update are used.)

AutoScalingConfiguration

Then use the custom resource Lambda above to create the AutoScalingConfiguration itself.

This is, as the name implies, a threshold setting resource for AutoScaling.

    autoScalingConfiguration := awscdk.NewCustomResource(stack, jsii.String("AutoScalingConfiguration"), &awscdk.CustomResourceProps{
        ResourceType: jsii.String("Custom::AutoScalingConfiguration"),
        Properties: &map[string]interface{}{
            "AutoScalingConfigurationName": *stack.StackName(),
            "MaxConcurrency":               strconv.Itoa(props.AppRunnerStackInputProps.AutoScalingConfigurationArnProps.MaxConcurrency),
            "MaxSize":                      strconv.Itoa(props.AppRunnerStackInputProps.AutoScalingConfigurationArnProps.MaxSize),
            "MinSize":                      strconv.Itoa(props.AppRunnerStackInputProps.AutoScalingConfigurationArnProps.MinSize),
            "StackName":                    *stack.StackName(),
        },
        ServiceToken: customResourceLambda.FunctionArn(),
    })
    autoScalingConfigurationArn := autoScalingConfiguration.GetAttString(jsii.String("AutoScalingConfigurationArn"))
Enter fullscreen mode Exit fullscreen mode

AutoScalingConfigurationName is the configuration name, MaxConcurrency is the number of concurrent connections to fire scaling, and MaxSize and MinSize are the minimum and maximum numbers, respectively.

All values are specified in input.go and passed via props.

The autoScalingConfigurationArn is used by the AppRunner Service that will be defined later, so I receive the ARN from the custom resource Lambda via GetAttString and store it in the variable.

GitHub Connection

If you are building App Runner with Managed Runtime, you will need to set up a connection to GitHub, where the source code is located.

This one requires authentication with GitHub, and the build will not be completed until you create a connection (a resource called) and manually press the "Complete Handshake" button from the console.

Therefore, since it is not possible to build automatically through the whole process (manual operation is inevitable), "Create the connection with CreateConnection API if it has not been created, stop the process by waiting for input with prompt, prompt console operation during that time, complete the handshake, and resume the CDK building process when the handshake is complete. If the handshake is completed, the CDK construction process is restarted.

    connectionArn, err := createConnection(props.AppRunnerStackInputProps.SourceConfigurationProps.ConnectionName, *props.Env.Region)
    if err != nil {
        panic(err)
    }
Enter fullscreen mode Exit fullscreen mode

The function itself here is also a bit long, so please see the link.

This will wait for input the first time, but once created, the CDK will run completely automatically.

Also, the GitHub connection itself will probably be set up on an account-by-account basis rather than on a one-to-one basis with the application, so if you already have a GitHub connection, it will use the connection you specify instead of creating a new one. Therefore, if a connection does not exist yet and you want to create one, you can use the API to create it outside of the stack management, instead of using a custom resource, in order to change the lifecycle of the application.

  • "I don't want the first CDK deployment to be fully automated."
  • "I'd rather make it by hand first, and then have the CDK deployments be fully automated.

However, based on voices like this (fictitious), I created a "shell script just to create a GitHub connection ", so if I throw this first to create the connection, then the CDK will be fully automatic, see this page.

And the above explanation is for the CDK and AWS side, but it assumes that the "AWS Connector for GitHub" is installed on the GitHub side. (You can jump to the GitHub page when you create a GitHub connection and complete the handshake in the console.)

InstanceRole

The IAM role, "instance role", for applications published by App Runner to access AWS resources.

Specify it so that tasks.apprunner.amazonaws.com can be asssumed.

In this case, I did not write any process to access AWS for the sample, so I did not specify any permission (action).

    appRunnerInstanceRole := awsiam.NewRole(stack, jsii.String("AppRunnerInstanceRole"), &awsiam.RoleProps{
        AssumedBy: awsiam.NewServicePrincipal(jsii.String("tasks.apprunner.amazonaws.com"), nil),
    })
Enter fullscreen mode Exit fullscreen mode

There is one more IAM role that is different from the instance role, the "access role" for access to the ECR, which is available in App Runner, but not necessary for managed runtime as in this case.

VPC Connector (L2 alpha version)

This is a function to allow access to VPC resources.

App Runner currently has alpha versions of L1 Construct and L2 Construct.

Therefore, I wrote in L1 and L2 alpha versions respectively.

Note that the VPC Connector also has a security group, which is defined together, and the VPC and subnet IDs are passed as external parameters via props.

First is L2.

    securityGroupForVpcConnectorL2 := awsec2.NewSecurityGroup(stack, jsii.String("SecurityGroupForVpcConnectorL2"), &awsec2.SecurityGroupProps{
        Vpc: awsec2.Vpc_FromLookup(stack, jsii.String("VPCForSecurityGroupForVpcConnectorL2"), &awsec2.VpcLookupOptions{
            VpcId: jsii.String(props.AppRunnerStackInputProps.VpcConnectorProps.VpcID),
        }),
        Description: jsii.String("for AppRunner VPC Connector L2"),
    })

    vpcConnectorL2 := apprunner.NewVpcConnector(stack, jsii.String("VpcConnectorL2"), &apprunner.VpcConnectorProps{
        Vpc: awsec2.Vpc_FromLookup(stack, jsii.String("VPCForVpcConnectorL2"), &awsec2.VpcLookupOptions{
            VpcId: jsii.String(props.AppRunnerStackInputProps.VpcConnectorProps.VpcID),
        }),
        SecurityGroups: &[]awsec2.ISecurityGroup{securityGroupForVpcConnectorL2},
        VpcSubnets: &awsec2.SubnetSelection{
            Subnets: &[]awsec2.ISubnet{
                awsec2.Subnet_FromSubnetId(stack, jsii.String("Subnet1"), jsii.String(props.AppRunnerStackInputProps.VpcConnectorProps.SubnetID1)),
                awsec2.Subnet_FromSubnetId(stack, jsii.String("Subnet2"), jsii.String(props.AppRunnerStackInputProps.VpcConnectorProps.SubnetID2)),
            },
        },
    })
Enter fullscreen mode Exit fullscreen mode

VPC Connector (L1 version)

And here is the VPC Connector (and security group) for the L1 one.

    securityGroupForVpcConnectorL1 := awsec2.NewSecurityGroup(stack, jsii.String("SecurityGroupForVpcConnectorL1"), &awsec2.SecurityGroupProps{
        Vpc: awsec2.Vpc_FromLookup(stack, jsii.String("VPCForVpcConnectorL1"), &awsec2.VpcLookupOptions{
            VpcId: jsii.String(props.AppRunnerStackInputProps.VpcConnectorProps.VpcID),
        }),
        Description: jsii.String("for AppRunner VPC Connector L1"),
    })

    vpcConnectorL1 := awsapprunner.NewCfnVpcConnector(stack, jsii.String("VpcConnectorL1"), &awsapprunner.CfnVpcConnectorProps{
        SecurityGroups: jsii.Strings(*securityGroupForVpcConnectorL1.SecurityGroupId()),
        Subnets: jsii.Strings(
            props.AppRunnerStackInputProps.VpcConnectorProps.SubnetID1,
            props.AppRunnerStackInputProps.VpcConnectorProps.SubnetID2,
        ),
    })
Enter fullscreen mode Exit fullscreen mode

AppRunner Service (L2 alpha version)

And the main AppRunner itself, AppRunner Service.

I made this one in L1 and L2 alpha versions.

Let's start with the L2 alpha version.

    apprunnerServiceL2 := apprunner.NewService(stack, jsii.String("AppRunnerServiceL2"), &apprunner.ServiceProps{
        InstanceRole: appRunnerInstanceRole,
        Source: apprunner.Source_FromGitHub(&apprunner.GithubRepositoryProps{
            RepositoryUrl:       jsii.String(props.AppRunnerStackInputProps.SourceConfigurationProps.RepositoryUrl),
            Branch:              jsii.String(props.AppRunnerStackInputProps.SourceConfigurationProps.BranchName),
            ConfigurationSource: apprunner.ConfigurationSourceType_API,
            CodeConfigurationValues: &apprunner.CodeConfigurationValues{
                Runtime:      apprunner.Runtime_GO_1(),
                Port:         jsii.String(strconv.Itoa(props.AppRunnerStackInputProps.SourceConfigurationProps.Port)),
                StartCommand: jsii.String(props.AppRunnerStackInputProps.SourceConfigurationProps.StartCommand),
                BuildCommand: jsii.String(props.AppRunnerStackInputProps.SourceConfigurationProps.BuildCommand),
                Environment: &map[string]*string{
                    "ENV1": jsii.String("L2"),
                },
            },
            Connection: apprunner.GitHubConnection_FromConnectionArn(jsii.String(connectionArn)),
        }),
        Cpu:          apprunner.Cpu_Of(jsii.String(props.AppRunnerStackInputProps.InstanceConfigurationProps.Cpu)),
        Memory:       apprunner.Memory_Of(jsii.String(props.AppRunnerStackInputProps.InstanceConfigurationProps.Memory)),
        VpcConnector: vpcConnectorL2,
        AutoDeploymentsEnabled: jsii.Bool(true),
    })

    var cfnAppRunner awsapprunner.CfnService
    //lint:ignore SA1019 This is deprecated, but Go does not support escape hatches yet.
    jsii.Get(apprunnerServiceL2.Node(), "defaultChild", &cfnAppRunner)
    cfnAppRunner.SetAutoScalingConfigurationArn(autoScalingConfigurationArn)
    cfnAppRunner.SetHealthCheckConfiguration(&awsapprunner.CfnService_HealthCheckConfigurationProperty{
        Path:     jsii.String("/"),
        Protocol: jsii.String("HTTP"),
    })
Enter fullscreen mode Exit fullscreen mode

The configuration is a bit longer than the others, but in order to match the L1 configuration, items that are not required are also specified.

Again, each information is taken via props.

What is important here is that since there are items that cannot be set using only L2 Construct, the L2 resource is treated as an L1 resource and the properties are overwritten afterwards "escape hatch " (something like that).

The reason for this is that CDK for Go does not currently support escape hatch, see this page.

CDK has a concept called the escape hatch , which allows you to modify an L2 construct's underlying CFN Resource to access features that are supported by CloudFormation but not yet supported by CDK. Unfortunately, CDK for Go does not yet support this functionality, so I have to create the resource through the L1 construct. See this GitHub issue for more information and to follow progress on support for CDK escape hatches in Go.

Therefore, I substitute the method jsii.Get instead.

    jsii.Get(apprunnerServiceL2.Node(), "defaultChild", &cfnAppRunner)
    cfnAppRunner.SetAutoScalingConfigurationArn(autoScalingConfigurationArn)
    cfnAppRunner.SetHealthCheckConfiguration(&awsapprunner.CfnService_HealthCheckConfigurationProperty{
        Path:     jsii.String("/"),
        Protocol: jsii.String("HTTP"),
    })
Enter fullscreen mode Exit fullscreen mode

Note that jsii.Get is deprecated. (But I used it this time because I couldn't help it.)

See doc.

AppRunner Service(L1 version)

And here is the L1 version.

    apprunnerServiceL1 := awsapprunner.NewCfnService(stack, jsii.String("AppRunnerServiceL1"), &awsapprunner.CfnServiceProps{
        SourceConfiguration: &awsapprunner.CfnService_SourceConfigurationProperty{
            AutoDeploymentsEnabled: jsii.Bool(true),
            AuthenticationConfiguration: &awsapprunner.CfnService_AuthenticationConfigurationProperty{
                ConnectionArn: jsii.String(connectionArn),
            },
            CodeRepository: &awsapprunner.CfnService_CodeRepositoryProperty{
                RepositoryUrl: jsii.String(props.AppRunnerStackInputProps.SourceConfigurationProps.RepositoryUrl),
                SourceCodeVersion: &awsapprunner.CfnService_SourceCodeVersionProperty{
                    Type:  jsii.String("BRANCH"),
                    Value: jsii.String(props.AppRunnerStackInputProps.SourceConfigurationProps.BranchName),
                },
                CodeConfiguration: &awsapprunner.CfnService_CodeConfigurationProperty{
                    ConfigurationSource: jsii.String("API"),
                    CodeConfigurationValues: &awsapprunner.CfnService_CodeConfigurationValuesProperty{
                        Runtime:      jsii.String("GO_1"),
                        Port:         jsii.String(strconv.Itoa(props.AppRunnerStackInputProps.SourceConfigurationProps.Port)),
                        StartCommand: jsii.String(props.AppRunnerStackInputProps.SourceConfigurationProps.StartCommand),
                        BuildCommand: jsii.String(props.AppRunnerStackInputProps.SourceConfigurationProps.BuildCommand),
                        RuntimeEnvironmentVariables: []interface{}{
                            &awsapprunner.CfnService_KeyValuePairProperty{
                                Name:  jsii.String("ENV1"),
                                Value: jsii.String("L1"),
                            },
                        },
                    },
                },
            },
        },
        HealthCheckConfiguration: &awsapprunner.CfnService_HealthCheckConfigurationProperty{
            Path:     jsii.String("/"),
            Protocol: jsii.String("HTTP"),
        },
        InstanceConfiguration: &awsapprunner.CfnService_InstanceConfigurationProperty{
            Cpu:             jsii.String(props.AppRunnerStackInputProps.InstanceConfigurationProps.Cpu),
            Memory:          jsii.String(props.AppRunnerStackInputProps.InstanceConfigurationProps.Memory),
            InstanceRoleArn: appRunnerInstanceRole.RoleArn(),
        },
        NetworkConfiguration: &awsapprunner.CfnService_NetworkConfigurationProperty{
            EgressConfiguration: awsapprunner.CfnService_EgressConfigurationProperty{
                EgressType:      jsii.String("VPC"),
                VpcConnectorArn: vpcConnectorL1.AttrVpcConnectorArn(),
            },
        },
        AutoScalingConfigurationArn: autoScalingConfigurationArn,
    })
Enter fullscreen mode Exit fullscreen mode

go-cdk-go-managed-apprunner_test.go (unit test)

I also created a simple sample unit test for CDK.

I have snapshot tests, fine-grained assertions tests, and snapshot Lambda S3 asset replacement (masking of values that change with each execution).


Application code (app/main.go)

This is a sample code to be deployed in App Runner.

This is really simple, just start it up as a web server with gin and display the environment variables passed in the CDK.

package main

import (
    "os"

    "github.com/gin-gonic/gin"
)

func main() {
    outputValue := os.Getenv("ENV1")
    engine := gin.Default()
    engine.GET("/", func(c *gin.Context) {
        c.String(200, outputValue)
    })
    engine.Run(":8080")
}
Enter fullscreen mode Exit fullscreen mode

Custom resource Lambda code (custom/custom.go)

This was mentioned a bit in the CDK section above, but it is a restatement. (It is long, so please see the link.)


Supplemental

By the way, I have written about the features and usage of writing CDK in Go language and the difference from TypeScript, if you would like to read it.


Finally

Since Go is now supported in App Runner's Managed Runtime and AWS CDK, I thought some of you might want to write your apps and infrastructure entirely in Go.

There is still very little information on CDK in Go, but I hope it will spread little by little.

Top comments (0)