AWSでOrganizationalUnitごとに利用料金を集計する環境を作成した話 – FURYU Tech Blog

この記事は フリューAdvent Calendar 2025 の2日目の記事です。

こんにちは、フリュー株式会社でピクトリンクの開発をしているまさおです👻

今日はAWSアカウントAのEC2インスタンスで動いているRundeckからアカウントBにあるLambdaを実行し、そのLambdaの中でOrganizationalUnitごとにAWSの利用料金を集計しようとした話をします。
今回はそこで必要なリソースをCDKで作成した部分のお話がメインで、集計するPythonコードの解説はほぼしません。

ざっくりとした構成はこのような感じ。

ざっくりとした構成図

アカウントA側は既にある環境を利用し、アカウントB側のLambda周りを新たに用意します。アカウントB側のロールが細かく分かれているので少しややこしいですが、それぞれの役割に基づいた必要な分割をした結果このようになりました。

では以下で一個一個のリソースの実装について示していきます。

アカウントA側のロールに権限追加

アカウントAのEC2インスタンスに付与するロールに以下の設定を追加します。

new PolicyStatement({
    effect: Effect.ALLOW,
    actions: ['sts:AssumeRole'],
    resources: [
        'arn:aws:lambda:ap-northeast-1:アカウントB:function:Lambda呼び出し用ロール'  
    ],
})

アカウントAからアカウントBのLambdaを実行する際に、このresourcesで指定したロールをAssumeしてからLambdaの呼び出しをします。
Shellスクリプトで書くとこんな感じです。

aws sts assume-role \
  --role-arn arn:aws:iam::アカウントB:role/Lambda呼び出し用ロール \
  --role-session-name cost-check-lambda-session \
  --output json > /tmp/session.json

# 取得した一時認証情報でLambdaを実行
AWS_ACCESS_KEY_ID=$(cat /tmp/session.json | jq -r '.Credentials.AccessKeyId') \
AWS_SECRET_ACCESS_KEY=$(cat /tmp/session.json | jq -r '.Credentials.SecretAccessKey') \
AWS_SESSION_TOKEN=$(cat /tmp/session.json | jq -r '.Credentials.SessionToken') \
aws lambda invoke \
  --function-name arn:aws:lambda:ap-northeast-1:アカウントB:function:cost-check-lambda \
  --region ap-northeast-1 \
  /tmp/costCheckLambdaResponse.json

アカウントB側のリソース作成

ロール1: Lambdaの呼び出し用ロール

Lambdaを呼び出して実行することができる権限を持つロールを作ります。
前述のアカウントA側でAssumeして使用するものです。
このロールには、スクリプト内で行う操作に必要な権限は不要です。

const role = new Role(this, 'lambda-execution-role', {  
    roleName: 'lambda-execution-role',  
    assumedBy: new ServicePrincipal('lambda.amazonaws.com'),  
    managedPolicies: [  
        ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
        ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'),
        new ManagedPolicy(this, 'lambda-policy-3', {  
            path: '/service-role/',  
            managedPolicyName: 'InvokeCostLambda',  
            statements: [  
                new PolicyStatement({  
                    effect: Effect.ALLOW,  
                    actions: ['lambda:InvokeFunction'],  
                    resources: [  
                        'arn:aws:lambda:ap-northeast-1:アカウントB:function:cost-check-lambda'  
                    ],  
                }),  
            ],  
        })
});  
  
role.assumeRolePolicy?.addStatements(  
    new PolicyStatement({  
        effect: Effect.ALLOW,  
        principals: [
            new ArnPrincipal("arn:aws:iam::アカウントA:role/RundeckBatchRole")  
        ],  
        actions: ['sts:AssumeRole']  
    })  
)

ロール2: Lambda自体に付与するロール

Lambdaが呼び出された際に、Lambda自身が実行に使用するロールを作成します。
雑にいうとLambdaの作成時にロールを指定するかと思いますが、それです。
こいつは次で作る「ロール3:集計スクリプト内でAssumeする用ロール」をAssumeできればそれでよいので、非常にシンプルです。

const role = new Role(this, 'cost-check-role', {  
    roleName: 'cost-check-role',  
    assumedBy: new ServicePrincipal('lambda.amazonaws.com'),  
    managedPolicies: [
        new ManagedPolicy(this, 'lambda-worker-policy', {
            path: '/service-role/',  
            managedPolicyName: 'assumeCostCheckRole',  
            statements: [  
                new PolicyStatement({  
                    effect: Effect.ALLOW,  
                    actions: [
                        'sts:AssumeRole'
                    ],  
                    resources: [  
                        'arn:aws:iam::アカウントB:role/cost-check-role'  
                    ],  
                }),  
            ],  
        })
});  

ロール3: 集計スクリプト内でAssumeする用ロール

集計のための各種操作に必要な権限をまとめたロールを作成します。
集計スクリプト内でこのロールをAssumeすることで必要な権限を獲得し、Organizationsや利用料金の情報を取得するといった操作が可能になります。

const role = new Role(this, 'cost-check-role', {  
    roleName: 'cost-check-role',  
    assumedBy: new ServicePrincipal('lambda.amazonaws.com'),  
    managedPolicies: [
        new ManagedPolicy(this, 'cost-check-policy', {
            path: '/service-role/',  
            managedPolicyName: 'CheckCost',  
            statements: [  
                new PolicyStatement({  
                    effect: Effect.ALLOW,  
                    actions: [
                        'ce:GetCostAndUsage',  
                        'ce:GetUsageReport',  
                        'ce:ListCostCategoryDefinitions',  
                        'organizations:ListParents',  
                        'organizations:DescribeOrganizationalUnit',  
                        'organizations:ListAccounts'
                    ],  
                    resources: [  
                        '*'
                    ],  
                }),  
            ],  
        })
});  

role.assumeRolePolicy?.addStatements(  
    new PolicyStatement({  
        effect: Effect.ALLOW,  
        principals: [
            new ArnPrincipal("arn:aws:iam::アカウントB:role/lambda-worker-role")  
        ],  
        actions: ['sts:AssumeRole']  
    })  
)

対象リソース(managedPolicies -> statements -> resourcesの内容)が * になっていますが、OrganizationsAPIやCostExplorerAPI(ce)では組織全体/アカウント横断の情報を取得する必要があり、リソース制限をかけるのは現実的ではないためです。

もしOrganizationalUnitが何階層かになっており、その親子関係なども取得したいような場合は managedPolicies で指定している statements 内の actions が不足しているので、追加が必要です。自環境では不要だったので未確認ですが、おそらく以下のようなものが必要になります。
– organizations:ListRoots
– organizations:ListChildren
– organizations:ListOrganizationalUnitsForParent
– organizations:DescribeAccount

ちなみに少しだけ例示すると、このロールをAssumeしてOrganizationalUnitの情報にアクセスするPythonコードはこんな感じ。

sts_client = boto3.client('sts')  
  
assumed_role = sts_client.assume_role(  
    RoleArn='arn:aws:iam::アカウントB:role/cost-check-role',  
    RoleSessionName='ou_read_session'  
)  
  
client = boto3.client(  
    'organizations',  
    aws_access_key_id=assumed_role['Credentials']['AccessKeyId'],  
    aws_secret_access_key=assumed_role['Credentials']['SecretAccessKey'],  
    aws_session_token=assumed_role['Credentials']['SessionToken']  
)

ou_cache = {}  
for account_id in LINKED_ACCOUNT.keys():  
    
    response = client.list_parents(ChildId=account_id)  
    parent_id = response['Parents'][0]['Id']  
  
    
    ou_response = client.describe_organizational_unit(OrganizationalUnitId=parent_id)  
    ou_cache[account_id] = ou_response['OrganizationalUnit']['Name']

ロググループ

この記事の趣旨的にはあまり重要ではないですが、Lambdaの実行ログを保持させるロググループを作ります。

new LogGroup(this, 'cost-check-lambda-log-group', {  
    logGroupName: 'cost-check-lambda-log-group',  
    retention: RetentionDays.ONE_MONTH,  
});

特別なことはなにもないです。

Lambda

最後に集計をさせるLambdaを作成します。

const func = new PythonFunction(this, 'cost-check-lambda', {  
    functionName: 'cost-check-lambda',  
    runtime: Runtime.PYTHON_3_13,  
    entry: 'src/lambda/cost',  
    index: 'cost.py',  
    handler: 'main',  
    timeout: Duration.minutes(3),  
    vpc: Vpc.fromVpcAttributes( this, 'cost-check-lambda-vpc', {  
        availabilityZones: ['ap-northeast-1c', 'ap-northeast-1d'],  
        vpcId: 'vpc-XXXXXXXXXXXXXXXXX',  
    }),  
    vpcSubnets: {
        subnets: [
            Subnet.fromSubnetId(this, 'subnet1', 'subnet-XXXXXXXXXXXXXXXXX'),
            Subnet.fromSubnetId(this, 'subnet2', 'subnet-XXXXXXXXXXXXXXXXX')

        ],  
    },  
    role: Role.fromRoleArn(this, 'lambda-worker-role', 'arn:aws:iam::アカウントB:role/lambda-worker-role'),  
    logGroup: LogGroup.fromLogGroupName(this, 'cost-check-lambda-log-group', 'cost-check-lambda-log-group'),  
});  

func.addPermission('アカウントA-permission', {  
            principal: new AccountPrincipal('アカウントA'),  
            action: 'lambda:InvokeFunction'
        })

デプロイ順序について

少しだけややこしいのでデプロイ順序を整理すると、下記のような順序で作業することになると思います。

  1. ロール1: Lambdaの呼び出し用ロール のデプロイ
    • 本来LambdaのArnを指定する箇所は一時的に * などにしてデプロイする
  2. アカウントA側のロールに権限追加
    • 1で作成したロールのArnを指定する
  3. ロール2: Lambda自体に付与するロールのデプロイ
    • 本来ロール3のArnを指定する箇所は一時的になし等でデプロイする
  4. ロール3: 集計スクリプト内でAssumeする用ロール
    • 3で作成したロールのArnを指定する
  5. ロール2: Lambda自体に付与するロールの更新
    • 4で作成したロールのArnを指定する
  6. ロググループのデプロイ
  7. Lambdaのデプロイ
    • 3で作成したロールを指定する
    • 6で作成したロググループを指定する
  8. ロール1: Lambdaの呼び出し用ロール の 更新
    • 7で作成したLambdaのArnを指定する

以上で「アカウントAのEC2インスタンスで内で動くRundeckから、アカウントBにあるLambdaを実行し、そのLambdaの中でOrganizationalUnitごとにAWSの利用料金を集計する」というシナリオで必要なリソースたちはすべて用意ができました。

あとはPythonコード内で assume_role でロールをAssumeし、 get_paginator を使ってアカウント一覧を取得、 list_parents で親アカウントの取得、describe_organizational_unit でOrganizationalUnitの取得、get_cost_and_usage で利用料金の取得、という感じで書いていけばOrganizationalUnitの情報に基づいてアカウントごとの利用料金を集計できます。

最小限の原則といいますか、「必要なところに必要なだけ」の方針でIAMロールを3つに分けたことにより、どこになんの権限が必要なのかを整理したり、デプロイする順序がややこしくなったりしたのが地味な難航ポイントでした。
特にデプロイの手順に関しては、権限を最小限にするにはどうしても相互に参照させる必要が生じるため複雑になってしまいましたが、ここはやむを得ないところかなと思います。

ではではお疲れ様でした☕




元の記事を確認する

関連記事