Cloud FTPをTerraformで構築する|SFTPでファイルを置いて取るまで

Cloud FTP Terraform と書かれたアイキャッチ。鍵付きの封筒から SFTP サーバー、Cloud Storage のバケットへ矢印でつながり、右下に砂時計が置かれている。画像内の文字は Cloud FTP Terraform、Build, Verify, Delete、SFTP Client、Cloud FTP Server、Cloud Storage データパイプライン

はじめに

外部から SFTP でファイルを受け取る必要があり、踏み台を自前で立てずに済ませたいと考えました。Cloud FTP は Cloud Storage を保存先にできるマネージドの SFTP サーバーで、Terraform で定義できます。

この記事では、google_storage_ftp_server と google_storage_ftp_user を書いて apply します。Mac の sftp でファイルを置いて取り、最後にサーバーを削除するまでを扱います。検証は自分の検証用プロジェクトで行いました。

本記事のまとめ

  • google_storage_ftp_server と google_storage_ftp_user は provider v8.2.0 で追加された。設計上必要な引数がそろうのは v8.5.0 以降である
  • サーバーは東京・大阪リージョンに対応していない。日本から最も近い対応リージョンはソウル(asia-northeast3)だが、バケットは東京に置ける。ソウルのサーバーから東京のバケットへ書き込めることは実際に確かめた
  • 転送量が 0 でも、起動しているあいだは稼働時間で課金される。停止用の Terraform 引数は無いため、課金を確実に止める手段は削除である
  • SFTP から置いたファイルは Cloud Storage にそのまま現れ、取り出した実体の MD5 もオブジェクトと一致した。
Cloud FTP のサーバー一覧。サーバー ID が sftp-sandbox、場所が asia-northeast3、状態がアクティブ、アクセスタイプが EXTERNAL で 1 件表示されている
Terraform で apply したあとの Cloud FTP の一覧。ソウル(asia-northeast3)に EXTERNAL のサーバーが 1 台立っている

結論|Cloud FTPを使う前に知っておく3つの制約

Terraform で立てられますが、その前に 3 つの制約があります。provider のバージョン、対応リージョン、稼働時間課金です。

制約内容対処
provider のバージョンリソースが追加されたのは google provider v8.2.0。external_config と service_agent は v8.5.0 で追加既存環境の provider を上げるか、google-beta だけ上げて provider = google-beta を明示する
対応リージョン東京(asia-northeast1)と大阪(asia-northeast2)は対応リージョンの一覧に無いサーバーは日本から最も近いソウル(asia-northeast3)に置く。制約を受けるのはサーバーだけで、保存先のバケットは東京に置ける
稼働時間課金サーバーの稼働時間と転送量の両方で課金される。転送量が 0 でも起動中は課金が続く使うときだけ立てて、終わったら削除する

料金は公式の Pricing に 2 つのメーターとして示されています。

  • サーバー稼働 — 1 時間あたり 0.30 ドル
  • データ転送 — アップロードとダウンロードのどちらも 1 GB あたり 0.04 ドル

公式ページ自身が、24 時間 365 日動かした場合の例として 0.30 ドル × 24 時間 × 30 日 = 216 ドル/月を挙げています。

停止については、公式ドキュメントが「データ転送が見込まれない期間はサーバーを一時停止することで稼働コストを減らせる」と書いています。減らせるとしか書かれておらず、0 になるとは書かれていません。停止中はアクセスが拒否され、接続中のセッションも切断されます。

サーバーはソウル、バケットは東京に置ける

リージョンの制約を受けるのはサーバーだけで、保存先の Cloud Storage バケットは東京に置けます。ソウルのサーバーから東京のバケットへ put が通ることは、実際に動かして確かめました。データを国内に置きたいなら、この組み合わせを選べます。

この組み合わせが使えるかどうかは、公式ドキュメントには書かれていません。サーバーとバケットのリージョン関係に触れている箇所を見つけられなかったため、可否が分からないまま推奨はできないと考え、自分で通しました。

検証は 2 通りの構成で行いました。

  • サーバーとバケットをソウル(asia-northeast3)にそろえた構成
  • サーバーをソウル、バケットを東京(asia-northeast1)にした構成

サーバーとバケットを別のリージョンにすると、クロスリージョンの転送料金が乗ります。ただし今回流したのは 38 バイトなので、料金がどう増えるかまでは測れていません。まとまった量を日常的に流すなら、先に見積もってください。

Consoleでの場所は「Storage Transfer」配下

Cloud FTP は独立したメニューではなく、ナビゲーションの「Storage Transfer」配下にあります。Cloud Storage の下を探しても見つかりません。作ったはずのサーバーが見当たらないときは、まずここを確認してください。

Google Cloud コンソールの左ナビゲーション。Storage Transfer の下に転送ジョブ、エージェント プール、Cloud FTP が並び、Cloud FTP が選択されている
Cloud FTP は独立したメニューではなく Storage Transfer 配下にある。プロジェクト名が出るコンソールのヘッダ帯は切り落とした

Cloud FTPの構成要素とGCSへの到達経路

リソースは SFTP サーバーと SFTP ユーザーの 2 つだけで、クライアント側のリソースはありません。公式ドキュメントによると、扱えるプロトコルは SFTP で、認証は公開鍵で行います。FTP・FTPS とパスワード認証の記載はありません。

サーバーには 2 つのアクセス種別があります。

  • external — 「インターネットから到達でき、アクセスは指定した IP レンジに限定される」
  • internal — 「自分の VPC ネットワーク内からのみアクセスできる」

今回は、追加のネットワーク整備が不要で手元の Mac から試せる external を選びました。

接続に使うクライアントは標準的な SFTP ツールです。公式の接続手順が挙げているのは Cyberduck、FileZilla、OpenSSH、PuTTY、WinSCP で、ポートはいずれも 22 を指定します。Cloud FTP 固有の SDK はありません。

SFTPユーザーは専用のサービスアカウントで動く

SFTP ユーザーはサービスアカウントに紐づき、そのサービスアカウントの権限で Cloud Storage を読み書きします。ユーザー自身がバケットの権限を持つのではありません。公式のユーザー追加手順では、そのサービスアカウントにロールを付けるとされています。読み取りだけなら roles/storage.objectViewer、読み書きなら roles/storage.objectAdmin です。

同じページに、作る側に必要なロールと上限も書かれています。サーバーとユーザーの作成には roles/ftp.admin が必要です。1 ユーザーに登録できる公開鍵は最大 10 個、アクセスを許可できるバケットは最大 10 個です。

保存先のバケットは、先に触れたとおりサーバーと別のリージョンに置いています。サーバーはソウル、バケットは東京です。検証前の中身は空で、ここにファイルが現れるかをこのあと確かめます。

ロケーションが asia-northeast1(東京)の Cloud Storage バケット。オブジェクトは 0 件で「表示する行がありません」と表示されている。バケット名はプロジェクト ID の部分だけ伏せてある
検証前の東京のバケット。サーバーはソウルだが、保存先はこのとおり東京に置ける。バケット名のうちプロジェクト ID にあたる部分だけ伏せた

providerを引き上げる|google-betaだけ8系へ

Terraform を書き始める前に、使っている provider に当該リソースが入っているかを確かめてください。入っていない provider のまま書くと、plan ではなく Failed to load plugin schemas で plan 自体が起動しなくなります。エラーがリソース名を指してくれないので、原因にたどり着くまで時間を取られます。

リソースが入ったのは terraform-provider-google の PR #29155 で、CHANGELOG を読むと v8.2.0 でリリースされています。ただし v8.2.0 の時点では external か internal かを指定する引数と、後述する Service Agent の参照属性がありません。これらが追加されたのは v8.5.0 なので、実質の要件は v8.5.0 以降でした。

インストール済みバイナリを検査してリソースの有無を確かめる

CHANGELOG を読むだけでなく、手元に入っている provider のバイナリを文字列検査すると確実です。リソース名が含まれていなければ、その provider ではそのリソースを書けません。

strings .terraform/providers/registry.terraform.io/hashicorp/google-beta/*/*/terraform-provider-google-beta_* \
  | grep -o "storage_ftp" | sort -u

私の環境では、引き上げ前の google 5.45.2 と google-beta 7.46.1 のどちらもヒット 0 件でした。検査方法そのものが機能しているかは、確実に存在するリソース名(google_storage_bucket_iam_member)で試して 1 件ヒットすることを確かめています。

googleは据え置き、google-betaだけ上げる

既存リソースへの影響を小さくするため、google は据え置いて google-beta だけ 8 系へ上げました。この環境の google は 5 系で、8 系まで 3 メジャー離れています。上げると環境内の全リソースが評価対象になるため、FTP の 2 リソースのためにそこまで動かす判断はしませんでした。

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }

    # Cloud FTP(google_storage_ftp_server / google_storage_ftp_user)は provider v8.2.0 で追加され、
    # external_config / internal_config / service_agent は v8.5.0 で追加された。
    # google provider 側は `~> 5.0` のまま据え置いているため、Cloud FTP は google-beta で扱う。
    google-beta = {
      source  = "hashicorp/google-beta"
      version = "~> 8.0"
    }
  }
}

引き上げた直後に既存リソースへ差分が出ないかを確認しました。この環境には google-beta を使う既存リソースが約 40 件あります。CI で全体 plan を取ると 4 to add, 0 to change, 0 to destroy で、7.46.1 から 8.5.0 への引き上げは既存リソースに差分を出しませんでした。

なお、ftp.tf を置いたまま provider を 7 系に戻すと plan が起動しなくなります。戻せないことは不便ですが、7.46.1 に当該リソースが無いことの裏づけにもなりました。

SFTPサーバーとユーザーをTerraformで書く

どのバケットをどこに見せるかと、接続に使う SSH 公開鍵は、google_storage_ftp_user のほうに書きます。前者が storage_directory_mappings、後者が user_credentials です。アクセスするサービスアカウントは customer_service_account で指定します。

# SFTP サーバー本体
resource "google_storage_ftp_server" "sftp_sandbox" {
  provider = google-beta
  count    = length(var.ftp_allowed_cidr_blocks) > 0 ? 1 : 0

  project      = var.project_id
  location     = var.ftp_location
  server_id    = "sftp-sandbox"
  display_name = "Sandbox SFTP Server"
  access_type  = "EXTERNAL"

  external_config {
    allowed_cidr_blocks = var.ftp_allowed_cidr_blocks
  }

  depends_on = [google_project_service.ftp_api]
}

# SFTP ユーザー
resource "google_storage_ftp_user" "sftp_sandbox_rw" {
  provider = google-beta
  count    = length(var.ftp_allowed_cidr_blocks) > 0 && var.ftp_user_ssh_public_key != "" ? 1 : 0

  project                  = var.project_id
  location                 = var.ftp_location
  server_id                = google_storage_ftp_server.sftp_sandbox[0].server_id
  user_id                  = "sftp-sandbox-rw"
  customer_service_account = google_service_account.sa_ftp_sandbox.email

  # SFTP 側の /upload をバケット直下に対応づける
  storage_directory_mappings {
    bucket     = google_storage_bucket.storage_ftp_sandbox.name
    directory  = "/upload"
    permission = "READ_WRITE"
  }

  # 公開鍵は秘匿値ではないため変数で受ける
  user_credentials {
    credential_name     = "sftp-sandbox-rw-key"
    credential_type     = "PUBLIC_KEY"
    ssh_public_key_body = var.ftp_user_ssh_public_key
  }

  depends_on = [google_service_account_iam_member.ftp_service_agent_token_creator]
}

API の有効化も Terraform で行います。サービス名は ftp.googleapis.com です。storageftp のような名前ではないので、google_project_service に書くときは注意してください。

許可CIDRが空ならサーバーを作らない

上のコードで count に許可 CIDR の件数を使っているのは、許可する接続元を決めないままインターネットに開かせないためです。EXTERNAL のサーバーはインターネットから到達できるので、変数が空のうちはサーバーとユーザーのどちらも作られません。

許可 CIDR と公開鍵は、variables.tf の default に置き、Terraform で管理しています。公開鍵は設計上公開される値なので、コミットしても問題ありません。

Cloud FTP のサーバー一覧が 0 件で、「リソースはありません」と表示されている画面
許可 CIDR が空のあいだは count が 0 になり、サーバーもユーザーも作られない。インターネットに開いたまま放置されずに済む

IAMは2本必要で、1本はサーバーを作らないと書けない

必要な IAM は、バケットへの権限と、Cloud FTP Service Agent からの権限借用の 2 本です。Service Agent は Google が管理するサービスアカウントです。SFTP ユーザーのサービスアカウントのトークンを作るために、roles/iam.serviceAccountTokenCreator が必要になります。これが無いとユーザーは Cloud Storage へアクセスできません。

# SFTP ユーザーが GCS バケットを読み書きするための権限(対象 bucket のみ)
resource "google_storage_bucket_iam_member" "sa_ftp_sandbox_storage_object_admin" {
  bucket = google_storage_bucket.storage_ftp_sandbox.name
  role   = "roles/storage.objectAdmin"
  member = "serviceAccount:${google_service_account.sa_ftp_sandbox.email}"
}

# Cloud FTP Service Agent が SFTP ユーザーの SA のトークンを作成するための権限
#
# service_agent はサーバーを作らないと払い出されない(server リソースの computed 属性)。
resource "google_service_account_iam_member" "ftp_service_agent_token_creator" {
  count = length(var.ftp_allowed_cidr_blocks) > 0 ? 1 : 0

  service_account_id = google_service_account.sa_ftp_sandbox.name
  role               = "roles/iam.serviceAccountTokenCreator"
  member             = "serviceAccount:${google_storage_ftp_server.sftp_sandbox[0].service_agent}"
}

Service Agent のメールアドレスは p-<プロジェクト番号>-<乱数>@gcp-sa-ftp.iam.gserviceaccount.com の形式でした。乱数部分があるため形式から組み立てることはできませんが、サーバーリソースの service_agent 属性から取れます。google_project_service_identity を別に立てる必要はありません。

applyしてSFTPで置いて取る

apply は 2 回に分かれました。許可 CIDR が空のうちはサーバーが作られない書き方にしているので、この順になります。

  1. API の有効化、サービスアカウント、バケット、バケットの IAM の 4 つ
  2. 許可 CIDR と公開鍵を入れて、サーバー、ユーザー、権限借用の 3 つ

サーバーの作成に4分かかる

サーバーの作成には 4 分 7 秒かかりました。ユーザーは 11 秒、IAM の付与は 4 秒で、時間がかかるのはサーバーだけです。待っている最中に失敗したと勘違いしないよう、数分かかる前提で見ておくとよいでしょう。削除も同じく時間がかかり、こちらは 4 分 46 秒でした。

Terraform の apply ログ。Plan: 3 to add から Apply complete! Resources: 3 added まで続き、サーバーが Creation complete after 4m7s、IAM が 4s、ユーザーが 11s と出ている。id の行は伏せてある
CI で流した apply のログ。サーバーだけ 4 分 7 秒かかり、ユーザーは 11 秒、IAM の付与は 4 秒で終わっている。id の行はプロジェクト ID・プロジェクト番号・Service Agent のアドレスを伏せた

できたサーバーの状態を確認しようとして、ひとつ手が止まりました。gcloud に Cloud FTP のコマンドが見つかりません。結局、REST API のエンドポイント https://ftp.googleapis.com/v1/projects/.../locations/.../servers を直接叩きました。認証には gcloud auth print-access-token のトークンを使っています。Terraform の output にサーバーの IP と SFTP のユーザー名を出しておくほうが実用的です。

そのユーザー名には注意点があります。SFTP のログイン名は、設定した user_id ではなく払い出された username です。今回は両者が同じ値でしたが、username は computed の属性なので、クライアント設定には output 経由で取った値を使うのが安全です。

sftpで繋いでput・getを試す

OpenSSH の sftp で、鍵認証・アップロード・ダウンロードがすべて通りました。Cloud FTP は公開鍵認証しか用意しておらず、パスワードを入力する場面はありません。使ったコマンドは次の 4 つです。

# 秘密鍵を指定して接続する(IP とユーザー名は Terraform の output から取る)
sftp -i ~/.ssh/id_rsa_cloud_ftp sftp-sandbox-rw@<サーバーの IP>

# 接続後、SFTP のプロンプトで実行する
put ftp-test.txt
get ftp-test.txt ftp-test-downloaded.txt

# ローカルに戻ってから、GCS 側のオブジェクトを確認する
gcloud storage ls --long gs://<バケット名>/
鍵認証          : 成立。パスワードを要求されない
アップロード    : 38 バイト転送。READ_WRITE が機能している
GCS への反映    : gs://<バケット名>/ftp-test.txt として存在。内容も一致
ダウンロード    : 取り出した実体の MD5 が GCS オブジェクトのものと一致
ロケーションが asia-northeast1(東京)の Cloud Storage バケットのオブジェクト一覧に、ftp-test.txt が 38 B・text/plain で 1 件表示されている。バケット名はプロジェクト ID の部分だけ伏せてある
ソウルの SFTP サーバーから put した直後。東京のバケットに ftp-test.txt が 38 バイトで現れている

/uploadは実在しないパスで、ls -lの権限表示に意味はない

マッピングに書いた /upload は、Cloud Storage 側には存在しないパスです。bucket_prefix を指定しなければバケットのルートに対応します。SFTP 側では /upload/ftp-test.txt ですが、Cloud Storage 側では upload/ が付かずルート直下のオブジェクトになります。ログイン直後の作業ディレクトリが /upload そのものになるので、ルートに upload というディレクトリが見えるわけでもありません。

なお、ls -l が返す -rwxrwxrwx と uid=0 / gid=0 に意味はありません。実体が Cloud Storage でパーミッションの概念が無いためで、「誰でも書ける」という意味ではありません。

実際のアクセス制御は 2 層に分かれています。

  • 誰が繋げるか — 許可 CIDR と SSH 公開鍵で決まる
  • 何ができるか — マッピングの permission と、サービスアカウントのバケット権限で決まる

運用で気をつける点が 2 つ残ります。

  • put は同名ファイルを確認なしで上書きする。バージョニングを有効にしていないバケットでは前の内容が消える
  • Cloud Storage のオブジェクトのメタデータに書き込んだ主体は残らない。誰が置いたかを追うには Cloud Audit Logs が要る

ホスト鍵の照合と、使い終わったあとの撤去

ホスト鍵のフィンガープリントは、サーバーの googleManagedServerCredential から取れます。ここに fingerprint(SHA256)と asymmetricAlgorithm(今回は ED25519)が入っています。sftp の初回接続プロンプト、REST API のレスポンス、Console のサーバー詳細の 3 経路すべてで同じ値でした。初回接続のプロンプトに yes と答える前に照合できます。

Console からも読めるので、API を叩けない相手にフィンガープリントを渡せます。公式の接続手順は「管理者から渡された鍵のフィンガープリントを入力してください」と書くだけで、その値をどこから取るかには触れていません。

Cloud FTP のサーバー詳細画面。Google マネージド サーバー認証情報の欄にフィンガープリントが SHA256 で、アルゴリズムが ED25519 と表示されている。ID・サービス エージェント・IP アドレス・許可された CIDR ブロックは伏せてある
ホスト鍵のフィンガープリントは Console からも読める。初回接続で yes と答える前にこの値と照合できる。ID・サービス エージェント・IP アドレス・許可 CIDR は伏せた

撤去は許可CIDRを空に戻すプルリクエストを出すだけ

課金を確実に止める手段は削除です。Cloud FTP には停止用の Terraform 引数がありません。state は computed の属性で、停止と起動は REST API の :stop / :start だけです。加えて、前述のとおり公式も停止で課金が 0 になるとは書いていません。

count の条件に許可 CIDR を使っているので、撤去は変数を空に戻すプルリクエストを出すだけで済みます。apply の結果は 0 added, 0 changed, 3 destroyed で、REST API のサーバー一覧が空を返すところまで確認しました。バケット・サービスアカウント・API の有効化・バケットの IAM は残しているので、再検証のときは許可 CIDR を入れる差分を出せば立ち上がります。

サーバーとバケットをソウルにそろえた構成では、立てていた時間は約 30 分でした。短時間で済ませたのは、転送量が 0 でも起動中は課金され続けるためです。

バケットを東京にした構成では、ここで失敗しました。定義づくりと apply のやり直しに手間取り、疎通検証を終えないまま、その日はサーバーを畳んでいます。検証は後日、立て直してやり直しました。停止用の引数が無い以上、立てた日のうちに検証まで終えて削除するしかありません。接続元の IP と鍵の用意、クライアント側の手順は、立てる前にそろえておいてください。

変動IPの回線から常用する使い方には向かない

external のサーバーは、変動 IP の回線から常用する使い方には向いていません。接続元の IP が変わるたびに許可 CIDR を更新することになり、更新のたびにプルリクエストか変数の書き換えが発生します。モバイル回線のように共有アドレスが割り当てられる環境では、/32 で絞っても同じ経路の他の利用者が到達しうる点にも注意が必要です。継続して使うなら、固定 IP の回線か internal のサーバーを先に検討してください。

接続元の IP を取るときは、IPv4 を明示してください。curl -s ifconfig.me のように指定すると IPv6 が返ることもあります。IPv6 の /32 は単一ホストではなく膨大な範囲です(単一ホストなら /128)。curl -4 を付ければ IPv4 が返ります。なお Cloud FTP が IPv6 の CIDR を受け付けるかは試していません。公式ドキュメントの例は IPv4 だけです。

まとめ

Cloud FTP は Terraform で立てられ、SFTP から Cloud Storage へファイルを置いて取るところまで通りました。手前で止まるのは provider のバージョンと対応リージョンの 2 つです。

  • provider は v8.5.0 以降が実質の要件である。既存環境を上げる前に、手元のバイナリを文字列検査して確かめる
  • 東京・大阪は対応リージョンの一覧に無い。サーバーはソウルに置くしかないが、バケットは東京のままでよい。ソウルのサーバーから東京のバケットへ書き込めることは確認済みである
  • 使い終わったら削除する。停止用の Terraform 引数は無く、転送量が 0 でも起動中は課金され続ける
  • ホスト鍵のフィンガープリントはサーバーの属性と Console から取れる。初回接続で yes と答える前に照合できる

サーバーを立てる前に Terraform の実行環境を整えておきたい場合は、Mac に Terraform をインストールする手順もあわせて参考にしてください。SSH 鍵の作り方は SSH キーの作成と GitHub への登録方法で扱っています。

なお、provider のバージョン要件、対応リージョンの一覧、料金の単価、管理画面の構成、gcloud に Cloud FTP のコマンドがあるかどうかは、いずれも変わります。実際に設定するときは、最新を公式ドキュメントで確認してください。