Athenaで別アカウントのS3をクエリーする最短手順


やりたいこと

別アカウントのS3バケットにあるデータをAthenaからクエリーしたい。
別アカウント側のGlueカタログ(Glueのクロスアカウントアクセス)は使わず、あくまで自アカウントのGlueカタログに別アカウントのS3バケットを登録して、検索を実行するのがゴール。

前提

  • アカウントA:読み取られる側
  • アカウントB:読み取る側

とする。
こんなイメージ。

ステップバイステップ

アカウントAの準備

  • S3バケットを作成し、テストデータを置く。
    • バケット名はathena-crossaccount-20210310とする。
    • テストデータはこちらの記事で使用したものを再利用する。
  • クロスアカウントアクセスを許可するため、バケットポリシーを設定する。
    • アカウントBでこの後作成されるIAMロールに対してのみ許可する。ロール名はAthenaCrossAccountRoleと仮定。

(2021/6/2追記)
存在していないロールをバケットポリシーに入れることはできないので、この手順は正しくは「アカウントBの準備」でAthenaCrossAccountRoleを作成した後に実行するか、バケットポリシー保存直前で寸止めしておく必要があります。
(ご指摘いただいた@cols-wisteriaさん、ありがとうございました!)

バケットポリシー
{
    "Version": "2012-10-17",
    "Id": "Policy1495458089866",
    "Statement": [
        {
            "Sid": "Policy_GetObject",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::2XXXXXXXXXX2:role/AthenaCrossAccountRole"
            },
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::athena-crossaccount-20210310",
                "arn:aws:s3:::athena-crossaccount-20210310/*"
            ]
        }
    ]
}

アカウントBの準備

  • クエリー結果保管バケットを作り、Athenaワークグループ"primary"(デフォルト)にセットする。
    • ここではathena-query-result-4-sandboxとした。
  • Athenaでクエリーを実行するためのIAMポリシーAthenaCrossAccountAccessPolicyを作成する。
    • AmazonAthenaFullAccessというマネージドポリシーがあるので、これを参考に作る。
    • ちなみに上記のマネージドポリシーはs3:GetObject権限がチュートリアル用バケットと(デフォルト仕様の)クエリー結果保管バケットにしかないので、そのままでは現場で使えない。他にもせっかく絞ったs3:ListBucketが後段で全開放されてたりと若干つっこみどころの多いポリシーだが、ここでは細かいことは措いて先に進むことにする。
  • IAMロールAthenaCrossAccountRoleを作成し、上記ポリシーをアタッチする。
    • IAMロールではなくIAMユーザーにアタッチしてもよいが、今回は多少汎用性を高める必要があったのでロールとし、このロールにアカウントAからスイッチロールできるように設定(アカウントAをAssumeRoleの信頼元Principalに指定)した。
    • 実際には、ロールの信頼元をEC2やLambdaに指定して、プログラマティックにAthenaを呼び出すイメージ。
Athena用ポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "athena:*"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "glue:CreateDatabase",
                "glue:DeleteDatabase",
                "glue:GetDatabase",
                "glue:GetDatabases",
                "glue:UpdateDatabase",
                "glue:CreateTable",
                "glue:DeleteTable",
                "glue:BatchDeleteTable",
                "glue:UpdateTable",
                "glue:GetTable",
                "glue:GetTables",
                "glue:BatchCreatePartition",
                "glue:CreatePartition",
                "glue:DeletePartition",
                "glue:BatchDeletePartition",
                "glue:UpdatePartition",
                "glue:GetPartition",
                "glue:GetPartitions",
                "glue:BatchGetPartition"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetBucketLocation",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads",
                "s3:ListMultipartUploadParts",
                "s3:AbortMultipartUpload",
                "s3:CreateBucket",
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::athena-query-result-4-sandbox",
                "arn:aws:s3:::athena-query-result-4-sandbox/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::athena-crossaccount-20210310",
                "arn:aws:s3:::athena-crossaccount-20210310/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "s3:GetBucketLocation",
                "s3:ListAllMyBuckets"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "sns:ListTopics",
                "sns:GetTopicAttributes"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudwatch:PutMetricAlarm",
                "cloudwatch:DescribeAlarms",
                "cloudwatch:DeleteAlarms"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "lakeformation:GetDataAccess"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
  • Glueデータベースを作成する。
    • Glueデータベースは、Crawlerを使う場合もAthenaでDDLを実行する場合も、テーブルを格納するために等しく必要。
    • ここではathena_crossaccount_20210310とする。
  • DDLを実行する。
    • テーブルについては、CrawlerかDDL(あるいはCloudFormationか)のどちらかだけ必要。
    • JSONLであればCrawlerで自動認識されるスキーマでほぼほぼ無修正で行けるはずだが、ここではAthena単体でどういう権限が必要かを切り分けたいので、あえてDDLで作成する。
    • SerdeにはHiveとOpenXが選択可能だが、Glue Crawlerでは後者が使われていること、実際にクエリーしてみるとOpenXの方がエラーが少ないことから、ここではOpenXを選択。
CREATE EXTERNAL TABLE jsontest (
    id int,
    name string,
    age int
)
ROW FORMAT serde 'org.openx.data.jsonserde.JsonSerDe'
with serdeproperties ( 'paths'='age, id, name' )
LOCATION 's3://athena-crossaccount-20210310/';

クエリー実行

  • マネジメントコンソールで、アカウントAから、アカウントBのAthenaCrossAccountRoleにスイッチ。
  • Athenaでクエリーを実行。
  • 成功。

ハマリポイント

  • SerdeをHiveで作成すると、このデータセットではエラーとなった(nullが原因かも)。OpenXの方が融通利く模様。
  • Glueカタログを介して外部テーブルを読みに行く以上、Glueサービスロールにもクロスアカウント権限が必要か?とも思ったが、Glue CrawlerやGlue Jobを使わない限りはそもそもそのロール使わないので、今回試したロールのみバケットポリシーで許可すればOKだった。
  • 今回はAthena、というかAthenaの実行主体であるIAMロール(AthenaCrossAccountRole)は、クロスアカウントアクセスにassumeRoleを使ってない(あくまで検証上、アカウントBにスイッチする際にassumeRoleしているだけで、クロスアカウントのクエリー自体はS3のリソースベースポリシーを介して、ロールをスイッチすることなく実行されている)。assumeRoleを用いるパターンだと、sts:AssumeRoleの付与やリソース/プリンシパル縛りなど、これはこれで細々とした設定が必要になる。はず。