Lambda関数をAWS CLI スクリプトで書こう 〜 @aws-cdk/lambda-layer-awscli モジュールの紹介 〜 

これは [AWS CDK Advent Calendar 2021](https://qiita.com/advent-calendar/2021/aws-cdk) 4日目の記事です。

こんにちは。米田 (@komeda-shinji)です。インフラエンジニアをしています。

みなさん、AWS CLI を使っていますか。AWS Lambda を手軽にシェルスクリプトで書けたらなあ、と思うことはありませんか。

その夢、叶います。

この記事では [@aws-cdk/lambda-layer-awscli](https://docs.aws.amazon.com/cdk/api/latest/docs/lambda-layer-awscli-readme.html) モジュールをつかって、シェルスクリプトで Lambda 関数を書いてデプロイする方法を説明します。

# はじめに

先日 lambda-layer-awscli を使おうとしたときに CDK に @aws-cdk/lambda-layer-awscli モジュールがあることに気が付きました。

Lambda Layer は、ライブラリのような追加のコードやデータを含めることができるしくみです。

AWS Lambda には、カスタムランタイムという機能があり、Lambda Layer によるカスタムランタイムを作成することで、標準で用意されているランタイムの言語以外でも、Lambda 関数を書くことができるようになります。

そして lambda-layer-awscli は、Lambda 関数のランタイムにAWS CLI コマンドをバンドルして、シェルスクリプトから使えるようにしたものです。

**つまり Lambda 関数をシェルスクリプトで書くことができるんです。**

@aws-cdk/lambda-layer-awscli モジュールのバージョン履歴を見ると、最初に登場したのは 1.81.0 で、筆者が存在に気がついていなかっただけで、随分古くから存在していたようです。

モジュールのコードを覗くと Lambda Layer の内容が lib/layer.zip にあるので、中身を確認できます。
AWS CLI を利用したシェルスクリプトを書くときにおおよそ必要なコマンドが用意されていることがわかります。


以前は、`sam.CfnApplication` で [AWS Serverless Application Repository(SAR)](https://aws.amazon.com/jp/serverless/serverlessrepo/) にある [lambda-layer-awscli](https://serverlessrepo.aws.amazon.com/applications/us-east-1/903779448426/lambda-layer-awscli) の Lambda レイヤー ARN を指定してデプロイしていましたが、CDK のモジュールになったおかげでずいぶん扱いやすくなりました。

ちなみに SAR にある Lambda Layer の内容を確認してみましたが、AWS CLI は 1.18.142 と、少し前のバージョンでした。AWS CDK 1.134.0 の @aws-cdk/lambda-layer-awscli モジュールでは 1.18.198 が利用可能です。

# lambda-layer-awscli モジュールの使いかた

CDK での lambda-layer-awscli モジュールの [説明](https://github.com/aws-samples/aws-lambda-layer-awscli)では、次のようにCDKでのコードをごくあっさりと説明してあるだけです。

> **AWS Lambda Layer with AWS CLI**

> This module exports a single class called AwsCliLayer which is a lambda.Layer that bundles the AWS CLI.

> Usage:

> ```ts
> // AwsCliLayer bundles the AWS CLI in a lambda layer
> import { AwsCliLayer } from '@aws-cdk/lambda-layer-awscli';
> 
> declare const fn: lambda.Function;
> fn.addLayers(new AwsCliLayer(this, 'AwsCliLayer'));
> ```

> The CLI will be installed under /opt/awscli/aws.

Lambda Layer に詳しい人はこのサンプルだけでも分かるのかもしれませんが、説明が簡潔すぎますね。

これでは、はじめて使う人は、どのようにデプロイすればよいか、戸惑うでしょう。

[lambda-layer-awscli](https://github.com/aws-samples/aws-lambda-layer-awscli) の README.md ではもう少し具体的なコードがありますが、これも Lambda Layer 部分のデプロイしか示されていません。

> **Basic Usage**

> ```ts
> import { App, CfnOutput, Construct, Stack, StackProps } from '@aws-cdk/core';
> import * as layer from '@aws-cdk/lambda-layer-awscli';
> 
> export class MyStack extends Stack {
>   constructor(scope: Construct, id: string, props: StackProps = {}) {
>     super(scope, id, props);
> 
>     const awscliLayer = new layer.AwsCliLayer(this, 'AwsCliLayer');
>     new CfnOutput(this, 'LayerVersionArn', { value: awscliLayer.layerVersionArn > })
> 
>   }
> }
> 
> const devEnv = {
>   account: process.env.CDK_DEFAULT_ACCOUNT,
>   region: process.env.CDK_DEFAULT_REGION,
> };
> 
> const app = new App();
> 
> new MyStack(app, 'awscli-layer-stack', { env: devEnv });
> 
> app.synth();
```

じつは aws-lambda-layer-awscli には、以前はもっとわかりやすいサンプルが含まれていたのですが、[PR#85](https://github.com/aws-samples/aws-lambda-layer-awscli/pull/85) で大きくリファクタリングされたときにサンプルが削除されてしまいました。

削除されたサンプルファイルは PR#85 の[前のコミット](https://github.com/aws-samples/aws-lambda-layer-awscli/tree/5afd3196f15d1a6c93cf64933292743fe27470f1)を参照することで入手できます。

古いサンプルではこのようなデプロイ方法になっています。

**[samples/aws-version/cdk/lib/cdk-stack.ts](https://github.com/aws-samples/aws-lambda-layer-awscli/blob/5afd3196f15d1a6c93cf64933292743fe27470f1/samples/aws-version/cdk/lib/cdk-stack.ts)**

```ts
import cdk = require('@aws-cdk/core');
import sam = require('@aws-cdk/aws-sam');
import lambda = require('@aws-cdk/aws-lambda');

const AWSCLI_LAYER_APP_ARN = 'arn:aws:serverlessrepo:us-east-1:903779448426:applications/lambda-layer-awscli';
const AWSCLI_VERSION = '1.16.238+1';

/**
 * An AWS Lambda layer and sample function that includes the AWS CLI.
 *
 * @see https://github.com/aws-samples/aws-lambda-layer-awscli
 */
export class CdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const resource = new sam.CfnApplication(this, 'awscliLayer', {
      location: {
        applicationId: AWSCLI_LAYER_APP_ARN,
        semanticVersion: AWSCLI_VERSION
      }
    })

    const layerVersionArn = resource.getAtt('Outputs.LayerVersionArn').toString()

    const func = new lambda.Function(this, 'AwsVersionFunc', {
      code: new lambda.AssetCode('../func.d'),
      handler: 'main',
      runtime: lambda.Runtime.PROVIDED,
      memorySize: 512,
    })

    func.addLayers(
      lambda.LayerVersion.fromLayerVersionArn(this, 'LayerVersion', layerVersionArn)
    )

    new cdk.CfnOutput(this, 'LayerVersionArn', {
      value: layerVersionArn,
    })

    new cdk.CfnOutput(this, 'FuncArn', {
      value: func.functionArn,
    })
  }
}
```

SAR にある Lambda Layer を `sam.CfnApplication` を利用して Lambda 関数をデプロイしています。

いまは @aws-cdk/lambda-layer-awscli モジュールをつかうことでもっと簡潔に書くことができます。


**@aws-cdk/lambda-layer-awscliを使って書き直した例**

```ts
import cdk = require('@aws-cdk/core');
import lambda = require('@aws-cdk/aws-lambda');
import layer = require('@aws-cdk/lambda-layer-awscli');

/**
 * An AWS Lambda layer and sample function that includes the AWS CLI.
 *
 * @see https://github.com/aws-samples/aws-lambda-layer-awscli
 */
export class CdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const awscliLayer = new layer.AwsCliLayer(this, 'awscliLayer');

    const func = new lambda.Function(this, 'AwsVersionFunc', {
      code: new lambda.AssetCode('../func.d'),
      handler: 'main',
      runtime: lambda.Runtime.PROVIDED,
      memorySize: 512,
    })

    func.addLayers(awscliLayer)

    new cdk.CfnOutput(this, 'LayerVersionArn', {
      value: awscliLayer.layerVersionArn,
    })

    new cdk.CfnOutput(this, 'FuncArn', {
      value: func.functionArn,
    })
  }
}
```

AWSCLI_LAYER_APP_ARN や AWSCLI_VERSION の指定も不要で、`layer.AwsCliLayer()` で Lambda Layer を作成できるようになっています。

Lambda 関数の本体であるシェルスクリプトは `handler: 'main'` で指定している名前にしたがって `main.sh` というファイル名で、`lambda.AssetCode()` で指定した `func.d` ディレクトに置いておきます。

# bootstrap ファイル

lambda-layer-awscli はカスタムランタイムとして実現されているため、デプロイパッケージには bootstrap という名前の実行ファイルを含めておく必要があります。

bootstrap は Lambda 関数の初期化タスクと、関数が呼び出されたときの処理タスクを受け持っています。詳しい説明は[AWS Lambda のカスタムランタイム](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-custom.html)を参照してください。

Lambda 関数本体となるシェルスクリプトを起動してくれる、とっても大切なファイルなのですが、残念ながら bootstrap のサンプルも、現在は aws-lambda-layer-awscli のリポジトリからは削除されてしまっています。リファクタリングされる前の[コミット]
(https://github.com/aws-samples/aws-lambda-layer-awscli/tree/5afd3196f15d1a6c93cf64933292743fe27470f1)を参照してファイルを入手できます。

- [bootstrap]
(https://github.com/aws-samples/aws-lambda-layer-awscli/blob/5afd3196f15d1a6c93cf64933292743fe27470f1/bootstrap)
- [main.sh]
(https://github.com/aws-samples/aws-lambda-layer-awscli/blob/5afd3196f15d1a6c93cf64933292743fe27470f1/main.sh)


また AWS Lambda デベロッパーガイドの「[チュートリアル - カスタムランタイムの公開](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-walkthrough.html)」にも bootstrap のサンプルがあります。ただし、このドキュメントにあるサンプルはそのままでは実行時にエラーが出るため少し修正する必要があります。

修正済み bootstrap はこのようになります。

**bootstrap**

```sh
#!/bin/sh

set -euo pipefail

# Initialization - load function handler
#source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"

# Processing
while true
do
  HEADERS="$(mktemp)"
  # Get an event. The HTTP request will block until one is received
  EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")

  # Extract request ID by scraping response headers received above
  REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)

  # Run the handler function from the script
  RESPONSE=$(./$(echo "$_HANDLER" | cut -d. -f2).sh "$EVENT_DATA")

  # Send the response
  curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response"  -d "$RESPONSE"
done
```

修正点は次のとおりです。

1. `RESPONSE=$(...)` で実行するシェルスクリプトは、パスの指定を `./` か `$LAMBDA_TASK_ROOT/` で始める必要があります。

2. スクリプトのサフィックスをつけるかどうかはどちらでも良いのですが、aws-lambda-layer-awscli のコードに習って `.sh` を付与しておくことにしています。

```diff
diff -u bootstrap bootstrap-fixed 
--- bootstrap	2021-11-30 18:50:23.000000000 +0900
+++ bootstrap	2021-11-30 18:51:10.000000000 +0900
@@ -3,7 +3,7 @@
 set -euo pipefail
 
 # Initialization - load function handler
-source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"
+#source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"
 
 # Processing
 while true
@@ -16,7 +16,7 @@
   REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
 
   # Run the handler function from the script
-  RESPONSE=$($(echo "$_HANDLER" | cut -d. -f2) "$EVENT_DATA")
+  RESPONSE=$(./$(echo "$_HANDLER" | cut -d. -f2).sh "$EVENT_DATA")
 
   # Send the response
   curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response"  -d "$RESPONSE"
```

# サンプルコード

サンプルを挙げておきます。このコードはリファクタリング前の aws-lambda-layer-awscli にあったサンプルを少し手直ししたものです。

**bin/awscli.ts**

```ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { AwscliStack } from '../lib/awscli-stack';

const app = new cdk.App();
new AwscliStack(app, 'AwscliStack', {});
```

**lib/awscli.ts**


```ts
import * as cdk from '@aws-cdk/core';
import * as layer from '@aws-cdk/lambda-layer-awscli';
import * as lambda from '@aws-cdk/aws-lambda';

export class AwscliStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const awscliLayer = new layer.AwsCliLayer(this, 'AwsCliLayer');

    const startFunc = new lambda.Function(this, 'FunctionCDKAdventCalendar2021', {
      functionName: 'lambda-function-CDK-Advent-Calendar-2021',
      code: new lambda.AssetCode('./func.d'),
      handler: 'main',
      runtime: lambda.Runtime.PROVIDED,
      memorySize: 512,
      timeout: cdk.Duration.seconds(900),
    })
    startFunc.addLayers(awscliLayer)
  }
}
```

**func.d/bootstrap**

```sh
#!/bin/sh

set -euo pipefail

# Initialization - load function handler
#source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"

export PATH=$PATH:/opt

# Processing
while true
do
  HEADERS="$(mktemp)"
  # Get an event
  EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
  REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
  LAMBDA_RUNTIME_INVOKED_FUNCTION_ARN=$(grep -Fi Lambda-Runtime-Invoked-Function-Arn "$HEADERS" | tr -d '[:space:]' | cut -d: -f2-)
  # AWS_ACCOUNT_ID from env variables
  export LAMBDA_RUNTIME_INVOKED_FUNCTION_ARN AWS_ACCOUNT_ID
  #cat $HEADERS

  # Execute the handler function from the script
  RESPONSE=$(./$(echo "$_HANDLER" | cut -d. -f2).sh "$EVENT_DATA")

  echo "=========[RESPONSE]======="
  echo "$RESPONSE"
  echo "=========[/RESPONSE]======="

  # Send the response
  curl -s -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response"  -d "$RESPONSE"
done
```

**func.d/main.sh**

```sh
#!/bin/bash

export PATH=$PATH:/opt/awscli

result=$(aws --version 2>&1)

cat << EOF
{"body": "$result", "headers": {"content-type": "text/plain"}, "statusCode": 200}
EOF

exit 0
```

# サンプルコードをデプロイ

cdk コマンドで雛形をつくります。

```sh
$ mkdir awscli
$ cd awscli
$ cdk init -l typescript
```
今回使用する @aws-cdk/lambda-layer-awscli をインストールします。

```sh
$ npm install @aws-cdk/lambda-layer-awscli
```

ディレクトリ配置はこのようになります。
さきほどのサンプルコードを配置してください。

```
.
└── awscli
    ├── bin
    │    └── awscli.ts
    ├── lib
    │    └── awscli.ts
    └── func.d
         ├── bootstrap
         └── main.sh
```

func.d に置くファイルには実行権限を付与しておきます。

```sh
$ chmod a+x func.d/{bootstrap,main.sh}
```


ビルドしてデプロイします。

```sh
$ npm run build
$ cdk synth
$ cdk deploy LambdaLayerAwscliStack
```

テストしてみましょう。

```sh
$ aws lambda invoke --function-name "lambda-function-CDK-Advent-Calendar-2021" --payload '{}' /dev/stdout
{"body": "aws-cli/1.18.198 Python/2.7.18 Linux/4.14.246-198.474.amzn2.x86_64 botocore/1.19.38", "headers": {"content-type": "text/plain"}, "statusCode": 200}{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
```

Lambda 関数と AWSCLI コマンドの出力が続けて出ていますが、前半の JSON 部分が Lambda 関数の出力です。`body` の部分に `aws --version` の実行結果が出ていて、シェルスクリプトが Lambda 関数として実行されているのがわかります。

# すこし実用的なサンプル

定刻になれば EC2 インスタンスを起動・停止するスクリプトです。

Name タグが cdk-dev* にマッチする EC2 インスタンスが対象になりますが、AWS Systems Manager Change Calendar を見て、休日のスケジュールが入っていれば、インスタンスは起動しません。

スケジュールによる起動・停止は[AWS Instance Scheduler](https://aws.amazon.com/jp/solutions/implementations/instance-scheduler/)でできるのですが、休日カレンダー機能がないため、休日は起動しないでおきたいといった場合には Instance Scheduler では対応できません。

というわけで、このようなスクリプトを書いて代用します。

**ec2-up.d/main.sh**

```sh
#!/bin/bash

export PATH=$PATH:/opt/awscli

describe_instances()
{ 
  aws ec2 describe-instances \
    --query 'Reservations[].Instances[?State.Name==`stopped`].[InstanceId]' \
    --filters "Name=tag:$1,Values=$2" \
    --output text
}

State=$(aws ssm get-calendar-state --calendar-names holidays --query "State" --output text)

case "$State" in
  OPEN)   ;;
  CLOSED) exit 0;;
esac

instances=$(describe_instances "Name" "cdk-dev*")
if [ -n "$instances" ]; then
  aws ec2 start-instances --instance-ids $instances
fi

exit 0
```

**ec2-down.d/main.sh**

```sh
#!/bin/bash

export PATH=$PATH:/opt/awscli

describe_instances()
{ 
  aws ec2 describe-instances \
    --query 'Reservations[].Instances[?State.Name!=`stopped` && State.Name!=`terminated`].[InstanceId]' \
    --filters "Name=tag:$1,Values=$2" \
    --output text
}

instances=$(describe_instances "Name" "cdk-dev*")
if [ -n "$instances" ]; then
  aws ec2 stop-instances --instance-ids $instances
fi

exit 0
```

**lib/awscli.ts**

```ts
import cdk = require('@aws-cdk/core');
import layer = require('@aws-cdk/lambda-layer-awscli');
import iam = require('@aws-cdk/aws-iam');
import lambda = require('@aws-cdk/aws-lambda');
import { Rule, Schedule } from '@aws-cdk/aws-events';
import targets = require('@aws-cdk/aws-events-targets');

export class AwscliStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const region = cdk.Stack.of(this).region;
    const awscliLayer = new layer.AwsCliLayer(this, 'AwsCliLayer');
    const lambdaFunctionPolicy = new iam.CfnManagedPolicy(this, 'lambdaFunctionPolicy', {
        managedPolicyName: 'lambda-ec2-up-down-func',
        description: 'lambda-ec2-up-down-func',
        path: "/",
        policyDocument: {
            Version: "2012-10-17",
            Statement: [
                {
                    "Effect": "Allow",
                    "Action": [
                        "ec2:DescribeInstances",
                        "ec2:StartInstances",
                        "ec2:StopInstances",
                        "ssm:GetCalendarState",
                    ],
                    "Resource": "*"
                }
            ]
        }
    });
    const lambdaFunctionRole = new iam.CfnRole(this, 'lambdaEc2UpDownFunctionRole', {
        roleName: 'lambda-ec2-up-down-func',
        description: 'lambda-ec2-up-down-func',
        assumeRolePolicyDocument: {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "lambda.amazonaws.com"
                    },
                    "Action": "sts:AssumeRole"
                }
            ]
        },
        managedPolicyArns: [
            'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
            lambdaFunctionPolicy.ref
        ],
        path: "/",
    });
    const lambdaRole = iam.Role.fromRoleArn(this, 'LambdaEc2UpDownRole', lambdaFunctionRole.attrArn)

    const startFunc = new lambda.Function(this, 'Ec2UpFunc', {
      functionName: 'ec2-up',
      code: new lambda.AssetCode('./ec2-up.d'),
      handler: 'main',
      runtime: lambda.Runtime.PROVIDED,
      memorySize: 512,
      timeout: cdk.Duration.seconds(900),
      role: lambdaRole,
    })
    startFunc.addLayers(awscliLayer)
    new Rule(this, 'UpScheduleRule', {
        schedule: Schedule.cron({ minute: '0', hour: `${10 - 9}`, weekDay: 'MON-FRI' }),
        targets: [new targets.LambdaFunction(startFunc, {})],
    });

    const stopFunc = new lambda.Function(this, 'Ec2DownFunc', {
      functionName: 'ec2-down',
      code: new lambda.AssetCode('./ec2-down.d'),
      handler: 'main',
      runtime: lambda.Runtime.PROVIDED,
      memorySize: 512,
      timeout: cdk.Duration.seconds(900),
      role: lambdaRole,
    })
    stopFunc.addLayers(awscliLayer)
    new Rule(this, 'DownScheduleRule', {
        schedule: Schedule.cron({ minute: '0', hour: `${19 - 9}`, weekDay: 'MON-FRI' }),
        targets: [new targets.LambdaFunction(stopFunc, {})],
    });
  }
}
```


# おわりに

AWS CLI に慣れていると、ちょっとした定形作業を書くときに、シェルスクリプトで Lambda 関数が書けると AWS CLI の絶大な力が発揮できます。

シェルスクリプトなので、CPU やメモリの効率は多少犠牲になりますが、CDK と AWS CLI の組み合わせで、ささっとスクリプトを書いて Lambda 関数として動かせるのはたいへん便利です。

どうぞお楽しみください。


# 参考

AWS Lambda デベロッパーガイド [Lambda 関数でのレイヤーの使用](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/invocation-layers.html)