Snowflake × Terraform で S3 Storage Integration を構築してみた
はじめに
データ事業本部の kasama です。
今回はSnowflake と AWS S3 を連携させる Storage Integration を Terraform で構築してみたいと思います。
前提
前提として、オープンソース版のTerraformの話であり、HCP Terraform(クラウド版)は考慮していません。
以下のブログで記載した構成を元に実装していきたいと思います。moduleをユーザータイプ単位で作成し、dev/prdごとのmain.tfで呼び出す実装です。terraformのinitやplan,applyコマンドの実行はGitHub Actions経由で行います。
snowflake-terraform-sample % tree
.
├── .github
│   └── workflows
│       ├── dev-snowflake-terraform-cicd.yml
│       └── prd-snowflake-terraform-cicd.yml
├── environments
│   ├── dev
│   │   ├── backend.tf
│   │   ├── main.tf
│   │   ├── output.tf
│   │   ├── variable.tf
│   │   └── versions.tf
│   └── prd
│       ├── backend.tf
│       ├── main.tf
│       ├── output.tf
│       ├── variable.tf
│       └── versions.tf
└── modules
    └── data_engineer
        ├── main.tf
        ├── outputs.tf
        ├── variables.tf
        └── versions.tf
S3 Storage Integration作成時の循環依存とその解決策
Terraformでの課題
Snowflake公式ドキュメントでは、以下の順序でリソースを作成します。
- S3バケット用のIAMポリシーを作成
 - IAM Roleを作成(Trust Policyに自分のAWSアカウントIDを一時的に設定)
 - SnowflakeでStorage Integrationを作成(Step 2のIAM Role ARNを指定)
 DESC INTEGRATIONでSnowflakeが生成したSTORAGE_AWS_IAM_USER_ARNとSTORAGE_AWS_EXTERNAL_IDを取得- IAM RoleのTrust Policyを更新(Step 4で取得した値を設定)
 - Snowflakeで外部Stageを作成
 
この手順は手動での構築には適していますが、Terraformで実装する場合は一度作成したIAM RoleのTrust Policyを後から変更する必要があるため、1回のterraform applyで完結しません。初回apply後に手動でStorage Integrationから値を確認し、IAM RoleのTrust Policyを更新して再度applyする必要があり、運用の手間が増えます。
解決策
- ARN事前計算
 - Storage Integration作成
 - IAM Role作成(正しいTrust Policy)
 
IAM RoleのARNは予測可能な形式(arn:aws:iam::{アカウントID}:role/{ロール名})のため、AWSアカウントIDとロール名から事前に文字列として構築できます。SnowflakeがStorage Integration作成時にIAM Roleの存在を確認しないため、まだ存在しないIAM RoleのARNを使ってStorage Integrationを先に作成し、その出力値を使ってIAM Roleを後から作成することで、循環依存を回避できます。
実装
それでは実際の実装を見ていきます。
dev環境とprd環境では細かい設定値以外は同様であるため、今回はdev環境のみの実装とします。
GitHub Actionsのworkflowについては以下のブログで記載しましたので今回は省略します。
terraform {
  backend "s3" {
    bucket       = "<YOUR_TERRAFORM_STATE_BUCKET>"  # 例: your-company-dev-terraform-state
    key          = "snowflake/snowflake.tfstate"
    region       = "ap-northeast-1"
    encrypt      = true
    use_lockfile = true
  }
}
Terraform stateファイルの保存先を定義します。S3 bucket名は適切な値に置き換えてください。
# ========================================================================================
# S3 Storage Integration Configuration - Dev Environment
# ========================================================================================
# This configuration uses the data_engineer module for S3 storage integration
# ========================================================================================
module "data_engineer" {
  source = "../../modules/data_engineer"
  providers = {
    snowflake.sysadmin     = snowflake.sysadmin
    snowflake.accountadmin = snowflake.accountadmin
  }
  # Basic configuration
  database_name  = "RAW_DATA_DEV"
  warehouse_name = "RAW_DATA_DEV_ADMIN_WH"
  # Warehouse configuration
  warehouse_size                = "XSMALL"
  warehouse_auto_suspend        = 60
  warehouse_initially_suspended = true
  warehouse_min_cluster_count   = 1
  warehouse_max_cluster_count   = 1
  # Storage Integration configuration
  storage_integration_name    = "S3_DEV_INTEGRATION"
  storage_integration_enabled = true
  stage_name                  = "DEV_STAGE"
  stage_schema                = "PUBLIC"
  # AWS configuration
  aws_s3_bucket_name  = "<YOUR_S3_BUCKET_NAME>"        # 例: your-company-snowflake-dev-data
  aws_iam_role_name   = "<YOUR_IAM_ROLE_NAME>"         # 例: your-company-snowflake-dev-role
  aws_iam_policy_name = "<YOUR_IAM_POLICY_NAME>"       # 例: snowflake-s3-dev-access-policy
  # AWS Tags
  aws_tags = {
    Environment = "dev"
    ManagedBy   = "Terraform"
    Project     = "snowflake-terraform-sample"
  }
}
data_engineer moduleを呼び出し、Storage Integration、Database、Warehouse、Stageを一括作成します。ProvidersブロックでSnowflakeの複数role(sysadmin/accountadmin)を明示的に渡しています。AWS関連のリソース名(S3 Bucket、IAM Role、IAM Policy)は適切な値に置き換えてください。
########################
# Snowflake authentication
########################
variable "snowflake_organization_name" {
  type        = string
  description = "Snowflake organization name"
}
variable "snowflake_account_name" {
  type        = string
  description = "Snowflake account name"
}
variable "snowflake_private_key" {
  description = "Snowflake private key content (for CI/CD)"
  type        = string
  default     = ""
  sensitive   = true
}
Snowflake接続に必要な変数を定義します。これらの値はGitHub ActionsのSecretsからTF_VAR_プレフィックスで渡されます。
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    snowflake = {
      source  = "snowflakedb/snowflake"
      version = "~> 2.3.0"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "6.18.0"
    }
  }
}
# Snowflake provider with SYSADMIN role
provider "snowflake" {
  alias             = "sysadmin"
  role              = "SYSADMIN"
  organization_name = var.snowflake_organization_name
  account_name      = var.snowflake_account_name
  user              = "TERRAFORM_BLOG_USER"
  authenticator     = "SNOWFLAKE_JWT"
  private_key       = var.snowflake_private_key
  warehouse         = "TERRAFORM_BLOG_WH"
  preview_features_enabled = [
    "snowflake_stage_resource"
  ]
}
# Snowflake provider with ACCOUNTADMIN role (for admin module)
provider "snowflake" {
  alias             = "accountadmin"
  role              = "ACCOUNTADMIN"
  organization_name = var.snowflake_organization_name
  account_name      = var.snowflake_account_name
  user              = "TERRAFORM_BLOG_USER"
  authenticator     = "SNOWFLAKE_JWT"
  private_key       = var.snowflake_private_key
  warehouse         = "TERRAFORM_BLOG_WH"
  preview_features_enabled = [
    "snowflake_storage_integration_resource"
  ]
}
provider "aws" {
  region = "ap-northeast-1"
}
TerraformとProviderのバージョンを固定し、2つのSnowflake Provider(sysadmin/accountadmin)を定義します。Storage Integrationの作成にはACCOUNTADMIN権限が必要なため、aliasを使用して複数のRoleを使い分けます。
# AWS caller identity for ARN construction
data "aws_caller_identity" "current" {}
# Local variables for computed values
locals {
  s3_bucket_url = "s3://${var.aws_s3_bucket_name}/"
  aws_role_arn  = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.aws_iam_role_name}"
}
# Database
resource "snowflake_database" "db" {
  provider = snowflake.sysadmin
  name     = var.database_name
}
# Warehouse
resource "snowflake_warehouse" "wh" {
  provider            = snowflake.sysadmin
  name                = var.warehouse_name
  warehouse_size      = var.warehouse_size
  auto_suspend        = var.warehouse_auto_suspend
  initially_suspended = var.warehouse_initially_suspended
  min_cluster_count   = var.warehouse_min_cluster_count
  max_cluster_count   = var.warehouse_max_cluster_count
}
# S3 Storage Integration
resource "snowflake_storage_integration" "s3_integ" {
  provider                  = snowflake.accountadmin
  name                      = var.storage_integration_name
  type                      = "EXTERNAL_STAGE"
  enabled                   = var.storage_integration_enabled
  storage_provider          = "S3"
  storage_aws_role_arn      = local.aws_role_arn
  storage_allowed_locations = [local.s3_bucket_url]
}
# ===========================
# AWS Resources
# ===========================
# S3バケット、暗号化、バージョニング設定
resource "aws_s3_bucket" "snowflake_storage" {
  bucket = var.aws_s3_bucket_name
  tags   = var.aws_tags
}
# S3バケット暗号化
resource "aws_s3_bucket_server_side_encryption_configuration" "snowflake_storage" {
  bucket = aws_s3_bucket.snowflake_storage.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}
resource "aws_s3_bucket_versioning" "snowflake_storage" {
  bucket = aws_s3_bucket.snowflake_storage.id
  versioning_configuration {
    status = "Enabled"
  }
}
# IAMロール(Snowflake Storage Integrationの出力値を使用)
resource "aws_iam_role" "snowflake_access" {
  name = var.aws_iam_role_name
  path = "/"
  # Snowflake Storage Integrationの出力を直接使用
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "TrustIamUserOfSnowflakePolicy"
        Effect = "Allow"
        Principal = {
          AWS = snowflake_storage_integration.s3_integ.storage_aws_iam_user_arn
        }
        Action = "sts:AssumeRole"
        Condition = {
          StringEquals = {
            "sts:ExternalId" = snowflake_storage_integration.s3_integ.storage_aws_external_id
          }
        }
      }
    ]
  })
  tags = var.aws_tags
}
resource "aws_iam_role_policy" "snowflake_s3_access" {
  name = var.aws_iam_policy_name
  role = aws_iam_role.snowflake_access.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "ReadAndWriteObjectsInSpecificBucketPolicy"
        Effect = "Allow"
        Action = [
          "s3:PutObject",
          "s3:GetObject",
          "s3:GetObjectVersion",
          "s3:DeleteObject",
          "s3:DeleteObjectVersion"
        ]
        Resource = "${aws_s3_bucket.snowflake_storage.arn}/*"
      },
      {
        Sid    = "ListBucketAndGetLocationAllowPolicy"
        Effect = "Allow"
        Action = [
          "s3:ListBucket",
          "s3:GetBucketLocation"
        ]
        Resource = aws_s3_bucket.snowflake_storage.arn
        Condition = {
          StringLike = {
            "s3:prefix" = "*"
          }
        }
      }
    ]
  })
}
resource "aws_s3_bucket_policy" "snowflake_access" {
  bucket = aws_s3_bucket.snowflake_storage.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowSnowflakeAccess"
        Effect = "Allow"
        Principal = {
          AWS = aws_iam_role.snowflake_access.arn
        }
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject",
          "s3:ListBucket"
        ]
        Resource = [
          aws_s3_bucket.snowflake_storage.arn,
          "${aws_s3_bucket.snowflake_storage.arn}/*"
        ]
      }
    ]
  })
}
# ===========================
# Snowflake Stage (depends on AWS resources)
# ===========================
# External Stage
resource "snowflake_stage" "stage" {
  provider            = snowflake.sysadmin
  database            = snowflake_database.db.name
  schema              = var.stage_schema
  name                = var.stage_name
  url                 = local.s3_bucket_url
  storage_integration = snowflake_storage_integration.s3_integ.name
  # IAMロール作成後にStageを作成
  depends_on = [
    aws_iam_role.snowflake_access,
    aws_iam_role_policy.snowflake_s3_access
  ]
}
# Grant Storage Integration to SYSADMIN
resource "snowflake_grant_privileges_to_account_role" "integ_to_sysadmin" {
  provider          = snowflake.accountadmin
  privileges        = ["USAGE"]
  account_role_name = "SYSADMIN"
  on_account_object {
    object_type = "INTEGRATION"
    object_name = snowflake_storage_integration.s3_integ.name
  }
}
moduleのmain実装です。ポイントは以下になります。
- ARN事前計算: 
data.aws_caller_identity.currentでAWSアカウントIDを取得し、local.aws_role_arnでIAM Role ARNを事前に文字列として構築します。 - Storage Integration作成: 事前計算した
local.aws_role_arnを使用してStorage Integrationを作成します。この時点ではまだIAM Roleは存在していません。 - IAM Role作成: 
snowflake_storage_integration.s3_integ.storage_aws_iam_user_arnとstorage_aws_external_idを使用してTrust Policyを構築します。これによりSnowflakeが正しくAssumeRoleできるようになります。 - Stage作成: 
depends_onでaws_iam_role.snowflake_accessとaws_iam_role_policy.snowflake_s3_accessを明示的に指定し、IAMリソースの作成完了後にStageを作成します。 
# Basic configuration
variable "database_name" {
  description = "Database name"
  type        = string
}
variable "warehouse_name" {
  description = "Warehouse name"
  type        = string
}
# Warehouse configuration
variable "warehouse_size" {
  description = "Warehouse size"
  type        = string
}
variable "warehouse_auto_suspend" {
  description = "Auto suspend time in seconds"
  type        = number
}
variable "warehouse_initially_suspended" {
  description = "Initially suspended"
  type        = bool
}
variable "warehouse_min_cluster_count" {
  description = "Minimum cluster count"
  type        = number
}
variable "warehouse_max_cluster_count" {
  description = "Maximum cluster count"
  type        = number
}
variable "storage_integration_name" {
  description = "Storage integration name"
  type        = string
}
variable "stage_name" {
  description = "Stage name"
  type        = string
}
variable "stage_schema" {
  description = "Schema for the stage"
  type        = string
}
variable "storage_integration_enabled" {
  description = "Storage integration enabled"
  type        = bool
}
# AWS configuration
variable "aws_s3_bucket_name" {
  description = "S3 bucket name for Snowflake storage"
  type        = string
}
variable "aws_iam_role_name" {
  description = "IAM role name for Snowflake access"
  type        = string
}
variable "aws_iam_policy_name" {
  description = "IAM policy name"
  type        = string
}
variable "aws_tags" {
  description = "AWS resource tags"
  type        = map(string)
  default     = {}
}
moduleの入力変数を定義します。Snowflakeリソース(Database、Warehouse、Storage Integration、Stage)とAWSリソース(S3、IAM)の設定に必要なパラメータを受け取ります。
terraform {
  required_providers {
    snowflake = {
      source  = "snowflakedb/snowflake"
      version = "~> 2.3.0"
      configuration_aliases = [
        snowflake.sysadmin,
        snowflake.accountadmin
      ]
    }
    aws = {
      source  = "hashicorp/aws"
      version = "6.18.0"
    }
  }
}
moduleのprovider要件を定義します。aliasでsysadminとaccountadminの2つのSnowflake provider aliasを受け取れるようにしています。呼び出し側(environments/dev/main.tf)でprovidersブロックを使用して実際のproviderを渡す必要があります。
デプロイ
実装が完了したら、GitHub Actions 経由でデプロイします。
featureブランチで実装してpushします。
git checkout -b feature/s3-storage-integration
git add .
git commit -m "feat(dev): Add S3 Storage Integration"
git push origin feature/s3-storage-integration
GitHub 上で feature ブランチから develop ブランチへ Pull Request を作成します。

Pull Request を作成すると GitHub Actions が自動実行され、terraform plan の結果が PR にコメントされます。

PR を承認してマージすると、GitHub Actions が terraform apply を自動実行し、リソースがデプロイされます。

デプロイ後の確認
デプロイが完了したら、サンプルデータで Storage Integration が正しく動作することを確認します。
サンプルCSVデータの準備
AWS CloudShell上で以下のコマンドを実行し、S3にテストデータをuploadします。
# サンプルデータを作成
cat > sample_data.csv << 'EOF'
id,name,age,email
1,Alice,30,alice@example.com
2,Bob,25,bob@example.com
3,Charlie,35,charlie@example.com
4,Diana,28,diana@example.com
5,Eve,32,eve@example.com
EOF
# S3にアップロード
aws s3 cp sample_data.csv s3://<YOUR_S3_BUCKET_NAME>/sample_data/
Snowflakeでデータを確認
デプロイしたStorage Integration、IAM Role、外部Stageが正しく連携し、S3からSnowflakeへデータをロードできることを確認します。
まずは、外部StageがS3 Bucketのファイルを正しく認識できるか(Storage IntegrationとIAM Roleの認証が成功しているか)を確認します。
-- Stageのファイルを確認
LIST @RAW_DATA_DEV.PUBLIC.DEV_STAGE/sample_data/;
S3に格納されているデータを参照できました。

次に、COPY INTOコマンドでS3からSnowflakeテーブルへデータをロードできるかを確認します。
-- テーブル作成
USE ROLE SYSADMIN;
USE DATABASE RAW_DATA_DEV;
USE WAREHOUSE RAW_DATA_DEV_ADMIN_WH;
CREATE OR REPLACE TABLE users (
    id INTEGER,
    name STRING,
    age INTEGER,
    email STRING
);
-- Stageからデータをロード
COPY INTO users
FROM @DEV_STAGE/sample_data/sample_data.csv
FILE_FORMAT = (
    TYPE = 'CSV'
    FIELD_DELIMITER = ','
    SKIP_HEADER = 1
    FIELD_OPTIONALLY_ENCLOSED_BY = '"'
);
-- データ確認
SELECT * FROM users;
S3からSnowflakeへのデータロードが正常に動作していることが確認できました。

クリーンアップの方法はS3 Bucketの中身を空にしてからenvironments/dev/main.tfをコメントアウトしてデプロイすればOKです。
最後に
SnowflakeのS3 Storage IntegrationをTerraformでデプロイする際に2回に分けてデプロイする必要があると思い悩んでいましたので、同じような方の参考になれば幸いです。







