【AWS CDK】 Step FunctionsとAthenaでクロスアカウントのGlue Data Catalogデータを取得してみた
はじめに
データ事業本部のkasamaです。
今回は、AWS Step FunctionsとAthenaを使って、別アカウント(データソース)のGlue Data Catalogのデータを自アカウント(ターゲット)のGlue TableにInsertする仕組みをAWS CDKで実装してみます。
前提
まずはこのアーキテクチャを実現するにあたり、二通りの方法を検討しました。
Assume Role方式

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

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方式

ターゲットアカウントのStep FunctionsがTarget Roleで直接Athenaクエリを実行する方式です。ソースアカウントでGlue Data Catalog Resource Policyを設定し、Target RoleからSource Catalogへのアクセスを許可します。この方式により、Target RoleがTarget CatalogとS3への書き込み(自アカウントの権限)と、Source Catalogからの読み取り(Resource Policyによる許可)の両方が可能になります。今回は実装のシンプルさを優先し、この方式を採用しました。
公式ドキュメントと同一の手順です。
実装
以降はGlue Data Catalog Resource Policy方式の実装になります。
実装コードはGitHubに格納しています。
プロジェクト構成
$ 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ではスタックを定義しています。
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バケットを手動で指定する必要がありましたが、この機能により運用が簡素化されます。詳細は以下の記事を参考にしています。
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クエリが完了するまで自動的に待機します。
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 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文を定義します。
ソースアカウント
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:GetObjectとs3:ListBucket権限を許可します。
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
テスト用のサンプルデータです。
{
"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:GetDatabase、glue:GetTable、glue: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スタックが生成されています。

ソースアカウントの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を実行します。
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するとデータを確認できます。

ターゲットアカウントのGlue Database/Table作成
ターゲットアカウントのAthenaコンソールで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の内容を設定することもできます。

デプロイ後確認
AWSコンソールからStep Functionsを実行しました。1分ほどで処理は正常終了しました。

以下のクエリでデータがInsertされていることも確認できました。
SELECT * FROM cm_kasama_cross_account_target_db.sales_copy;

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

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







