爆速でSnowflakeの外部ステージ(S3)連携を設定するためのCloudFormationテンプレートを作ってみた

2021.10.28

こんにちは!DA(データアナリティクス)事業本部 サービスソリューション部の大高です。

みなさん、Snowflakeの外部ステージ(S3)、使っていますか?私は使っています。

SnowflakeからS3にアクセスするためには、以下のドキュメントやエントリに記載のとおり、「ストレージ統合」を作成して、SnowflakeとAWSをいったりきたりしながら設定をする必要があるわけですが、正直なところちょっと大変です。

弊社プロダクトのデータ分析基盤「カスタマーストーリー アナリティクス」においては、この設定の自動化に対応しましたが、プロダクトを利用しない場合にはやらざるを得ないので少し困っていました。

そこで、今回はこれを解消するべく、CloudFormationテンプレートを作成してみたのでご紹介します。

前提

「外部ステージに利用するS3バケットは、AWS上に作成済みであること」を前提とします。

やってみた

では、さっそくやってみましょう。

大きな流れとしては、以下の流れになります。

  • (Snowflake) 仮の「ストレージ統合」を作成する
  • (AWS) CloudFormationを利用してサクッと設定を実施する
  • (Snowflake) 「ストレージ統合」を修正する
  • (Snowflake) 「外部ステージ」を作成する

公式ドキュメントにおける「ステップ1, 2, 5」をCloudFormationを利用してやってしまおう、という発想です。

(Snowflake) 仮の「ストレージ統合」を作成する

まずは仮の「ストレージ統合」を以下のSQLで作成します。

仮の「ストレージ統合」作成クエリ

CREATE OR REPLACE STORAGE INTEGRATION <integration_name>
  TYPE = EXTERNAL_STAGE
  STORAGE_PROVIDER = S3
  ENABLED = TRUE
  STORAGE_AWS_ROLE_ARN = 'arn:aws:iam::0:role/'
  STORAGE_ALLOWED_LOCATIONS = ('s3://')
;

<integration_name>ootaka_devio_interationなど、任意の名前に変更してください。

ここでの一番のポイントは、STORAGE_AWS_ROLE_ARNの値です。公式手順では先にAWS上でIAM Roleを作成してから、ここに値の設定を行うのですが、そうするとAWS側といったりきたりしないといけないので、ダミーの値を設定することで手数を減らしています。

「ストレージ統合」を作成したら、DESC INTEGRATIONコマンドを実行してSTORAGE_AWS_IAM_USER_ARNSTORAGE_AWS_EXTERNAL_IDの値を手に入れます。この値は後でCloudFormationのパラメータとして利用します。

「ストレージ統合」情報取得クエリ

DESC INTEGRATION <integration_name>;

(AWS) CloudFormationを利用してサクッと設定を実施する

STORAGE_AWS_IAM_USER_ARNSTORAGE_AWS_EXTERNAL_IDの値が手に入ったら、AWSの管理コンソールでCloudFormationの画面を開きます。

「スタックの作成」から、下記のテンプレートをファイル保存したものなどを指定してください。なお、このテンプレートではIAMポリシーがインラインポリシーになるのですが、今回はこれは目をつむりました。(公式ドキュメントの手順だと、個別のポリシーとして作成していますね)

setup-external-stage-settings.yml

AWSTemplateFormatVersion: "2010-09-09"
Description: "Snowflake - S3 Secure Access Configuration"

Parameters:
  StorageAwsIamUserArn:
    Type: String
    Description: "[Required] STORAGE_AWS_IAM_USER_ARN value obtained by Snowflake's DESC INTEGRATION command"
    # "[必須]SnowflakeのDESC INTEGRATIONコマンドで取得したSTORAGE_AWS_IAM_USER_ARNの値"

  StorageAwsExternalId:
    Type: String
    Description: "[Required] STORAGE_AWS_IAM_EXTERNAL_ID value obtained by Snowflake's DESC INTEGRATION command"
    # "[必須]SnowflakeのDESC INTEGRATIONコマンドで取得したSTORAGE_AWS_IAM_EXTERNAL_IDの値"

  S3BucketName:
    Type: String
    Description: "[Required] S3 bucket name to be set on the external stage"
    # "[必須]外部ステージに設定するS3バケット名"

  S3PathPrefix:
    Type: String
    Description: "[Optional] S3 path prefix to set on the external stage (ex: snowflake/load/ )"
    # "[任意]外部ステージに設定するS3のパスプレフィックス (例: snowflake/load/ )"

  ReadOnlyBucket:
    Type: String
    Description: "[Required] Whether it is a read-only bucket (yes / no)"
    # "[必須]読み取り専用バケットか否か(yes/no)"
    AllowedValues:
      - "yes"
      - "no"
    Default: "no"

  IAMPolicyName:
    Type: String
    Description: "[Required] IAM policy name for Snowflake. Alphanumeric characters and + =,. @ -_ Can be used. Maximum 128 characters."
    # "[必須]Snowflake用IAMポリシー名。英数字と「 +=,.@-_ 」が利用可能。最大 128 文字。"
    MaxLength: 128

  IAMRoleName:
    Type: String
    Description: "[Required] IAM role name for Snowflake. Alphanumeric characters and + =,. @ -_ Can be used. Maximum 64 characters."
    # "[必須]Snowflake用IAMロール名。英数字と「 +=,.@-_ 」が利用可能。最大 64 文字。"
    MaxLength: 64

  IAMRoleDescription:
    Type: String
    Description: "[Optional] IAM role description for Snowflake. Alphanumeric characters and + =,. @ -_ Can be used. Maximum 1000 characters."
    # "[任意]Snowflake用IAMロール説明。英数字と「 +=,.@-_ 」が利用可能。最大 1000 文字。"
    MaxLength: 1000

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      -
        Label:
          default: "Snowflake Information"
        Parameters:
          - StorageAwsIamUserArn
          - StorageAwsExternalId
      -
        Label:
          default: "S3 Bucket Information"
        Parameters:
          - S3BucketName
          - S3PathPrefix
          - ReadOnlyBucket
      -
        Label:
          default: "IAM Information"
        Parameters:
          - IAMPolicyName
          - IAMRoleName
          - IAMRoleDescription

Conditions:
  IsS3PathPrefixEmpty: !Equals [!Ref "S3PathPrefix", ""]
  IsReadOnlyBucket: !Equals [!Ref "ReadOnlyBucket", "yes"]
  IsWritableBucket: !Equals [!Ref "ReadOnlyBucket", "no"]

Resources:

  SnowflakeReadOnlyIAMPolicy:
    Condition: IsReadOnlyBucket
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: !Ref IAMPolicyName
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - s3:GetObject
              - s3:GetObjectVersion
            Resource: !If
              - IsS3PathPrefixEmpty
              - !Sub arn:aws:s3:::${S3BucketName}/*
              - !Sub arn:aws:s3:::${S3BucketName}/${S3PathPrefix}*
          - Effect: Allow
            Action:
              - s3:ListBucket
              - s3:GetBucketLocation
            Resource: !Sub arn:aws:s3:::${S3BucketName}
            Condition:
              StringLike:
                  s3:prefix: !If
                    - IsS3PathPrefixEmpty
                    - '*'
                    - !Sub ${S3PathPrefix}*
      Roles:
        - !Ref SnowflakeIAMRole

  SnowflakeWritableIAMPolicy:
    Condition: IsWritableBucket
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: !Ref IAMPolicyName
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - s3:PutObject
              - s3:GetObject
              - s3:GetObjectVersion
              - s3:DeleteObject
              - s3:DeleteObjectVersion
            Resource: !If
              - IsS3PathPrefixEmpty
              - !Sub arn:aws:s3:::${S3BucketName}/*
              - !Sub arn:aws:s3:::${S3BucketName}/${S3PathPrefix}*
          - Effect: Allow
            Action:
              - s3:ListBucket
              - s3:GetBucketLocation
            Resource: !Sub arn:aws:s3:::${S3BucketName}
            Condition:
              StringLike:
                  s3:prefix: !If
                    - IsS3PathPrefixEmpty
                    - '*'
                    - !Sub ${S3PathPrefix}*
      Roles:
        - !Ref SnowflakeIAMRole

  SnowflakeIAMRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Ref IAMRoleName
      Description: !Ref IAMRoleDescription
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          -
            Effect: Allow
            Principal:
              AWS:
                - !Ref StorageAwsIamUserArn
            Action: sts:AssumeRole
            Condition: 
              StringEquals:
                sts:ExternalId: !Ref StorageAwsExternalId

Outputs:
  StorageAwsRoleArn:
    Description: "STORAGE_AWS_ROLE_ARN"
    Value: !GetAtt SnowflakeIAMRole.Arn

  StorageAwsRoleArnAlterCommand:
    Description: "STORAGE_AWS_ROLE_ARN Alter Command"
    Value: !Join
      - ""
      - - "ALTER INTEGRATION <integration_name> SET STORAGE_AWS_ROLE_ARN = '"
        - !GetAtt SnowflakeIAMRole.Arn
        - "';"

  StorageAllowedLocations:
    Description: "STORAGE_ALLOWED_LOCATIONS"
    Value: !If
      - IsS3PathPrefixEmpty
      - !Sub "('s3://${S3BucketName}/')"
      - !Sub "('s3://${S3BucketName}/${S3PathPrefix}')"

  StorageAllowedLocationsAlterCommand:
    Description: "STORAGE_ALLOWED_LOCATIONS Alter Command"
    Value: !Join
      - ""
      - - "ALTER INTEGRATION <integration_name> SET STORAGE_ALLOWED_LOCATIONS = "
        - !If
          - IsS3PathPrefixEmpty
          - !Sub "('s3://${S3BucketName}/')"
          - !Sub "('s3://${S3BucketName}/${S3PathPrefix}')"
        - ";"

それぞれパラメータの概要に記載がある通り、必要な値を設定して構築を行います。構築は数分で終わると思います。

構築が終わったら、CloudFormationスタックの「出力」タブに以下のように表示される「キー」のStorageAllowedLocationsAlterCommandStorageAwsRoleArnAlterCommandの値をコピーします。

出力例

# StorageAllowedLocationsAlterCommandの値
ALTER INTEGRATION <integration_name> SET STORAGE_ALLOWED_LOCATIONS = ('s3://foobar/sample/');

# StorageAwsRoleArnAlterCommandの値
ALTER INTEGRATION <integration_name> SET STORAGE_AWS_ROLE_ARN = 'arn:aws:iam::123456789012:role/foobar-role';

(Snowflake) 「ストレージ統合」を修正する

次に、Snowflakeに戻って「ストレージ統合」を修正します。先程CloudFormationスタックの「出力」から取得したクエリを貼り付けて実行します。この時、<integration_name>は最初に作成した自分の「ストレージ統合」の名前に置き換えます。

「ストレージ統合」修正クエリ例

ALTER INTEGRATION ootaka_devio_interation SET STORAGE_ALLOWED_LOCATIONS = ('s3://foobar/sample/');
ALTER INTEGRATION ootaka_devio_interation SET STORAGE_AWS_ROLE_ARN = 'arn:aws:iam::123456789012:role/foobar-role';

(Snowflake) 「外部ステージ」を作成する

これで「ストレージ統合」はできたので、最後に「外部ステージ」を自分の作成したい設定に応じて作成すれば完了です。

以下は、適宜データベースとスキーマを選択してから作成した外部ステージの作成クエリ例です。

「外部ステージ」作成クエリ例

CREATE OR REPLACE STAGE ootaka_devio_external_stage
  STORAGE_INTEGRATION = ootaka_devio_interation
  URL = 's3://foobar/sample/'
;

外部ステージが作成できたら、LISTコマンドでS3バケットの中身が参照できているか確認して完了です。

LISTコマンド例

LIST @ootaka_devio_external_stage;

まとめ

以上、爆速でSnowflakeの外部ステージ(S3)連携を設定するためのCloudFormationテンプレートを作ってみました。

個人的にAWS上でポチポチIAMポリシーとIAMロールを作成するのが面倒だったのですが、CloudFormationテンプレートを利用することで、だいぶこの作業が楽になりました。

どなたかのお役に立てば幸いです。それでは!