Lambda で異なる AWS アカウントへ接続してみる

Lambda で異なる AWS アカウントへ接続してみる

Clock Icon2025.01.28

こんにちは!クラウド事業本部コンサルティング部のたかくに(@takakuni_)です。

最近、Lambda で異なる AWS アカウントのリソースへアクセスしたい機会がありました。

私のケースは異なるのですが、たとえば中央集権的にリソースをチェックしたり、以下のように、別アカウントの Bedrock リソースにアクセスしたいケースが挙げられます。

https://dev.classmethod.jp/articles/how-to-use-amazon-bedrock-with-cross-account/

車輪の再開発的なブログですが、私の理解も含めて整理してみたいと思います。

実装方式の策定

今回のポイントは 2 つです。以下の理由や背景をもとに実装方法を検討してみました。

  1. 何度も同じ処理を、別々の関数内で定義するのは管理が大変なため、 Lambda Rayer でひとつにまとめます。
  2. SAM での開発を想定し、なるべくローカルと本番側で差分がないように設定します。

接続元のリソース作成

まずは、接続元アカウント向けに IAM ロールを作成します。こちらは CloudFormation を利用します。

Principal は適宜変更しましょう。

external.yaml
Resources:
  Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: external-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action: sts:AssumeRole
            Principal:
              AWS: 接続元AWSアカウントのID
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/ReadOnlyAccess

接続先のリソース作成

続いて接続先の AWS アカウントのセットアップを行います。

template

クロスアカウントの処理は、複数 Lambda で再利用される想定で Lambda Layer を使ってみました。(CrossAccountLayer

また、SwitchAccountFunction ではレイヤーを使いながら、スイッチロールしてみます。(SwitchAccountFunction

デフォルトでは AssumeRole できる権限は付与されていないため、 Policies にポリシーを加えておきます。(ここ大事)

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: |
  Sample SAM Template for Switch Role

Globals:
  Function:
    Tracing: Active
  Api:
    TracingEnabled: true

Resources:
  ####################################
  # 00. Layer (Cross Account)
  ####################################
  CrossAccountLayer:
    Type: AWS::Serverless::LayerVersion
    Metadata:
      BuildMethod: python3.12
    Properties:
      LayerName: CrossAccountLayer
      ContentUri: layers/cross_account/
      CompatibleRuntimes:
        - python3.12

  ####################################
  # 01. Switch Account
  ####################################
  SwitchAccountFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/switch_account/
      Handler: app.lambda_handler
      Runtime: python3.12
      Architectures:
        - x86_64
      Layers:
        - !Ref CrossAccountLayer
      Timeout: 60
      LoggingConfig:
        LogFormat: JSON
      Policies:
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - sts:AssumeRole
              Resource: arn:aws:iam::*:role/external-role

コード周り

レイヤー

アカウント ID をもとに、セッションを作成する処理にしてみました。

Assume する IAM ロールの値は固定値で設定してるため、必要に応じて変更してください。

layers/cross_account/cross_account.py
from typing import Dict, Any
import json
import boto3
import logging
import re

logger = logging.getLogger()
logger.setLevel("INFO")

# 定数定義
ROLE_NAME = "external-role"
SESSION_NAME = "external-session"
ACCOUNT_ID_PATTERN = re.compile(r'^\d{12}$')

def validate_account_id(account_id: str) -> bool:
    """
    AWSアカウントIDを検証する
    Args:
        account_id (str): 検証するアカウントID
    Returns:
        bool: 有効なアカウントIDの場合True
    """
    return bool(ACCOUNT_ID_PATTERN.match(str(account_id)))

def assume(account_id: str) -> Dict[str, Any]:
    """
    指定されたアカウントのロールを引き受ける

    Args:
        account_id (str): 対象のAWSアカウントID

    Returns:
        Dict[str, Any]: 認証情報を含む辞書

    Raises:
        ValueError: 無効なアカウントID
        ClientError: AWS API呼び出しエラー
    """
    if not validate_account_id(account_id):
        logger.error(f"Invalid account ID format: {account_id}")
        raise ValueError("Invalid account ID format")

    try:
        sts_client = boto3.client('sts')

        role_arn = f'arn:aws:iam::{account_id}:role/{ROLE_NAME}'
        logger.info(f"Attempting to assume role: {role_arn}")

        response = sts_client.assume_role(
            RoleArn=role_arn,
            RoleSessionName=SESSION_NAME,
            DurationSeconds=3600  # セッション期間を1時間に設定
        )

        logger.info(f"Successfully assumed role for account: {account_id}")
        return response['Credentials']

    except ClientError as e:
        logger.error(f"Failed to assume role for account {account_id}: {str(e)}")
        raise
    except Exception as e:
        logger.error(f"Unexpected error while assuming role: {str(e)}")
        raise

https://repost.aws/ja/knowledge-center/lambda-function-assume-iam-role

関数

続いて Lambda 関数のコードです。受け取ったセッションからクレデンシャルを引っ張り、クライアントを生成します。

生成されたクライアントを利用することで、スイッチ先アカウントへの操作ができます。

念の為、 finally でクレデンシャルを削除してみました。

import json
import boto3
import cross_account
import logging
from botocore.exceptions import BotoCoreError, ClientError

logger = logging.getLogger()
logger.setLevel("INFO")

def lambda_handler(event, context):
    # アカウント ID からクロスアカウントの認証情報を取得
    try:
        account_id = event.get('account_id')
        if not account_id:
            logger.error("Missing account_id in event")
            return { 'statusCode': 400, 'body': json.dumps({'message': 'Missing account_id'}) }
        credential = cross_account.assume(account_id)

        # # ここに処理を書く (例: S3 バケットの一覧取得)
        # s3 = boto3.client(
        #     's3',
        #     aws_access_key_id=credential['AccessKeyId'],
        #     aws_secret_access_key=credential['SecretAccessKey'],
        #     aws_session_token=credential['SessionToken']
        # )
        # response = s3.list_buckets()
        logger.info(f"Successfully processed request for account: {account_id}")
        # ここに応答結果を書く
        return {
            'statusCode': 200,
            'body': json.dumps({'message': 'Successfully processed request'})
        }

    except (BotoCoreError, ClientError) as e:
            logger.error(f"AWS API error: {str(e)}")
            return {
                'statusCode': 500,
                'body': json.dumps({'message': 'AWS API error occurred'})
            }
    except Exception as e:
        logger.error(f"Unexpected error: {str(e)}")
        return {
            'statusCode': 500,
            'body': 'Internal server error'
        }
    finally:
        # クレデンシャル情報のクリーンアップ
        logger.info(f"Cleaning up credentials for account: {account_id}")
        if 'credential' in locals():
            del credential
        logger.info(f"Credentials cleaned up for account: {account_id}")

動作

以下のイベント内容で sam local invoke を行ってみます。

events/account.json
{
	"account_id": "123456789012"
}

無事、実行できていますね。

takakuni@ hoge % sam local invoke SwitchAccountFunction --skip-pull-image -e events/account.json
Invoking app.lambda_handler (python3.12)
CrossAccountLayer is a local Layer in the template
arn:aws:lambda:ap-northeast-1:234567890123:layer:AWSLambdaPowertoolsPython:33 is already cached. Skipping download
Building image.........................
Requested to skip pulling images ...

Mounting /Users/takakuni.shinnosuke/Documents/hoge/.aws-sam/build/SwitchAccountFunction as /var/task:ro,delegated, inside runtime container
START RequestId: b830db0f-255c-4d2a-ba97-c3903fbe99eb Version: $LATEST
{"timestamp": "2025-01-27T09:26:41Z", "level": "INFO", "message": "Found credentials in environment variables.", "logger": "botocore.credentials", "requestId": "1b152782-b7fc-4c83-9e38-fee15d20c99d"}
{"level":"INFO","location":"assume:48","message":"Attempting to assume role: arn:aws:iam::123456789012:role/external-role","timestamp":"2025-01-27 09:26:43,053+0000","service":"cross_account"}
{"level":"INFO","location":"assume:56","message":"Successfully assumed role for account: 123456789012","timestamp":"2025-01-27 09:26:45,070+0000","service":"cross_account"}
{"timestamp": "2025-01-27T09:26:45Z", "level": "INFO", "message": "Successfully processed request for account: 123456789012", "logger": "root", "requestId": "1b152782-b7fc-4c83-9e38-fee15d20c99d"}
{"timestamp": "2025-01-27T09:26:45Z", "level": "INFO", "message": "Cleaning up credentials for account: 123456789012", "logger": "root", "requestId": "1b152782-b7fc-4c83-9e38-fee15d20c99d"}
{"timestamp": "2025-01-27T09:26:45Z", "level": "INFO", "message": "Credentials cleaned up for account: 123456789012", "logger": "root", "requestId": "1b152782-b7fc-4c83-9e38-fee15d20c99d"}
END RequestId: 1b152782-b7fc-4c83-9e38-fee15d20c99d
REPORT RequestId: 1b152782-b7fc-4c83-9e38-fee15d20c99d  Init Duration: 0.67 ms  Duration: 5789.42 ms    Billed Duration: 5790 ms        Memory Size: 128 MB     Max Memory Used: 128 MB
{"statusCode": 200, "body": "{\"message\": \"Successfully processed request\"}"}

まとめ

以上、「Lambda で異なる AWS アカウントへ接続してみる」でした。

SAM はとっても便利なデプロイツールです。「なるべくローカルと本番側で差分がないように」をテーマに実装してみました。

このブログがどなたかの参考になれば幸いです。

クラウド事業本部コンサルティング部のたかくに(@takakuni_)でした!

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.