Lambda 関数で Secrets Manager から直接データを取るようにしてみた

2023.05.23

こんにちは、高崎@アノテーション です。

はじめに

下記のブログにて AWS CDK テンプレートのソース上に平文で記載する方法から Secrets Manager を使って Lambda の環境変数へ埋め込む方法を実装しました。

今回は環境変数にも残さずに直接 Secrets Manager からデータを取得するやり方を AWS Lambda 関数で AWS Secrets Manager シークレットを使用する という AWS ドキュメントをもとに実装いたしました。

ターゲットとなる環境

上記のブログの環境を使用いたしますが Secrets Manager へ登録したものは Lambda 関数の環境変数の設定に AWS Secrets Manager を使ってみた話 のものをそのまま使用します。

テンプレート側の実装方針

実装の方針としては下記になります。

  1. Lambda の構築にレイヤー定義を追加する
  2. Secrets Manager のアクセス権限付与

Lambda の構築にレイヤー定義を追加する

レイヤー定義用の ARN を使用する必要がありますが、ARN は こちらの一覧 を元に、リージョンに適した ARN を設定します。

ここでは東京リージョン(ap-northeast-1)へ設定するとして Lambda へのレイヤー定義を行う実装は下記になります。

    const lambdaLayer = lambda.LayerVersion.fromLayerVersionArn(this, "lambdaLayer", "arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4");
    const lambdaParrotingBot = new lambda.Function(this, "LineParrotingBot", {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: "index.handler",
      code: lambda.Code.fromAsset("src/lambda"),
      layers: [lambdaLayer],
    });

環境変数は削除しています。

Secrets Manager へのアクセス権限付与

Secrets Manager の情報は独自で作成したため、AWS CDK のテンプレートからはアクセス付与を行わないと Lambda から取得が出来ません。

まずは今回、事前に作成した Secrets Manager のオブジェクトの取得です。ARN は 以前の投稿 と同じものを使用しますが、リソースの定義は fromSecretCompleteArn 関数 を使用して定義します。

次に、先に定義した Lambda 関数に対して grantRead 関数 を使って権限を付与します。

以上を踏まえた Lambda への権限付与は以下のような実装になります。

    const stringSecretName = "LineAccessInformation";
    const { region, accountId } = new ScopedAws(this);
    const stringSecretArn = `arn:aws:secretsmanager:${region}:${accountId}:secret:${stringSecretName}-XXXXXX`;
    const smResource = Secret.fromSecretCompleteArn(this, "SecretsManager", stringSecretArn);
    smResource.grantRead(lambdaParrotingBot);

テンプレートまとめ

最終的なテンプレートは下記です。
ハイライト行が差分になります。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import { Secret } from "aws-cdk-lib/aws-secretsmanager";
import { ScopedAws } from "aws-cdk-lib";

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

    // example resource
    // Lambda 関数の作成
    const lambdaLayer = lambda.LayerVersion.fromLayerVersionArn(this, "lambdaLayer", "arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4");
    const lambdaParrotingBot = new lambda.Function(this, "LineParrotingBot", {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: "index.handler",
      code: lambda.Code.fromAsset("src/lambda"),
      layers: [lambdaLayer],
    });

    // Secrets Manager への権限付与
    const stringSecretName = "LineAccessInformation";
    const { region, accountId } = new ScopedAws(this);
    const stringSecretArn = `arn:aws:secretsmanager:${region}:${accountId}:secret:${stringSecretName}-XXXXXX`;
    const smResource = Secret.fromSecretCompleteArn(this, "SecretsManager", stringSecretArn);
    smResource.grantRead(lambdaParrotingBot);

    // API Gateway の作成
    const api = new apigateway.RestApi(this, "LineParrotingApi", {
      restApiName: "LineParrotingApi",
    });
    // proxy ありで API Gateway に渡すインテグレーションを作成
    const lambdaInteg = new apigateway.LambdaIntegration(lambdaParrotingBot, {
      proxy: true,
    });
    // API Gateway の POST イベントと Lambda との紐付け
    api.root.addMethod("POST", lambdaInteg);
  }
}

サンプルですので Secrets Manager の ARN 等をハードコーディングしていますが、動的に設定する工夫を行う方がより良いかと思います。

Lambda 側の実装方針

実装の方針としては下記になります。

  1. localhost へ http 通信するライブラリのインストール
  2. Secrets Manager からシークレットテキストを取得

ライブラリのインストール

今回は単に筆者が使い慣れているだけですが axios を使います。

cd src/lambda
npm install "axios"

シークレットテキストを取得

先述の AWS Lambda 関数で AWS Secrets Manager シークレットを使用する を元に取得の概略を記載します。

  1. HTTP GET リクエストを定義する
  2. レスポンスの SecretString を使用する

1. HTTP GET リクエストを定義する

GET する時の基本的なエンドポイントは下記になります。

http://localhost:2773/secretsmanager/get?secretId=取得したいシークレット名もしくはシークレット ARN

拡張キャッシュを設定して取得する場合はリクエストヘッダに X-AWS-Parameters-Secrets-Token:トークン を指定しますが、トークンは環境変数の AWS_SESSION_TOKEN から取得します。

今回のサンプルはキャッシュを使おうと思いますが、環境変数 PARAMETERS_SECRETS_EXTENSION_CACHE_ENABLEDtrue であれば設定になります。

この記事を書いている現在のデフォルトは true ですので何も指定せずに使えますが、もし、キャッシュを使わない方針の場合は PARAMETERS_SECRETS_EXTENSION_CACHE_ENABLEDfalse に CDK テンプレートから指定し、リクエストヘッダには何も指定しない、という指定になります。

2. レスポンスの SecretString を使用する

今回はエラー無し前提で get を await で取り、欲しいシークレット文字が 「SecretString」 フィールド内に更に JSON 文字列で入っていますので、パースして取得するようにします。

HTTP GET リクエストの定義と踏まえて、取得の実装は下記のようなイメージです。

import axios from "axios";
    :
const requestEndpoint = "http://localhost:2773/secretsmanager/get?secretId=LineAccessInformation";
const requestOptions = {
  headers: {
    "X-Aws-Parameters-Secrets-Token": process.env.AWS_SESSION_TOKEN,
  },
};
    :
const responseSM = await axios.get(requestEndpoint, requestOptions);
const jsonSecret = JSON.parse(responseSM.data["SecretString"]);

Lambda まとめ

最終的な Lambda は下記です。
ハイライト行が差分になります。

import * as Line from "@line/bot-sdk";
import * as Types from "@line/bot-sdk/lib/types";
import * as Lambda from "aws-lambda";
import axios from "axios";

const requestEndpoint = "http://localhost:2773/secretsmanager/get?secretId=LineAccessInformation";
const requestOptions = {
  headers: {
    "X-Aws-Parameters-Secrets-Token": process.env.AWS_SESSION_TOKEN,
  },
};

const resultError: Lambda.APIGatewayProxyResult = {
    statusCode: 500,
    body: "Error",
};
const resultOK: Lambda.APIGatewayProxyResult = {
    statusCode: 200,
    body: "OK",
};

export const handler = async (eventLambda: Lambda.APIGatewayProxyEvent, contextLambda: Lambda.Context): Promise<Lambda.APIGatewayProxyResult> => {
    console.log(JSON.stringify(eventLambda));

    // Secrets Manager から値を取得
    const responseSM = await axios.get(requestEndpoint, requestOptions);
    const jsonSecret = JSON.parse(responseSM.data["SecretString"]);
    const clientLine = new Line.Client({
      channelAccessToken: jsonSecret.ACCESS_TOKEN!,
      channelSecret: jsonSecret.CHANNEL_SECRET,
    });

    const structHeader = JSON.parse(JSON.stringify(eventLambda.headers).replace(/X-Line-Signature/gi, "X-Line-Signature"));
    const stringSignature = structHeader["X-Line-Signature"];
    // Line の署名認証
    if (!Line.validateSignature(eventLambda.body!, clientLine.config.channelSecret!, stringSignature!)) {
        // 署名検証がエラーの場合はログを出してエラー終了
        console.log("署名認証エラー", stringSignature!);
        return resultError;
    }
    // 文面の解析
    const bodyRequest: Line.WebhookRequestBody = JSON.parse(eventLambda.body!);
    if (typeof bodyRequest.events[0] === "undefined") {
        // LINE Developer による Webhook の検証は events が空配列の body で来るのでその場合は 200 を返す
        console.log("Webhook inspection");
        return resultOK;
    }
    if (bodyRequest.events[0].type !== "message" || bodyRequest.events[0].message.type !== "text") {
        // text ではない場合は終了する
        console.log("本文がテキストではない", bodyRequest);
        return resultError;
    } else {
        // 文面をそのままオウム返しする
        const messageReply: Types.Message = {
            type: "text",
            text: bodyRequest.events[0].message.text,
        };
        await clientLine.replyMessage(bodyRequest.events[0].replyToken, messageReply);
        // OK 返信をセット
        return resultOK;
    }
}

こちらもテンプレート同様、サンプルですのでエンドポイントの URL 等をハードコーディングしていますが、動的に設定出来る工夫を行う方がより良いかと思います。

ビルド・デプロイ

ビルドとデプロイは前回と同じです。

npm run build
cdk deploy

実行してみる

console.log での出力確認は本末転倒なので、直接実行してみることにします。

改めてオウム返しをしてくれて上手くいきました。

おわりに

今回は環境変数を経ずに直接取得する方法を実装してみました。

サンプルは単一のセッションですが、セッション内で複数の Lambda を使用するようなことがあれば、内部で引き渡す仕組みを実装する工夫が必要だと思います。

また、今回は Secrets Manager へ格納したものを取得する方法ですが、同じような取得方法でパラメータストアからも取得が可能です。

詳細は AWS Lambda 関数での Parameter Store パラメーターの使用 をご参照ください。

参考文献

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。
「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。
現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。
少しでもご興味あれば、アノテーション株式会社WEBサイト をご覧ください。