【AWS CDK】 Step FunctionsとAthenaでクロスアカウントのGlue Data Catalogデータを取得してみた

【AWS CDK】 Step FunctionsとAthenaでクロスアカウントのGlue Data Catalogデータを取得してみた

2025.11.09

はじめに

データ事業本部のkasamaです。
今回は、AWS Step FunctionsとAthenaを使って、別アカウント(データソース)のGlue Data Catalogのデータを自アカウント(ターゲット)のGlue TableにInsertする仕組みをAWS CDKで実装してみます。

前提

まずはこのアーキテクチャを実現するにあたり、二通りの方法を検討しました。

Assume Role方式

glue-db-to-glue-db
ソースアカウントにCatalogとS3にアクセスするIAM Roleを作成し、ターゲットアカウントのStep FunctionsがそのRoleをAssumeRoleしてクエリを実行する方式です。Selectクエリのみであれば、Source RoleがSource Catalogを読むだけなので、処理は成功します。しかしInsertの場合は、Source RoleがTarget Catalog、S3へ書き込みにいくため、権限が無く失敗します。

glue-db-to-glue-db-with-lambda
Assume Role方式で上記の問題を回避するには、Lambdaもしくは、Glue Python Shellを使います。
具体的には、まずSource RoleにAssumeRoleしてソースアカウントのデータを取得し、その後Target Roleに切り替えてターゲットアカウントのテーブルへInsertするロジックをLambda内で実装します。この方式により、各Roleがそれぞれ自アカウントのリソースにアクセスするため、権限の問題を回避できます。ただ、今回はシンプルな実装を優先したため、Glue Data Catalog Resource Policy方式を採用しました。Lambda方式は、より細かい制御が必要な場合や、Glue Data Catalog Resource Policyを設定できない制約がある環境では有効な選択肢となります。

Glue Data Catalog Resource Policy方式

glue-db-to-glue-db-with-resource-policy
ターゲットアカウントのStep FunctionsがTarget Roleで直接Athenaクエリを実行する方式です。ソースアカウントでGlue Data Catalog Resource Policyを設定し、Target RoleからSource Catalogへのアクセスを許可します。この方式により、Target RoleがTarget CatalogとS3への書き込み(自アカウントの権限)と、Source Catalogからの読み取り(Resource Policyによる許可)の両方が可能になります。今回は実装のシンプルさを優先し、この方式を採用しました。

公式ドキュメントと同一の手順です。

https://docs.aws.amazon.com/ja_jp/athena/latest/ug/security-iam-cross-account-glue-catalog-access.html

実装

以降はGlue Data Catalog Resource Policy方式の実装になります。
実装コードはGitHubに格納しています。

https://github.com/cm-yoshikikasama/blog_code/tree/main/58_cross_account_glue_athena

プロジェクト構成

$ tree
.
├── cdk
│   ├── bin
│   │   └── app.ts
│   ├── cdk.json
│   ├── lib
│   │   └── cross-account-glue-athena-stack.ts
│   ├── package-lock.json
│   ├── package.json
│   ├── parameter.ts
│   └── tsconfig.json
├── README.md
├── source
│   ├── create-database-and-table.sql
│   ├── glue-resource-policy.json
│   ├── s3.yml
│   └── sample-data
│       └── sales.csv
└── target
    ├── create-database-and-table.sql
    └── insert-query.sql

ソースアカウントのリソースとターゲットアカウントのGlue Database, TableはCloudFormationで定義しています。

ターゲットアカウント

#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { CrossAccountGlueAthenaStack } from '../lib/cross-account-glue-athena-stack';
import { devParameter } from '../parameter';

const app = new cdk.App();

// Get sourceAccountId from context (runtime argument)
const sourceAccountId = app.node.tryGetContext('sourceAccountId');

// Validate sourceAccountId
if (!sourceAccountId) {
  throw new Error(
    `sourceAccountId is required. Please provide it via context:
  npx cdk deploy --context sourceAccountId=111111111111`
  );
}

// Stack name using project name
const stackName = `${devParameter.projectName}-stack`;

new CrossAccountGlueAthenaStack(app, stackName, {
  stackName: stackName,
  description:
    'Target Account - Cross-account Glue Data Catalog access with Athena and Step Functions (tag:cross-account-glue)',
  env: {
    account: devParameter.env?.account || process.env.CDK_DEFAULT_ACCOUNT,
    region: devParameter.env?.region || process.env.CDK_DEFAULT_REGION,
  },
  tags: {
    Project: devParameter.projectName,
    Environment: devParameter.envName,
    Repository: 'blog-code-58',
  },
  projectName: devParameter.projectName,
  envName: devParameter.envName,
  parameter: devParameter,
  sourceAccountId,
});

app.tsではスタックを定義しています。

cdk/lib/cross-account-glue-athena-stack.ts
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as cdk from 'aws-cdk-lib';
import * as athena from 'aws-cdk-lib/aws-athena';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sfn from 'aws-cdk-lib/aws-stepfunctions';
import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks';
import type { Construct } from 'constructs';
import type { AppParameter } from '../parameter';

export interface CrossAccountGlueAthenaStackProps extends cdk.StackProps {
  projectName: string;
  envName: string;
  parameter: AppParameter;
  sourceAccountId: string;
}

export class CrossAccountGlueAthenaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: CrossAccountGlueAthenaStackProps) {
    super(scope, id, props);

    const { projectName, envName, sourceAccountId } = props;

    // ========================================
    // IAM Role - Step Functions Execution Role
    // ========================================
    const stepFunctionsExecutionRole = new iam.Role(this, 'StepFunctionsExecutionRole', {
      roleName: `${projectName}-${envName}-sfn-execution-role`,
      assumedBy: new iam.ServicePrincipal('states.amazonaws.com'),
      description: 'Execution role for Step Functions to execute cross-account Athena queries',
    });

    // ========================================
    // Target Account - S3 Bucket for data storage
    // ========================================
    const targetDataBucket = new s3.Bucket(this, 'TargetDataBucket', {
      bucketName: `${projectName}-${envName}-target-data`,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // Grant Step Functions execution role access to target bucket
    targetDataBucket.grantReadWrite(stepFunctionsExecutionRole);

    // Target database name (created manually via SQL)
    const targetDatabaseName = 'cm_kasama_cross_account_target_db';

    // ========================================
    // Target Account - Athena Workgroup with AWS Managed Storage
    // ========================================
    const targetWorkgroup = new athena.CfnWorkGroup(this, 'TargetAthenaWorkgroup', {
      name: `${projectName}-${envName}-target-workgroup`,
      description: 'Athena workgroup with AWS managed storage for target account queries',
      workGroupConfiguration: {
        enforceWorkGroupConfiguration: true,
        publishCloudWatchMetricsEnabled: true,
        engineVersion: {
          selectedEngineVersion: 'AUTO',
        },
        managedQueryResultsConfiguration: {
          enabled: true,
        },
      },
    });

    // Grant Athena permissions to Step Functions execution role
    stepFunctionsExecutionRole.addToPolicy(
      new iam.PolicyStatement({
        sid: 'AllowAthenaQueryExecution',
        effect: iam.Effect.ALLOW,
        actions: [
          'athena:StartQueryExecution',
          'athena:GetQueryExecution',
          'athena:GetQueryResults',
          'athena:StopQueryExecution',
          'athena:GetDataCatalog',
        ],
        resources: [
          `arn:aws:athena:${this.region}:${this.account}:workgroup/${targetWorkgroup.name}`,
          `arn:aws:athena:${this.region}:${this.account}:datacatalog/*`,
        ],
      })
    );

    // Grant Glue permissions to Step Functions execution role
    // - Target Catalog: Read/Write for INSERT query
    // - Source Catalog: Read-only via registered Data Catalog (enabled by Source Account's Resource Policy)
    stepFunctionsExecutionRole.addToPolicy(
      new iam.PolicyStatement({
        sid: 'AllowTargetGlueCatalogAccess',
        effect: iam.Effect.ALLOW,
        actions: [
          'glue:GetDatabase',
          'glue:GetTable',
          'glue:GetPartitions',
          'glue:BatchCreatePartition',
        ],
        resources: [
          `arn:aws:glue:${this.region}:${this.account}:catalog`,
          `arn:aws:glue:${this.region}:${this.account}:database/${targetDatabaseName}`,
          `arn:aws:glue:${this.region}:${this.account}:table/${targetDatabaseName}/*`,
        ],
      })
    );

    stepFunctionsExecutionRole.addToPolicy(
      new iam.PolicyStatement({
        sid: 'AllowSourceGlueCatalogReadAccess',
        effect: iam.Effect.ALLOW,
        actions: ['glue:GetDatabase', 'glue:GetTable', 'glue:GetPartitions'],
        resources: [
          `arn:aws:glue:${this.region}:${sourceAccountId}:catalog`,
          `arn:aws:glue:${this.region}:${sourceAccountId}:database/*`,
          `arn:aws:glue:${this.region}:${sourceAccountId}:table/*/*`,
        ],
      })
    );

    // Grant S3 read access to Source Account bucket
    stepFunctionsExecutionRole.addToPolicy(
      new iam.PolicyStatement({
        sid: 'AllowSourceS3BucketReadAccess',
        effect: iam.Effect.ALLOW,
        actions: ['s3:GetObject', 's3:ListBucket'],
        resources: [
          `arn:aws:s3:::${projectName}-${envName}-data`,
          `arn:aws:s3:::${projectName}-${envName}-data/*`,
        ],
      })
    );

    // ========================================
    // Athena Data Catalog - Register Source Account Catalog
    // ========================================
    const sourceCatalogName = 'source_catalog';
    new athena.CfnDataCatalog(this, 'SourceDataCatalog', {
      name: sourceCatalogName,
      type: 'GLUE',
      description: 'Cross-account Glue Data Catalog from Source Account',
      parameters: {
        'catalog-id': sourceAccountId,
      },
    });

    // ========================================
    // Step Functions - Athena Query State Machine
    // ========================================
    // Load INSERT query from file
    const insertTemplate = fs.readFileSync(
      path.join(__dirname, '../../target/insert-query.sql'),
      'utf-8'
    );
    const insertQuery = insertTemplate
      .replace('${TARGET_DATABASE}', targetDatabaseName)
      .replace('${SOURCE_CATALOG}', sourceCatalogName);

    // Execute INSERT query with .sync integration (waits for completion automatically)
    const executeInsertQuery = new tasks.AthenaStartQueryExecution(this, 'ExecuteInsertQuery', {
      queryString: insertQuery,
      workGroup: targetWorkgroup.name,
      integrationPattern: sfn.IntegrationPattern.RUN_JOB,
      comment: 'Execute cross-account INSERT query and wait for completion',
    });

    // Create state machine
    new sfn.StateMachine(this, 'AthenaInsertStateMachine', {
      stateMachineName: `${projectName}-${envName}-athena-insert`,
      definitionBody: sfn.DefinitionBody.fromChainable(executeInsertQuery),
      role: stepFunctionsExecutionRole,
      timeout: cdk.Duration.minutes(10),
      comment:
        'Cross-account Athena INSERT: Copy data from Source Account Glue Catalog to Target Account table',
    });
  }
}

cross-account-glue-athena-stack.tsでは、以下のリソースを作成します。

  • Step Functions実行用のIAM Role
  • ターゲットアカウントのS3バケット(INSERT先データ格納用)
  • Athena Workgroup
  • Athena Data Catalog(Source Account Catalog登録)
  • Step Functions State Machine

Athena Workgroupでは、managedQueryResultsConfiguration.enabled: trueを設定しています。これは、Athenaのクエリ結果をAWSマネージドストレージに保存する機能です。従来はS3バケットを手動で指定する必要がありましたが、この機能により運用が簡素化されます。詳細は以下の記事を参考にしています。

https://dev.classmethod.jp/articles/update-amazon-athena-aws-managed-storage-query-results/

Step Functions実行ロールには、以下の権限を付与しています。

  • ターゲットアカウントのGlue Catalogへの読み書き権限
  • ソースアカウントのGlue Catalogへの読み取り専用権限
  • ターゲットアカウントのS3への読み書き権限
  • ソースアカウントのS3への読み取り権限

IAM Roleの権限だけでは不十分で、ソースアカウント側でGlue Data Catalog Resource Policyによる明示的な許可が必要です。
Athena Data Catalogでは、AWS::Athena::DataCatalogリソースを使ってSource Account Catalogを登録しています。catalog-idパラメータにソースアカウントIDを指定することで、ターゲットアカウントからsource_catalog.database.tableの形式でクロスアカウント参照が可能になります。ちなみにglue:arn:aws:glue:us-east-1:999999999999:catalogのようにarnを指定することで、この登録処理は不要になります。先ほど紹介したAWS公式ドキュメントにも記載があります。

Step Functions State Machineでは、integrationPattern: sfn.IntegrationPattern.RUN_JOBを指定することで、Athenaクエリが完了するまで自動的に待機します。

parameter.ts
import { Environment } from 'aws-cdk-lib';

export interface AppParameter {
  env?: Environment;
  envName: string;
  projectName: string;
}

export const devParameter: AppParameter = {
  envName: 'dev',
  projectName: 'cm-kasama-cross-account',
  env: {},
};

export const prodParameter: AppParameter = {
  envName: 'prod',
  projectName: 'cm-kasama-cross-account',
  env: {},
};

parameter.tsでは環境ごとに活用するparameterを定義しています。

insert-query.sql
-- Insert data from source account to target account
-- Using registered Athena Data Catalog to access cross-account Glue Catalog
INSERT INTO ${TARGET_DATABASE}.sales_copy
SELECT * FROM ${SOURCE_CATALOG}.cm_kasama_cross_account_db.sales;

実際にAthenaで実行するInsert文を定義します。

ソースアカウント

source/s3.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "S3 Bucket for data storage"

Parameters:
  ProjectName:
    Type: String
    Default: cm-kasama-cross-account
    Description: Project name for resource naming
  EnvName:
    Type: String
    Default: dev
    Description: Environment name (dev, prod, etc.)
  TargetAccountId:
    Type: String
    Description: Target Account ID for cross-account access

Resources:
  DataBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "${ProjectName}-${EnvName}-data"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256

  DataBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref DataBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowTargetAccountRead
            Effect: Allow
            Principal:
              AWS: !Sub "arn:aws:iam::${TargetAccountId}:role/${ProjectName}-${EnvName}-sfn-execution-role"
            Action:
              - s3:GetObject
              - s3:ListBucket
            Resource:
              - !GetAtt DataBucket.Arn
              - !Sub "${DataBucket.Arn}/*"

ソースアカウントのS3バケットとBucket Policyを作成します。TargetAccountIdをパラメータとして受け取り、ターゲットアカウントのStep Functions実行ロールにs3:GetObjects3:ListBucket権限を許可します。

source/sample-data/sales.csv
id,product,amount,date
1,ProductA,1000,2025-01-01
2,ProductB,2000,2025-01-02
3,ProductC,1500,2025-01-03
4,ProductA,1200,2025-01-04
5,ProductD,3000,2025-01-05
6,ProductB,1800,2025-01-06
7,ProductC,2200,2025-01-07
8,ProductE,2500,2025-01-08
9,ProductA,1100,2025-01-09
10,ProductD,2800,2025-01-10

テスト用のサンプルデータです。

source/glue-resource-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::<TARGET_ACCOUNT_ID>:role/cm-kasama-cross-account-dev-sfn-execution-role"
      },
      "Action": ["glue:GetDatabase", "glue:GetTable", "glue:GetPartitions"],
      "Resource": [
        "arn:aws:glue:ap-northeast-1:<SOURCE_ACCOUNT_ID>:catalog",
        "arn:aws:glue:ap-northeast-1:<SOURCE_ACCOUNT_ID>:database/*",
        "arn:aws:glue:ap-northeast-1:<SOURCE_ACCOUNT_ID>:table/*/*"
      ]
    }
  ]
}

ターゲットアカウントのStep Functions実行ロールに対して、ソースアカウントのGlue Catalogへの読み取り専用権限(glue:GetDatabaseglue:GetTableglue:GetPartitions)を付与するResource Policyです。AWS CLIまたはAWSコンソールから設定します。

デプロイ

IAM Roleを先に作成するため、ターゲットアカウントから先にデプロイします。

ターゲットアカウントのCDKデプロイ

cd cdk

# 依存関係のインストール
npm install

# CDK Deploy(ソースアカウント IDを指定)
npx cdk deploy \
  --context sourceAccountId=<SOURCE_ACCOUNT_ID> \
  --require-approval never \
  --profile <TARGET_ACCOUNT_PROFILE>

実行するとCloudFormationスタックが生成されています。
Screenshot 2025-11-08 at 22.30.01

ソースアカウントのS3バケット作成

cd source

aws cloudformation create-stack \
  --stack-name cm-kasama-cross-account-s3 \
  --template-body file://s3.yml \
  --parameters \
    ParameterKey=TargetAccountId,ParameterValue=<TARGET_ACCOUNT_ID> \
    ParameterKey=EnvName,ParameterValue=dev \
  --profile <SOURCE_ACCOUNT_PROFILE>

ソースアカウントのサンプルデータアップロード

aws s3 cp sample-data/sales.csv s3://<SOURCE_BUCKET_NAME>/data/sales.csv \
  --profile <SOURCE_ACCOUNT_PROFILE>

ソースアカウントのGlue Database/Table作成

ソースアカウントのAthenaコンソールでSQLを実行します。

source/create-database-and-table.sql
CREATE DATABASE IF NOT EXISTS cm_kasama_cross_account_db
COMMENT 'Database for cross-account access testing'
LOCATION 's3://<SOURCE_BUCKET_NAME>/data/';

CREATE EXTERNAL TABLE IF NOT EXISTS cm_kasama_cross_account_db.sales (
  id INT COMMENT 'Sales ID',
  product STRING COMMENT 'Product name',
  amount INT COMMENT 'Sales amount',
  date STRING COMMENT 'Sales date'
)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY ','
STORED AS TEXTFILE
LOCATION 's3://<SOURCE_BUCKET_NAME>/data/'
TBLPROPERTIES (
  'skip.header.line.count' = '1',
  'classification' = 'csv'
);

Selectするとデータを確認できます。
Screenshot 2025-11-08 at 22.52.37

ターゲットアカウントのGlue Database/Table作成

ターゲットアカウントのAthenaコンソールでSQLを実行します。

target/create-database-and-table.sql
CREATE DATABASE IF NOT EXISTS cm_kasama_cross_account_target_db
COMMENT 'Target database for cross-account data copy'
LOCATION 's3://<TARGET_BUCKET_NAME>/';

CREATE EXTERNAL TABLE IF NOT EXISTS cm_kasama_cross_account_target_db.sales_copy (
  id INT COMMENT 'Sales ID',
  product STRING COMMENT 'Product name',
  amount INT COMMENT 'Sales amount',
  date STRING COMMENT 'Sales date (YYYY-MM-DD)'
)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY ','
STORED AS TEXTFILE
LOCATION 's3://<TARGET_BUCKET_NAME>/sales_copy/'
TBLPROPERTIES (
  'classification' = 'csv'
);

ソースアカウントのGlue Data Catalog Resource Policy設定

source/glue-resource-policy.jsonのプレースホルダーを実際の値に置き換えてから以下のコマンドを実行します。

cd source

aws glue put-resource-policy \
  --policy-in-json file://glue-resource-policy.json \
  --enable-hybrid TRUE \
  --profile <SOURCE_ACCOUNT_PROFILE>

または、AWS Glueコンソールから「Data catalog」→「Catalog settings」→「Permissions」でsource/glue-resource-policy.jsonの内容を設定することもできます。
Screenshot 2025-11-08 at 23.02.40

デプロイ後確認

AWSコンソールからStep Functionsを実行しました。1分ほどで処理は正常終了しました。
Screenshot 2025-11-08 at 23.05.18

以下のクエリでデータがInsertされていることも確認できました。

SELECT * FROM cm_kasama_cross_account_target_db.sales_copy;

Screenshot 2025-11-08 at 23.06.44

ターゲットアカウントのS3バケット配下に、コピーされたデータファイルが格納されていることも確認できます。
Screenshot 2025-11-08 at 23.07.12

最後に

今回の検証を通じて、Glue Data Catalogのクロスアカウントアクセスにおける複数のアプローチとそれぞれの特徴を理解することができました。同じような課題に取り組まれている方の参考になれば幸いです。

この記事をシェアする

FacebookHatena blogX

関連記事