Lambda（Node.js） で ElastiCache for Memcached を使用する

![elasticache.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/5e29186f-e239-856d-f289-928d8847d629.png)

## AWS CDK
VPCを作成し、おなじサブネットにElastiCacheとLambdaを配置します。ElastiCacheの設定エンドポイントをLambdaの環境変数に設定します。
cacheNodeTypeは下記のリンク先から適切なノードを選択してください。
- [サポートされているノードの種類 - Amazon ElastiCache](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/CacheNodes.SupportedTypes.html)

VPC LambdaからElastiCacheにアクセスするために、セキュリティグループのインバウンドルールを作成する必要があります。

```typescript:lib/stack/sample-stack.ts
import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elasticache from 'aws-cdk-lib/aws-elasticache';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambdaNodeJs from 'aws-cdk-lib/aws-lambda-nodejs';

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

        // VPC
        const vpc = this.createVpc();
        const securityGroup = this.createVpcSecurityGroup(vpc);

        // ElastiCache
        const memcache = this.createElasticache(vpc, securityGroup);
        const memcachedConfigEndpoint = `${memcache.attrConfigurationEndpointAddress}:${memcache.attrConfigurationEndpointPort}`;

        // VPC Lambda
        this.createFunc(vpc, memcachedConfigEndpoint);
    }

    /**
     * @description VPCを作成する
     * {@link Vpc | https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.Vpc.html}
     */
    private createVpc(): ec2.IVpc {
        return new ec2.Vpc(this, 'Vpc', {
            ipAddresses: ec2.IpAddresses.cidr('192.168.0.0/16'),
            subnetConfiguration: [
                {
                    name: `vpc-public`,
                    cidrMask: 24,
                    subnetType: ec2.SubnetType.PUBLIC,
                },
                {
                    cidrMask: 24,
                    name: `vpc-private`,
                    subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
                },
            ],
        });
    }

    /**
     * {@link SecurityGroup | https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.SecurityGroup.html}
     */
    private createVpcSecurityGroup(vpc: ec2.IVpc): ec2.SecurityGroup {
        return new ec2.SecurityGroup(this, 'VpcSecurityGroup', { vpc });
    }

    /**
     * @description ElastiCache for Memcachedを作成する
     * {@link Amazon ElastiCache for Memcached | https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/WhatIs.html}
     * {@link CfnSubnetGroup | https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_elasticache.CfnSubnetGroup.html}
     * {@link CfnCacheCluster | https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_elasticache.CfnCacheCluster.html}
     */
    private createElasticache(vpc: ec2.IVpc, securityGroup: ec2.SecurityGroup): elasticache.CfnCacheCluster {
        const subnetGroup = new elasticache.CfnSubnetGroup(this, 'SubnetGroup', {
            description: 'private subnet',
            subnetIds: vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_ISOLATED }).subnetIds,
        });

        const cluster = new elasticache.CfnCacheCluster(this, 'ElastiCache', {
            engine: 'memcached',
            cacheNodeType: 'cache.r6g.large',
            numCacheNodes: 1,
            cacheSubnetGroupName: subnetGroup.ref,
            vpcSecurityGroupIds: [securityGroup.securityGroupId], // vpcSecurityGroupIds,cacheSecurityGroupNamesのいずれかが必須
        });

        // インバウンドルールの追加
        const port = ec2.Port.tcp(cluster.port ?? 11211);
        securityGroup.addIngressRule(ec2.Peer.anyIpv4(), port);

        return cluster;
    }

    /**
     * @description Lambdaの作成
     * {@link NodejsFunction | https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs.NodejsFunction.html}
     */
    private createFunc(vpc: ec2.IVpc, memcachedConfigEndpoint: string): lambdaNodeJs.NodejsFunction {
        return new lambdaNodeJs.NodejsFunction(this, 'SampleFunc', {
            entry: 'src/lambda/sample/index.ts',
            handler: 'handler',
            runtime: lambda.Runtime.NODEJS_18_X,
            timeout: cdk.Duration.minutes(5),
            environment: {
                MEMCACHED_CONFIG_ENDPOINT: memcachedConfigEndpoint,
            },
            vpc,
            vpcSubnets: vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_ISOLATED }),
        });
    }
}
```
## Lambda
ここでは、memcachedのクライアントに[「Memcache Plus」](https://memcache-plus.com/index.html)というライブラリを使用します。
このライブラリはElastiCacheの設定エンドポイントからエンドポイントを取得するautodiscoverという機能が備わっています。

```console
% npm install memcache-plus aws-lambda
% npm install --save-dev @types/aws-lambda esbuild@0
```

```typescript:src/lambda/sample/index.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import MemcachePlus = require('memcache-plus');

export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
    const configEndpoint: string = process.env.MEMCACHED_CONFIG_ENDPOINT ?? '';

    const client = new MemcachePlus({
        hosts: [configEndpoint],
        autodiscover: true,
    });
    const ttl = 86400 * 30; // キャッシュ期間。単位秒。

    const key = 'key';
    const input = Math.floor(Math.random() * 1000);

    // データをキャッシュする。keyは250バイトまで。
    await client.set(key, input, ttl);

    // キャッシュしたデータを取得する。存在しない場合はnullを返却する。
    const output = await client.get(key);

    if (input === output) {
        console.debug(`memcachedから取得した値: ${output}`);
        return {
            statusCode: 200,
            body: output,
        };
    } else {
        console.error(`期待値: ${input}, 実際: ${output}`);
        return {
            statusCode: 500,
            body: `期待値: ${input}, 実際: ${output}`
        };
    }
}
```

```typescript:types/memcache-plus.d.ts
declare module 'memcache-plus' {
    interface MemcacheOptions {
        autodiscover?: boolean;
        backoffLimit?: number;
        bufferBeforeError?: number;
        disabled?: boolean;
        hosts?: string | string[];
        maxValueSize?: number;
        queue?: boolean;
        netTimeout?: number;
        reconnect?: boolean;
    }

    class MemcacheClient {
        constructor(param: string | string[] | MemcacheOptions);
        get(key: string): Promise<any>;
        set(key: string, value: any, lifetime?: number): Promise<any>;
        // 必要に応じて他のメソッドの型定義を追加
    }

    export = MemcacheClient;
}

```

## テスト
<details><summary>test/sample.test.ts</summary>

```typescript:test/sample.test.ts
import * as cdk from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { SampleStack } from '../lib/sample-stack';

test('SecurityGroup', () => {
    const app = new cdk.App();
    const stack = new SampleStack(app, 'SampleStack');
    const template = Template.fromStack(stack);

    // Security Group
    template.hasResourceProperties('AWS::EC2::SecurityGroup', {
        VpcId: Match.anyValue(),
        SecurityGroupIngress: [{
            "CidrIp": "0.0.0.0/0",
            "Description": "from 0.0.0.0/0:11211",
            "FromPort": 11211,
            "IpProtocol": "tcp",
            "ToPort": 11211
        }]
    });
});

test('ElastiCache', () => {
    const app = new cdk.App();
    const stack = new SampleStack(app, 'SampleStack');
    const template = Template.fromStack(stack);

    // Subnet Group
    template.resourceCountIs('AWS::ElastiCache::SubnetGroup', 1);
    template.hasResourceProperties('AWS::ElastiCache::SubnetGroup', {
        SubnetIds: Match.anyValue(),
        Description: 'private subnet',
    });

    // Cache Cluster
    template.resourceCountIs('AWS::ElastiCache::CacheCluster', 1);
    template.hasResourceProperties('AWS::ElastiCache::CacheCluster', {
        Engine: 'memcached',
        CacheNodeType: 'cache.r6g.large',
        NumCacheNodes: 1,
        CacheSubnetGroupName: Match.anyValue(),
        VpcSecurityGroupIds: Match.anyValue(),
    });
});

test('Lambda', () => {
    const app = new cdk.App();
    const stack = new SampleStack(app, 'SampleStack');
    const template = Template.fromStack(stack);

    // Lambda
    template.resourceCountIs('AWS::Lambda::Function', 1);
    template.hasResourceProperties('AWS::Lambda::Function', {
        Runtime: 'nodejs18.x',
        Timeout: 300,
        VpcConfig: Match.anyValue(),
    });
});
```

</details>

## エラー
### Error: spawnSync docker ENOENT
esbuildをインストールすることで解消します。

```console
$ npm install --save-dev esbuild@0
```

- [@aws-cdk/aws-lambda-nodejs module · AWS CDK](https://docs.aws.amazon.com/cdk/api/v1/docs/aws-lambda-nodejs-readme.html#local-bundling)

### error TS2688: Cannot find type definition file for 'babel__generator'.
node_modulesを削除してnpm installすると解消すると解消することがあります。

### Autodiscovery failed. Error: connect ETIMEDOUT 192.168.3.236:11211
セキュリティグループにインバウンドルールが追加されていない場合にこのエラーが起こることがあります。
TCPのインバウンド・アウトバウンド双方にトラフィックの許可が必要です。アウトバウンドルールはデフォルトで全て許可されるので、インバウンドルールを設定します。
手動で設定する場合は下記のように設定します。

![スクリーンショット 2023-08-11 13.03.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/a43aa65a-8105-7c8e-c6f7-e14028c57ce0.png)

## 参考
- AWS CDK
    - [AWS CDK Intro Workshop](https://catalog.us-east-1.prod.workshops.aws/workshops/99731164-1d19-4d2e-9319-727a130e2d57/ja-JP)
    - [API Reference · AWS CDK](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-construct-library.html)
- ElastiCache
    - [Amazon ElastiCache for Memcached とは - Amazon ElastiCache](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/WhatIs.html)
- Memcached Client
    - [Memcache Plus](https://memcache-plus.com/index.html)
    - [victorquinn/memcache-plus: Memcache Plus - Better memcache for node](https://github.com/victorquinn/memcache-plus)