Tech Do | メディアドゥの技術ブログ

株式会社メディアドゥのエンジニアによるブログです。

AWS CDKがGoでの記述に対応したらしいので試してみる【devpreview】

f:id:qazx7412:20210430195519j:plain

こんにちは。最近、某アイドル育成ソーシャルゲームが怒涛の勢いで新規シナリオを発表したことで、すっかり心をやられてしまい、未だ傷が癒えていないエンジニアの回路(@qazx7412)です。

本日はAWS CDKがGoでの記述に対応したという情報を手にしたので、その真偽を確かめるためにジャングルの奥地へ向かいます。

あらすじ

きっかけはこのツイートを見たことでした。

"真実"(マジ)かよ…!?"幻想"(ユメ)じゃねえよな…!?

私は普段Lambdaでのデプロイを行う際、以下の通り方法を使い分けています。
個人的な作業:Serverless Frameworkでデプロイ
業務的な作業:手作業でデプロイ

一応、上記の他にはSAMなどには触れたこともあるのですが、CDKには触れたことがなかったので、ちょうどいいと思い、今回はCDKをやっていきます。

今回作成したサンプルを以下に置いてきます。適宜ご確認をお願いします。

github.com

また、ライブラリのバージョンはv1.101.0-devpreviewを利用しています。
devpreviewなので、今後ライブラリの更新によって破壊的な変更が発生して、この記事の内容が正しくなくなってしまう可能性があります。ご了承ください。

とりあえずLambda単体で

まず、件のツイートで紹介されていたブログで紹介されていたブログによると、下記のようにinitするときにオプションでGoを指定すると、Goの雛形が作成されるようです。

$ npm install -g aws-cdk
$ cd /path/to/work/dir/
$ cdk init --language=go

作成された雛形を元に、ドキュメントやライブラリの実装を参考にしながら、Lambdaをデプロイできるようにしたのがこちらです。

// prototype-cdk-go.go
type PrototypeCdkGoStackProps struct {
    awscdk.StackProps
}

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

    simpleCmd := exec.Command("go", "build", "-o", "bin/handler/simple/main", "lambda/simple/main.go")
    simpleCmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0")
    _, err := simpleCmd.CombinedOutput()
    if err != nil {
        return nil, err
    }
    _ = awslambda.NewFunction(stack, jsii.String("prototype-go-cdk-simple-lambda"), &awslambda.FunctionProps{
        FunctionName: jsii.String("prototype-go-cdk-function"),
        Runtime:      awslambda.Runtime_GO_1_X(),
        Code:         awslambda.Code_Asset(jsii.String("bin/handler/simple/")),
        Handler:      jsii.String("main"),
    })

    return stack, nil
}

func main() {
    app := awscdk.NewApp(nil)

    _, err := NewPrototypeCdkGoSimpleStack(app, "prototype-cdk-go-simple-stack", nil)
    if err != nil {
        panic(err)
    }

    app.Synth(nil)
}


// lambda/simple/main.go
func Handler(ctx context.Context) (string, error) {
    return "test run success", nil
}

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

上記の「prototype-cdk-go.go」 がCDKで作成するリソースの記述で、下記の 「lambda/simple/main.go」 が実際にデプロイするLambdaのコードです。
基本的にはLambdaを作成するためのメソッドを呼び出して、適当に引数に作成するLambdaの設定を入れて実行するだけの簡単な内容です。
Goはエディタの補完が効くのでこの程度ならなんとなくで書けてしまいました。
今回はせっかくプログラミング言語で記述できるので、CDKの実行時にそのままデプロイできるようにコード内でビルドまでするようにしてみました。
Serverless Frameworkなどと比べると自由度があるので、デプロイ元にするディレクトリを各関数ごとした場合に、もし関数が増えすぎてもデプロイごとに容量が増えないようにするみたいな小細工をしてみたりもしました。

ということで準備はできたので早速デプロイしてみましょう。

$ cdk bootstrap # 初回の場合このコマンドでS3バケットを用意する必要がある
$ cdk deploy

This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬───────────────────────────────────────────────────┬────────┬────────────────┬──────────────────────────────┬───────────┐
│   │ Resource                                          │ Effect │ Action         │ Principal                    │ Condition │
├───┼───────────────────────────────────────────────────┼────────┼────────────────┼──────────────────────────────┼───────────┤
│ + │ ${prototype-go-cdk-simple-lambda/ServiceRole.Arn} │ Allow  │ sts:AssumeRole │ Service:lambda.amazonaws.com │           │
└───┴───────────────────────────────────────────────────┴────────┴────────────────┴──────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬───────────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
│   │ Resource                                      │ Managed Policy ARN                                                             │
├───┼───────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${prototype-go-cdk-simple-lambda/ServiceRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
└───┴───────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
prototype-cdk-go-simple-stack: deploying...
[0%] start: Publishing ********:current
[100%] success: Published ********:current
prototype-cdk-go-simple-stack: creating CloudFormation changeset...
[██████████████████████████████████████████████████████████] (4/4)





 ✅  prototype-cdk-go-simple-stack

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:********:stack/prototype-cdk-go-simple-stack/********

実に簡単です。
もちろん実行してみると問題なく動作します。

f:id:qazx7412:20210430194145p:plain

API Gateway

ところで、Lambdaの実践的な利用を考えるとき、通常はLambdaを関数単体だけではなく、必ずトリガーとなるサービスも作成して紐付けてやる必要があるとおもいます。
例えば、よく利用されるのはAPI GatewayやCloudWatch Eventなどですね。

ということで、先程のLambdaを作成するコードにAPI Gatewayを作成して紐付ける記述を追加してみようと思います。

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

    apiGW := awsapigateway.NewRestApi(stack, jsii.String("prototype-go-cdk-api-gw"), nil)

    cmd := exec.Command("go", "build", "-o", "bin/handler/api/main", "lambda/api/main.go")
    cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0")
    _, err := cmd.CombinedOutput()
    if err != nil {
        return nil, err
    }
    apiFn := awslambda.NewFunction(stack, jsii.String("prototype-go-cdk-api-lambda"), &awslambda.FunctionProps{
        FunctionName: jsii.String("prototype-go-cdk-api-function"),
        Runtime:      awslambda.Runtime_GO_1_X(),
        Code:         awslambda.Code_Asset(jsii.String("bin/handler/api/")),
        Handler:      jsii.String("main"),
    })
    apiGW.
        Root().
        AddResource(jsii.String("prototype"), nil).
        AddMethod(jsii.String("POST"), awsapigateway.NewLambdaIntegration(apiFn, nil), nil)

    return stack, nil
}

先程のLambda単体に比べて特別複雑といった感じはしませんね。
新たにAPI Gatewayを作成して、指定したエンドポイント(今回は prototype/ のPOST)にアクセスしたとき、指定した関数を呼び出すようにしてます。
こういうAPI Gatewayでどういったリクエストに対してどの関数を紐付けるか、という形でルーティングをする場合はやはりServerless FrameworkやSAMに比べると冗長な気はします。
ただそれでもうまく書けば生のCFnやTerraformよりは読みやすくなりそうではとも思いますね。
作成したAPIにリクエストをするとこんな感じです。

f:id:qazx7412:20210430194521p:plain

CloudWatch Event

次はCloudWatch Eventの場合も見てみましょう。

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

    cronCmd := exec.Command("go", "build", "-o", "bin/handler/cron/main", "lambda/cron/main.go")
    cronCmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0")
    _, err := cronCmd.CombinedOutput()
    if err != nil {
        return nil, err
    }
    cronFn := awslambda.NewFunction(stack, jsii.String("prototype-go-cdk-cron-lambda"), &awslambda.FunctionProps{
        FunctionName: jsii.String("prototype-go-cdk-cron-function"),
        Runtime:      awslambda.Runtime_GO_1_X(),
        Code:         awslambda.Code_Asset(jsii.String("bin/handler/cron/")),
        Handler:      jsii.String("main"),
    })

    rule := awsevents.NewCfnRule(stack, jsii.String("prototype-go-cdk-cron-rule"), &awsevents.CfnRuleProps{
        ScheduleExpression: jsii.String("cron(0 12 * * ? *)"),
        Targets: []struct {
            Arn *string `json:"arn"`
            Id  *string `json:"id"`
        }{{
            Arn: cronFn.FunctionArn(),
            Id:  jsii.String("lambda-rule"),
        }},
    })
    awslambda.NewCfnPermission(stack, jsii.String("prototype-go-cdk-cron-permission"), &awslambda.CfnPermissionProps{
        Action:       jsii.String("lambda:InvokeFunction"),
        FunctionName: cronFn.FunctionName(),
        Principal:    jsii.String("events.amazonaws.com"),
        SourceArn:    rule.AttrArn(),
    })

    return stack, nil
}

今度は先程に比べると少し複雑になりました。
コードの中に Cfn の文字が散見されるのを見てわかるように、今回はCDKを使ってはいますがCloudWatch Eventのルールを生のCloudFormationを書いてリソースを作成してます。
本来は他の言語でできているように下記のような形で作成したかったのですが、調べてもCloudFormationを記述しない形で関数とルールを紐付ける方法が見つからなかったので、苦肉の策として採用しました。

   awsevents.NewRule(stack, jsii.String("prototype-go-cdk-cron-rule"), &awsevents.RuleProps{
        Schedule: awsevents.Schedule_Cron(&awsevents.CronOptions{
            Minute:  jsii.String("0"),
            Month:   jsii.String("12"),
            WeekDay: jsii.String("*"),
            Year:    jsii.String("*"),
        }),
        Targets: , // ここにどうにかしてLambda関数を紐付けたかった
    })

CloudFormationの資料を読みながら生のコードを利用した記述を書く際に、structの各フィールドが interface{} になっていたり、GetAtt のような組み込み関数が使えなかったりしたので、実際に自分で書いてみると結構辛かったです。
ただ、もしCDK本来の記法で記述できるようになれば、structの定義に沿ってcronが書けたりして良いと思います。

まとめ

ということで駆け足でしたがCDKとGoでLambdaをデプロイする方法の紹介でした。
devpreviewの段階なので当然ではあるのですが、資料が少なくて動作させるのに結構苦労しました。 最初に書いた通りCDKは初触りだったのですが、エディタがGo言語向けの補完を効かせまくってくれるので、開発体験としてはかなり雑にバシバシ書いてもそれなりのものが出来たので楽しかったです。
また、Goをメインの言語に採用しているような現場ならばすでに構築済みのエコシステムにそのまま乗せる形で開発できますし、アプリケーションを書いている時と同じ感覚で書けるのがすごく良いと思いました。

弊社ではLambdaだけではなくデプロイ周りで改善しなければならないところがまだまだ残っていますので、もしCDKだけではなくデプロイツールの導入や改善に興味がありましたらこちらからぜひよろしくお願いします。 recruit.mediado.jp