🤖

LambdaでLambda Layerを作成し、CDKにおけるレイヤービルドのダルさを終わらせる

に公開

はじめに

AWS CDKにおけるLambda layerの作成、どのようにしていますか?
CDKにおける、Lambda layerの管理手法は複数ありますが、どれも決め手に欠けるイメージです。

本記事では、CDK内で完結し、自動化されたLayer管理を実現する手法として、Lambda関数自身でLayerを生成し、CustomResourceを用いて紐付けるアプローチを紹介します。

今回実装したコードのリポジトリは以下です。
https://github.com/tyumugi1113/layer-builder

CDKでのLambda Layerの一般的な作成方法(メリット/デメリット)

一般的には以下の方法が考えられると思います。

  1. 既存LayerをARNで指定して利用
    • AWS公式Layerや既存LayerをARNで参照して追加する方法。
  2. requirements.txtからCDK BundlingでLayerを生成
    • CDKのbundling機能を使い、requirements.txtをもとに依存ライブラリをインストールしてLayerを生成する方法。
  3. @aws-cdk/aws-lambda-python-alpha を使用してLayerを生成
    • bundling自体は②と同様だが、Python Lambda専用のコンストラクトを使うことで依存管理が簡潔になる。
  4. 手動で用意したライブラリをLayerとして追加
    • 自作ユーティリティなどをzip化またはディレクトリとして用意し、そのままLayerに組み込む方法。
  5. Dockerで事前ビルドしたLayerをzip化して追加
    • コンテナで依存ライブラリをビルドしてzip化し、Lambdaに追加する方法。プラットフォーム差異の影響を避けやすい。

上記手法は以下の記事を参考にしています。それぞれの詳細は、記事を参照してください。
https://dev.classmethod.jp/articles/2025-08-05-cdk-python-lambda-layer/

手法ごとのメリット・デメリット整理

項番 手法 主な課題・デメリット
ARN参照 バージョン管理が他者依存/変更検知しづらい
bundling(素のCDK) 設定がやや煩雑/Docker環境が必要
Python-alpha Alpha モジュールのため将来互換の懸念/Docker依存
手動アップロード 自動化しづらい/属人化しやすい
Docker 事前ビルド CI前提になりがち/Docker環境が必要

特に、②番・③番の方法はCDKをデプロイする環境にDockerが必要となります。ローカル環境ではまだしも、CI/CDパイプラインに組み込む際には手間が増えるのが課題です。

⑤番のDocker事前ビルドも同様に、ローカルでDockerが使えない場合は利用困難です。

従来手法の限界と運用上の問題点

他の手法を採用すると、CDKの外でLayerを準備する必要が出てきます。しかし、これはモダンなIaC運用としては望ましくありません。

さらに、Lambda Layerのベストプラクティスとして、Lambdaと同じランタイム環境でLayerをビルドすることが挙げられます。これを実現するには専用のDockerイメージ上でビルドするのが理想ですが、ローカル環境では手間がかかります。

Lambda自身でLayerを作る

そこで本記事では、Lambda関数自身でLayerを生成し、CustomResourceを用いてCDKから管理する方法を提案します。

  • LambdaがLayerを生成するため、CDK外でLayerを準備する必要がない
  • ローカル環境でもCI/CD環境でもDockerを意識せずに完結
  • 依存関係やバージョン管理もCDK内で自動化可能

を目指します。
次章からCDKのサンプルコードを紹介します。

Lambda関数によるセルフビルドの流れ

主な処理の流れは以下です。

  1. LayerBuilder Lambda関数をデプロイ
  2. CustomResourceでLayerBuilder実行
  3. LayerBuilder内でpip install & Layer作成
  4. 作成したLayerを使うLambda関数をデプロイ

CustomResourceを使用することで、CDK内で完結する処理が可能であり、実行時間もそこまで掛からないものになっています。

LayerBuilderの実装

LayerBuilder Lambda関数の実装例を示します。この関数は、指定されたパッケージをインストールし、Lambda Layerとして登録します。

import json
import subprocess
import boto3
import os
import tempfile
import shutil
import hashlib
import logging
from typing import Dict, Any

# ロガーの設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# AWS クライアント
lambda_client = boto3.client("lambda")


def create_or_update_layer(requirements: str, layer_name: str) -> Dict[str, str]:
    """Lambda Layerを作成または更新"""
    # requirements ハッシュを計算(Description に埋め込む用)
    req_hash = hashlib.sha256(requirements.encode("utf-8")).hexdigest()[:16]
    logger.info(f"Requirements hash: {req_hash}")

    # 既存 Layer を確認して同じハッシュのものがあれば再利用
    try:
        existing_versions = lambda_client.list_layer_versions(
            LayerName=layer_name,
            MaxItems=50,  # 最新50個まで確認
        ).get("LayerVersions", [])

        for v in existing_versions:
            if req_hash in v.get("Description", ""):
                logger.info(f"Reusing existing layer: {v['LayerVersionArn']}")
                return {"LayerVersionArn": v["LayerVersionArn"]}

    except lambda_client.exceptions.ResourceNotFoundException:
        logger.info(f"Layer {layer_name} does not exist yet, will create new one")

    # 一時ディレクトリを作成
    tmp_dir = None
    try:
        tmp_dir = tempfile.mkdtemp(prefix="layer-builder-")
        logger.info(f"Created temp directory: {tmp_dir}")

        # requirements.txt を /tmp に書き出し
        req_path = os.path.join(tmp_dir, "requirements.txt")
        with open(req_path, "w") as f:
            f.write(requirements)
        logger.info(f"Written requirements to: {req_path}")

        # python ディレクトリに pip install
        python_dir = os.path.join(tmp_dir, "python")
        os.makedirs(python_dir, exist_ok=True)

        logger.info("Installing packages...")
        result = subprocess.run(
            [
                "pip",
                "install",
                "-r",
                req_path,
                "-t",
                python_dir,
                "--no-cache-dir",
                "-q",
            ],
            capture_output=True,
            text=True,
            check=True,
        )
        if result.stderr:
            logger.warning(f"pip install warnings: {result.stderr}")
        logger.info("Packages installed successfully")

        # zip 作成
        zip_base_path = os.path.join(tmp_dir, "layer")
        zip_path = f"{zip_base_path}.zip"
        shutil.make_archive(zip_base_path, "zip", tmp_dir, "python")
        logger.info(f"Created zip file: {zip_path}")

        # ファイルサイズを確認(Lambdaの制限は50MB)
        zip_size = os.path.getsize(zip_path)
        logger.info(f"Zip file size: {zip_size / 1024 / 1024:.2f} MB")

        if zip_size > 50 * 1024 * 1024:
            raise ValueError(
                f"Layer zip file is too large: {zip_size / 1024 / 1024:.2f} MB (max 50 MB)"
            )

        # Lambda Layer を作成
        with open(zip_path, "rb") as f:
            zip_bytes = f.read()

        response = lambda_client.publish_layer_version(
            LayerName=layer_name,
            Content={"ZipFile": zip_bytes},
            CompatibleRuntimes=["python3.13"],
            Description=f"Requirements hash: {req_hash}",
        )

        logger.info(f"Created new layer: {response['LayerVersionArn']}")

        # 古いバージョンを削除(最新5個を残す)
        cleanup_old_versions(layer_name, keep_versions=5)

        return {"LayerVersionArn": response["LayerVersionArn"]}

    finally:
        # 一時ディレクトリをクリーンアップ
        if tmp_dir and os.path.exists(tmp_dir):
            try:
                shutil.rmtree(tmp_dir)
                logger.info(f"Cleaned up temp directory: {tmp_dir}")
            except Exception as e:
                logger.warning(f"Failed to cleanup temp directory: {str(e)}")


def cleanup_old_versions(layer_name: str, keep_versions: int = 5) -> None:
    """古いLayerバージョンを削除"""
    try:
        versions = lambda_client.list_layer_versions(
            LayerName=layer_name, MaxItems=50
        ).get("LayerVersions", [])

        if len(versions) > keep_versions:
            for v in versions[keep_versions:]:
                try:
                    lambda_client.delete_layer_version(
                        LayerName=layer_name, VersionNumber=v["Version"]
                    )
                    logger.info(f"Deleted old layer version: {v['Version']}")
                except Exception as e:
                    logger.warning(
                        f"Failed to delete layer version {v['Version']}: {str(e)}"
                    )

    except Exception as e:
        logger.warning(f"Failed to cleanup old versions: {str(e)}")


def delete_layer(layer_name: str) -> None:
    """Layerを削除(全バージョン)"""
    try:
        versions = lambda_client.list_layer_versions(
            LayerName=layer_name, MaxItems=50
        ).get("LayerVersions", [])

        for v in versions:
            try:
                lambda_client.delete_layer_version(
                    LayerName=layer_name, VersionNumber=v["Version"]
                )
                logger.info(f"Deleted layer version: {v['Version']}")
            except Exception as e:
                logger.warning(
                    f"Failed to delete layer version {v['Version']}: {str(e)}"
                )

    except lambda_client.exceptions.ResourceNotFoundException:
        logger.info(f"Layer {layer_name} does not exist, nothing to delete")
    except Exception as e:
        logger.warning(f"Failed to delete layer: {str(e)}")


def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    """Provider用 Layer Builder Lambda関数"""
    logger.info(f"Received event: {json.dumps(event)}")

    request_type = event.get("RequestType", "")
    physical_resource_id = event.get("PhysicalResourceId", "")

    # プロパティを取得
    properties = event.get("ResourceProperties", {})
    requirements = properties.get("Requirements", "")
    layer_name = properties.get("LayerName", "DynamicRequirementsLayer")

    if request_type in ["Create", "Update"]:
        if not requirements:
            raise ValueError("Requirements property is required")

        logger.info(f"Processing {request_type} request for layer: {layer_name}")
        result = create_or_update_layer(requirements, layer_name)
        layer_version_arn = result["LayerVersionArn"]

        # Provider用のレスポンス形式
        return {
            "Data": {"LayerVersionArn": layer_version_arn},
            "PhysicalResourceId": layer_version_arn,  # LayerVersionArnを識別子として使用
        }

    elif request_type == "Delete":
        logger.info(f"Processing Delete request for layer: {layer_name}")
        delete_layer(layer_name)

        # Provider用のレスポンス形式(Deleteの場合)
        return {"Data": {}, "PhysicalResourceId": physical_resource_id or "deleted"}

    else:
        # 通常のLambda呼び出し(テスト用)
        logger.info("Processing direct invocation (test mode)")
        requirements = event.get("requirements", "boto3==1.26.137")
        layer_name = event.get("layer_name", "TestLayer")

        result = create_or_update_layer(requirements, layer_name)
        layer_version_arn = result["LayerVersionArn"]

        # テスト用のレスポンス
        return {
            "Data": {"LayerVersionArn": layer_version_arn},
            "PhysicalResourceId": layer_version_arn,
        }

解説

CustomResourceとしてLambdaを呼ぶ

def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    request_type = event.get("RequestType", "")
    if request_type in ["Create", "Update"]:
        ...
    elif request_type == "Delete":
        ...
    else:
        ...

CustomResourceから呼び出す用の処理です。
LambdaはRequestTypeを受け取り、Create / Update / Deleteで処理を分岐しています。

  • Create/Update → Layer生成・更新
  • Delete → Layer削除
  • それ以外(テスト呼び出し) → 直接Layer生成

となります。

ハッシュ値によるrequirements.txtの変更検知

def create_or_update_layer(requirements: str, layer_name: str) -> Dict[str, str]:
    req_hash = hashlib.sha256(requirements.encode("utf-8")).hexdigest()[:16]
    # 既存 Layer を確認して同じハッシュのものがあれば再利用
    try:
        existing_versions = lambda_client.list_layer_versions(
            LayerName=layer_name,
            MaxItems=50,  # 最新50個まで確認
        ).get("LayerVersions", [])

        for v in existing_versions:
            if req_hash in v.get("Description", ""):
                logger.info(f"Reusing existing layer: {v['LayerVersionArn']}")
                return {"LayerVersionArn": v["LayerVersionArn"]}

        response = lambda_client.publish_layer_version(
            LayerName=layer_name,
            Content={"ZipFile": zip_bytes},
            CompatibleRuntimes=["python3.12", "python3.11", "python3.10"],
            Description=f"Requirements hash: {req_hash}",
        )

デプロイの度に pip install が走るのを防ぐため、requirements.txt の内容からハッシュ値を計算し、Layer の Description に埋め込んでいます。

CustomResource は requirements.txt に変更があれば常に LayerBuilder Lambda を実行しますが、このハッシュ値チェックにより、過去に作成した全く同じ内容の Layer Version があれば、それを再利用します。
これにより、デプロイ時間の短縮と、同一内容の Layer Version が際限なく増えることを防ぎます。

Layerビルドの流れ

tmp_dir = tempfile.mkdtemp(prefix="layer-builder-")
req_path = os.path.join(tmp_dir, "requirements.txt")
with open(req_path, "w") as f:
    f.write(requirements)
python_dir = os.path.join(tmp_dir, "python")
os.makedirs(python_dir, exist_ok=True)

subprocess.run(
    ["pip", "install", "-r", req_path, "-t", python_dir, "--no-cache-dir", "-q"],
    capture_output=True,
    text=True,
    check=True,
)
zip_path = shutil.make_archive(os.path.join(tmp_dir, "layer"), "zip", tmp_dir, "python")
zip_size = os.path.getsize(zip_path)
if zip_size > 50 * 1024 * 1024:
    raise ValueError(
        f"Layer zip file is too large: {zip_size / 1024 / 1024:.2f} MB (max 50 MB)"
    )
  1. 依存ライブラリの管理
    • requirements.txtを元にpip installでLayer用ディレクトリにインストールします。
  2. 一時ディレクトリの活用とクリーンアップ
    • tempfile.mkdtempで作業用ディレクトリを作成し、処理後にshutil.rmtreeで削除します。
  3. zip化とサイズチェック
    • Lambda Layerの制限(50MB)を超えないか確認します。

CDKでのCustomResource定義

LayerBuilder Lambda関数自体もCDKで定義します。

import * as cdk from 'aws-cdk-lib';
import { aws_iam as iam, aws_lambda as lmd, aws_logs as logs } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class LayerBuilder extends Construct {
  public readonly function: lmd.Function;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    const logGroup = new logs.LogGroup(this, 'LogGroup', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      retention: logs.RetentionDays.THREE_MONTHS,
    });

    // Lambda関数を作成
    this.function = new lmd.Function(this, 'Function', {
      runtime: lmd.Runtime.PYTHON_3_13,
      handler: 'lambda_function.lambda_handler',
      code: lmd.Code.fromAsset('assets/lambda/layer-builder'),
      memorySize: 1024,
      description: 'LayerBuilderFunction',
      logGroup: logGroup,
      timeout: cdk.Duration.minutes(15),
    });

    // Lambda Layer管理に必要な権限を付与
    this.function.addToRolePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['lambda:PublishLayerVersion', 'lambda:ListLayerVersions', 'lambda:DeleteLayerVersion'],
        resources: [
          cdk.Stack.of(this).formatArn({
            service: 'lambda',
            resource: 'layer:*',
          }),
        ],
      }),
    );
  }
}

解説

LayerBuilderの権限

    // Lambda Layer管理に必要な権限を付与
    this.function.addToRolePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: [
          'lambda:PublishLayerVersion',
          'lambda:ListLayerVersions',
          'lambda:DeleteLayerVersion'
        ],
        resources: [
          cdk.Stack.of(this).formatArn({
          service: 'lambda',
          resource: 'layer:*'
        })
        ]
      })
    );

実行に必要な権限は、上記の通りです。
layerの名前に関しては、動的に決定されるものなので、* としています。

CustomResourceでLayerBuilderを実行

import * as cdk from 'aws-cdk-lib';
import { custom_resources as cr, aws_lambda as lmd, aws_logs as logs } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as fs from 'fs';

export interface IRequestsLambdaProps {
  /**
   * LayerBuilderインスタンス
   */
  readonly layerBuilder: lmd.IFunction;
}

export class RequestsLambda extends Construct {
  constructor(scope: Construct, id: string, props: IRequestsLambdaProps) {
    super(scope, id);

    const logGroup = new logs.LogGroup(this, 'LogGroup', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      retention: logs.RetentionDays.THREE_MONTHS,
    });

    // requirements.txtを読み込む
    const requirementsPath = 'assets/lambda/requests-lambda/requirements.txt';
    const requirements = fs.readFileSync(requirementsPath, 'utf-8');

    // 共通のLayerBuilderを使用してCustomResourceを作成
    const provider = new cr.Provider(this, 'RequestsLambdaLayerProvider', {
      onEventHandler: props.layerBuilder,
    });

    const layerResource = new cdk.CustomResource(this, 'RequestsLambdaLayer', {
      serviceToken: provider.serviceToken,
      properties: {
        Requirements: requirements,
        LayerName: 'RequestsLambdaLayer',
      },
    });

    // CustomResourceから返されるLayerVersionArnを取得
    const layerVersionArn = layerResource.getAttString('LayerVersionArn');

    // LayerVersionオブジェクトを作成
    const layer = lmd.LayerVersion.fromLayerVersionArn(this, 'ImportedRequestsLambdaLayer', layerVersionArn);

    // Lambda関数を作成(Layerを適用)
    const lambdaFunction = new lmd.Function(this, 'Function', {
      runtime: lmd.Runtime.PYTHON_3_13,
      handler: 'lambda_function.lambda_handler',
      code: lmd.Code.fromAsset('assets/lambda/requests-lambda'),
      memorySize: 1024,
      description: 'RequestsLambda',
      logGroup: logGroup,
      timeout: cdk.Duration.minutes(15),
      layers: [layer],
    });

    // CustomResourceのLayer作成が完了してからLambda関数をデプロイするように明示的な依存関係を追加
    lambdaFunction.node.addDependency(layerResource);
  }
}

解説

requirements.txt の渡し方

    const requirementsPath = 'assets/lambda/requests-lambda/requirements.txt';
    const requirements = fs.readFileSync(requirementsPath, 'utf-8');
    const provider = new cr.Provider(this, 'LayerProvider', {
      onEventHandler: props.layerBuilder.function,
    });

    const layerResource = new cdk.CustomResource(this, 'RequestsLambdaLayer', {
      serviceToken: provider.serviceToken,
      properties: {
        Requirements: requirements,
        LayerName: 'RequestsLambdaLayer',
      },
    });

CustomResourceのproviderとして、layerBuilderを渡します。
これにより、デプロイ時にLambdaが実行され、レイヤーが作成されます。

また、CustomResourceのpropertiesとして、requirements.txtを渡します。
そうすることで、requirements.txtに変更が合った際にCustomResourceのトリガーとすることができます。

requirements.txtには以下を定義しています。

requests==2.32.5

複数のLambdaでlayerBuilderを共有する

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { LayerBuilder } from '../construct/layer-builder';
import { MyLambdaFunction } from '../construct/my-lambda-function';

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

    // 共通のLayerBuilderを作成(複数のLambda関数で共有)
    const layerBuilder = new LayerBuilder(this, 'LayerBuilder');

    // Lambda関数を追加(共通のLayerBuilderを使用)
    new RequestsLambda(this, 'RequestsLambda', {
      layerBuilder: layerBuilder.function,
    });

    // 今後、他のLambda関数を追加する場合も同じlayerBuilderを使用
    // 例:
    // new AnotherFunction(this, 'AnotherFunction', {
    //   layerBuilder: layerBuilder.function,
    // });
  }
}

layerBuilderをコンストラクトとして定義することで、Lambdaを共通で使いまわすことが可能になります。
他のLambdaが追加された場合でも、問題なくビルドできます。

ただし、Lambdaの同時実行数に関しては注意してください。

デプロイ

デプロイした場合、3つのLambdaが作成されます。

  • LayerBuilder Lambda: レイヤーをビルドするためのLambdaです。
  • Layerを付与するLambda: (RequestsLambda など)作成されたレイヤーを使用するLambdaです。
  • CustomResource: LayerBuilder Lambdaを呼び出し、レイヤーのARNを取得するためのリソースです。

まとめ

LambdaでLambda Layerを作成することで、非常に簡単に管理することができました。
制約の非常に多い環境で作業されている方にとっても、悩ましい問題が一つ解消されるのではないかなと思います!

ぜひ皆さんもやってみてください!

参考リンク

Discussion