GoマネージドなApp RunnerをGo版CDKで構築する

AWS App Runner Advent Calendar 2022」の11日目の記事になります。

qiita.com


目次

目次


概要

今年(2022年)の10/28、ついにAWS App Runnerの マネージドランタイム(コンテナイメージを作らずGitHubからソースコードを直接デプロイ可能)にPHPGo、.Net、Rubyが対応しました。

aws.amazon.com


そこで、バックエンドをGo言語で開発している方はきっとマネージドランタイムとしてGo言語でAWS App Runnerを使用したいのではないかと。

かつ、それをGo言語によるAWS CDKを用いて、アプリもインフラもGo言語で構築できたら最高ではないかと思い、やってみました。


またCDKにて2022年12月11日現在、App RunnerのL2 Constructはα版ですが、「L1 Construct」と「L2 Constructのα版」どちらの方法でも構築してみました。


TypeScriptマネージドなApp RunnerをTypeScript版CDKで構築する記事も書きました。

go-to-k.hatenablog.com


※その他App Runnerのリリース情報や特徴などをまとめているのでよかったらぜひご覧ください。

go-to-k.hatenablog.com


前提

App Runnerに対応しているGoのバージョンは1.18からになります。

そのため、ここでご紹介するコードは、1.18.7を使用しています。


また、AWS CDKのバージョンは2.52.0を使用しています。


GitHub

早速ですが、全コードはGitHubに公開しています。

github.com


ディレクトリ構成

以下のディレクトリ構成になります。(※本題に関係ないファイルは省略しています。)

├── 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


Workspacesモード

今回は上記で記載したように、「App Runnerコード」、「CDKコード」、「カスタムリソースLambda用コード」で3種類のアプリケーションコード群が登場します。

1つのリポジトリにそれらのディレクトリを用意し、ルートディレクトリのgo.modを共有してビルドも可能なのですが、それぞれで必要なものをまとめたgo.modを用意(マルチモジュール)してあげたくなります。


そこで今回、Go 1.18から導入されたWorkspacesモードという機能を用いました。

これにより、サブディレクトリでgo.modを用意して、マルチモジュールで開発・ビルドすることができるようになります。

go.dev


以下コマンドを叩くことで、Workspacesモードになります。

go work init app cdk custom


すると、以下のようなgo.workファイルが作成されます。

go 1.18

use (
    ./app
    ./cdk
    ./custom
)


CDKコード

App Runner(AppRunner ServiceやVPC Connectorなど)をAWS CDKで構築する際、L2 Constructは提供されていません(2022年12月11日現在)

しかし、αバージョンでは提供されています。

そこで今回は、「L1」と「L2のα版」でそれぞれのApp Runner構築方法を紹介します。


go-cdk-go-managed-apprunner.go(スタック定義)

スタック全体像

AppRunnerStackPropsという外部パラメータ用の構造体を作成し、NewAppRunnerStack関数によってスタックを作成します。

AppRunnerStackPropsAppRunnerStackInputPropsに関しては、input.goというファイルでスタックに渡すパラメータの型と設定値を管理しています(TypeScriptと同じく型付言語なので良いですね)。

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)

    // ここからリソース構築


input.go(AppRunnerStackInputProps)は以下をご覧ください。

github.com


カスタムリソースLambda(AutoScalingConfiguration用)

(※現在はAutoScalingConfigurationはCloudFormation対応されているのですが本記事執筆時は未対応でした。)

いきなりカスタムリソース用のLambdaになります。

「なぜ急に?」という感じですが、実はApp Runnerは、一部のリソースはまだCloudFormation対応されていません。

CDKはCloudFormation対応されていないもののリソースは作れないため、それらをCDKで構築するためにカスタムリソースを用いています。


またカスタムリソース自体は、CDKで構築する場合、AWS APIを呼ぶだけの簡単なモノであればLambdaを自前で作らずとも構築できるモジュールがあります。

docs.aws.amazon.com


今回「AutoScalingConfiguration」というものをカスタムリソースで作成するのですが、作成(onCreate)だけであれば上記モジュールでcreate APIを呼んで作成することが可能なのですが、削除に関しては「それ自身のARN」が必要になります。

ARN自体はリソース作成時にランダム文字列で生成されるものなので、カスタムリソース定義時点ではまだどんな文字列になるかわからず、削除定義(onDelete)にてARNを指定することができません。

作成だけ定義するのもあれだったので、Lambdaを使って、削除時は「動的にListしてARNを取得して指定する」ことで作成だけでなく削除もできるようにしました。(更新も可能にしてあります。


長くなりましたが、カスタムリソース用のLambdaの作成部分のCDK定義です。

   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(),
                },
            }),
        },
    })


こちらはDockerによるバンドルになるのですが、Code.Bundling.Commandにてビルドコマンドを指定してあげます。

cdkディレクトリを起点としてAssetCode_FromAsset(jsii.String("../"),によって親ディレクトリパスを指定し、ビルドコマンド(Command)で... custom/custom.goというようにカスタムリソースLambda用ファイルを指定し、Handler: jsii.String("main")によって関数(ハンドラー)名を指定します。


jsii.Xxxはポインタを返すだけの関数で、理由は省略しますが、Go版CDKではパラメータをポインタで指定しないといけないルールなので、少し面倒ですがとりあえず指定しておきます。


またInitialPolicyで、カスタムリソースLambdaに対して、App RunnerのAutoScalingConfigurationを操作できる権限を付与することができます。


実際にこのAutoScalingConfigurationを作成するカスタムリソースLambdaもGoで書いていますが、こちらのコードもまた長く、本題とは直接関係ないので省略します。

興味がある方はこちらにありますのでご覧ください。

(Create, Deleteそれぞれ分岐で作成・更新・削除処理を書き、Create・Update時は作成したリソースのARNを返すようにしています。)

github.com


AutoScalingConfiguration

そして上記のカスタムリソースLambdaを使用して、AutoScalingConfiguration自体を作成します。

これは名前の通り、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"))


AutoScalingConfigurationNameは設定名、MaxConcurrencyはスケーリングを発火させる同時接続数、MaxSizeMinSizeはそれぞれ最小・最大台数となります。

全てinput.goで指定した値をprops経由で渡しています。

autoScalingConfigurationArnは、後で定義するAppRunner Serviceで使用するので、GetAttStringカスタムリソースLambdaからARNを受け取って変数に格納しておきます。


GitHub接続

App Runnerをマネージドランタイムで構築する場合、ソースコードのあるGitHubと接続するためのコネクション設定が必要になります。

こちらはGitHubとの認証が必要で、接続(というリソース)を作成して、コンソールから手動で「ハンドシェイクを完了」というボタンを押さないと構築が完了しません。


そのため全てを通して自動で構築することはできない(どうしても手動操作が入る)ため、「作成済みでない場合はCreateConnection APIで接続を作成し、promptで入力待ちにすることで処理を止めてその間にコンソール操作を促し、ハンドシェイクを完了したらCDK構築処理を再開する」ような関数createConnectionを用意しました。

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


こちらの関数自体も少し長いので、リンクにて失礼します。

github.com


これで1回目は入力待ちになりますが、一度作ると完全自動でCDKが走るようになります。


また、GitHub接続自体は恐らくアプリケーションと1対1で紐付くというよりかはアカウント単位で設定するケースもあると思うので、すでにある場合は新たに作らず指定した接続を使用するようになっています。そのため接続がまだ無くて作成する場合も、アプリケーションとライフサイクルを変えるため敢えてカスタムリソースを使わずスタックの管理外としてAPIで作成するようにしました。


  • 「最初のCDKデプロイでも完全自動にならないのは嫌だな」
  • 「どうせなら最初に手で作っておいて、その後のCDKデプロイは完全自動がいいな」

しかし、こんな声(架空)をもとに、GitHub接続を作成するためだけのシェルスクリプトを作成したので、これを最初に投げて接続を作成してからだとCDKが完全自動になります。

github.com


※上記はCDK・AWS側の説明になりますが、GitHub側には「AWS Connector for GitHub」が導入されている前提となります。(GitHub接続を作成してコンソールでハンドシェイクを完了する際にGitHubページに飛べます)


InstanceRole

App Runnerで公開するアプリケーションがAWSリソースにアクセスするためのIAMロール、「インスタンスロール」です。

tasks.apprunner.amazonaws.comがassumeできるように指定します。

今回はサンプルのためAWSにアクセスする処理を書かなかったので、許可権限(アクション)は何も指定していません。

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


もう一つインスタンスロールとは別のIAMロールで、ECRにアクセスするための「アクセスロール」というものがApp Runnerにはありますが、今回のようにマネージドランタイムの場合は必要ありません。


VPC Connector(L2α版)

こちらは、VPCリソースへアクセスできるようにするための機能です。


App Runnerには現状、L1 ConstructとL2 Constructのα版があります。

そのため、L1とL2α版でそれぞれで書いてみました。

ちなみに、VPC Connectorにはセキュリティグループもいるため一緒に定義しています。VPC、サブネットのIDは外部パラメータとしてprops経由で渡しています。


まずは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)),
            },
        },
    })


VPC Connector(L1版)

そしてL1の方のVPC Connector(とセキュリティグループ)です。

   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,
        ),
    })


L2と比べると、、、コード量が1行少なくなっています(笑)


AppRunner Service(L2α版)

そしてメインのAppRunner自体である、AppRunner Serviceです。

こちらもL1とL2のα版で作ってみました。

まずはL2α版から。

   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"),
    })


他と比べると少々長くなってしまいましたが、L1と設定内容を合わせるため必須でない項目も指定しています。

ここでも各情報はprops経由で取っています。


またここで大事なのは、L2 Constructだけでは設定できない項目があるため、L2リソースをL1リソースとして扱って後からプロパティを上書きするエスケープハッチ(のようなこと)をしています。


「のようなこと」というのも、現状CDK for Goではエスケープハッチ(escape hatch)がサポートされていないのです。

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 we 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.

Workshop Studio


そのため、代わりにjsii.Getというメソッドで代替しています。

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


ただしjsii.Getは非推奨なので注意です。(しかしどうしようもないので今回使いました。)

pkg.go.dev


AppRunner Service(L1版)

そしてL1版です。

   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,
    })


やはり長くなってしまいました。L2は優秀ですね。


go-cdk-go-managed-apprunner_test.go(ユニットテスト)

CDKのユニットテストも簡単なサンプルを作りました。

スナップショットテスト、fine-grained assertionsテスト、スナップショットのLambdaのS3のアセット置換(実行のたびに値が変わるもののマスキング)などをしています。

github.com


アプリケーションコード(app/main.go)

App Runnerで展開するコードのサンプルです。

これは本当にシンプルで、ginでWebサーバとして立ち上げて、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")
}


カスタムリソースLambdaコード(custom/custom.go)

上記CDKのところで少し出てきましたが、再掲になります。(長いのでリンクをご覧ください。)

github.com


最後に

App RunnerのマネージドランタイムにGoが対応し、さらにAWS CDKでもGoが対応されているため、アプリとインフラを全てGoで書きたい方もいるのではないと思い書いてみました。

GoでのCDK情報はまだまだ少ないですが、少しずつ広がっていけば良いなと思います。


補足

ちなみにGo言語でCDKを書く際の特徴・使い方、TypeScriptとの違いなどを書いてみたので、もしよかったらこちらもぜひ。

go-to-k.hatenablog.com