Cloud Functions で SFTP サーバから GCS にファイル転送してみた。

2021.11.05

こんにちは、みかみです。

やりたいこと

  • SFTP サーバにアクセスしてファイルを取得するバッチ処理を実装したい
  • バッチサーバは管理が面倒だしコストが嵩むので建てたくない
  • Cloud Functions 関数から SFTP サーバにアクセスして、ファイルを GCS に転送したい

前提

環境構築時などでコマンドを使用していますが、Google Cloud SDK(CLI)の実行環境は準備済みの前提です。 本エントリでは、準備不要ですぐに使える Cloud Shell を使用しました。

SFTP サーバを準備

動作確認で使用する SFTP サーバを建てます。

以下の記事を参考にさせていただきましたmm

Google Cloud 管理コンソールのナビゲーションメニュー「Compute Engine」から「VM インスタンス」を選択して、インスタンスを作成します。 東京リージョンで一番小さいマシンタイプを選択し、他はデフォルトのままで作成しました。

インスタンスが起動したようなので、「SSH」リンクからインスタンスに接続してみます。

無事インスタンスに接続できました。アカウント情報を確認しておきます。

動作確認時に取得するファイルも作成しておきます。

mikami_yuki@test-sftp:~$ mkdir sample
mikami_yuki@test-sftp:~$ cd sample/
mikami_yuki@test-sftp:~/sample$ vi test.txt

sample ディレクトリ配下に test.txt ファイルを作成し、hello! SFTP!! と入力して保存しました。

続いて、SSH 認証鍵を作成します。 ローカル PC で以下のコマンドを実行して、認証ファイルを作成しました。

ssh-keygen -t rsa -m PEM -b 4096 -C "mikami_yuki@test-sftp" -f test_sftp

コマンドを実行するとパスフレーズを求められるので、任意のパスフレーズを入力します。

HL00710:.ssh mikami.yuki$ ssh-keygen -t rsa -m PEM -b 4096 -C "mikami_yuki@test-sftp" -f test_sftp
Generating public/private rsa key pair.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in test_sftp.
Your public key has been saved in test_sftp.pub.
The key fingerprint is:
SHA256:tXLfEY6JLRe10dU5tF07+gHPWwbhIXlYatZyQceE580 mikami_yuki@test-sftp
The key's randomart image is:
+---[RSA 4096]----+
|            .=X*O|
|            o*oXX|
|          . *oBB+|
|         . * B=oE|
|        S = =.o+o|
|         o + ..o+|
|            . .o |
|                 |
|                 |
+----[SHA256]-----+

秘密鍵と公開鍵が作成されたことが確認できました。

HL00710:.ssh mikami.yuki$ ls -l | grep test_sftp
-rw-------  1 mikami.yuki  staff   3326 Nov  5 18:26 test_sftp
-rw-r--r--  1 mikami.yuki  staff    747 Nov  5 18:26 test_sftp.pub

作成した公開鍵を開いて内容をコピーし、SFTPサーバに登録しておきます。

先ほど作成した VM インスタンスの編集画面に入り、編集画面の下の方にある「SSH 認証鍵」項目から、公開鍵を登録しました。

ローカル PC から Cyberduck を使って SFTP接続できるか確認してみます。

「サーバ」項目に VM インスタンスのパブリック IP を入力し、プライベートキーのパスを指定します。

接続してみると、先ほど VM インスタンス上に作成したディレクトリとファイルが確認できました。

SSH 認証キーを Secret Manager に保存

先ほどはローカル PC 上に保存してあるキーファイルのパスを指定して SFTP 接続してみましたが、Cloud Functions から SFTP サーバにアクセスする場合には、キーファイルをローカルに保存することはできません。 また、仮に Cloud Functions ではなく VM インスタンスからアクセスする場合でも、Secret Manager を使えばよりセキュアにキー情報を管理できます。

SFTP 接続時に使用するプライベートキーとパスフレーズを、Secret Manager のシークレットバージョンを作成して保管します。

以下のコマンドで、シークレットを作成し、プライベートキーを保管するシークレットとバージョンを作成します。

gcloud secrets create test-sftp
gcloud secrets versions add test-sftp --data-file="./test_sftp"

同様に、パスフレーズを保管するシークレットバージョンも作成しました。

gcloud secrets create test-key-pass
gcloud secrets versions add test-key-pass --data-file="./key_pass"

作成したシークレットを確認します。

mikami_yuki@cloudshell:~/sftp (cm-da-mikami-yuki-258308)$ gcloud secrets list
NAME: test-key-pass
CREATED: 2021-11-05T09:46:58
REPLICATION_POLICY: automatic
LOCATIONS: -

NAME: test-sftp
CREATED: 2021-11-05T09:31:36
REPLICATION_POLICY: automatic
LOCATIONS: -

管理コンソールからも、シークレットバージョンが正常に作成されたことが確認できました。

続いて、作成したシークレットにアクセスするためのサービスアカウントを作成し、アクセス権を付与します。

gcloud iam service-accounts create test-sftp
mikami_yuki@cloudshell:~/sftp (cm-da-mikami-yuki-258308)$ gcloud iam service-accounts list | grep test-sftp
EMAIL: test-sftp@cm-da-mikami-yuki-258308.iam.gserviceaccount.com
gcloud secrets add-iam-policy-binding test-sftp \
    --member="serviceAccount:test-sftp@cm-da-mikami-yuki-258308.iam.gserviceaccount.com" \
    --role="roles/secretmanager.secretAccessor"
gcloud secrets add-iam-policy-binding test-key-pass \
    --member="serviceAccount:test-sftp@cm-da-mikami-yuki-258308.iam.gserviceaccount.com" \
    --role="roles/secretmanager.secretAccessor"

このサービスアカウントを使って Cloud Functions を実行し、SFTP サーバから取得したファイルデータを GCS に Put する予定のため、GCS のアクセス権も付与しておきます。

gcloud projects add-iam-policy-binding cm-da-mikami-yuki-258308 \
    --member="serviceAccount:test-sftp@cm-da-mikami-yuki-258308.iam.gserviceaccount.com" \
    --role="roles/storage.admin"

Cloud Functions 関数をデプロイして実行

環境の準備ができたので、いよいよ本題です。 SFTP サーバから GCS にファイルを転送する Cloud Functions 関数を作成します。

以下の Python コードを準備しました。

from google.cloud import secretmanager
from google.cloud import storage
import os
import paramiko
import io

def get_secret_version(project_id, secret_id, version_id='latest'):
    client = secretmanager.SecretManagerServiceClient()
    name = f"projects/{project_id}/secrets/{secret_id}/versions/{version_id}"
    response = client.access_secret_version(request={"name": name})
    payload = response.payload.data.decode('UTF-8')
    return payload

def get_sftp_data(host, port, user, private_key, target_dir, dst_bucket):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.WarningPolicy())
    client.connect(host, port=port, username=user, pkey=private_key, timeout=5.0)
    sftp_connection = client.open_sftp()
    files = [_files for _files in sftp_connection.listdir(path=target_dir)]
    print(files)
    for file in files:
        file_path = f'{target_dir}{file}'
        obj = io.BytesIO()
        with obj as fp:
            sftp_connection.getfo(file_path, fp)
            stream = fp.getvalue().decode('UTF-8')
            put_blob(dst_bucket, file_path, stream, content_type='text/plain')
    return files

def put_blob(bucket_name, file_path, stream, content_type='text/plain'):
    client = storage.Client()
    bucket = client.get_bucket(bucket_name)
    blob = bucket.blob(file_path)
    blob.upload_from_string(stream, content_type=content_type)

def get_sftp_to_gcs(event, context):
    try:
        GCP_PROJECT = os.getenv('GCP_PROJECT', 'None')
        SECRET_ID_SFTP = os.getenv('SECRET_ID_SFTP', 'None')
        SECRET_ID_KEYPASS = os.getenv('SECRET_ID_KEYPASS', 'None')
        SFTP_HOST = os.getenv('SFTP_HOST', 'None')
        SFTP_PORT = os.getenv('SFTP_PORT', 'None')
        SFTP_USER = os.getenv('SFTP_USER', 'None')
        SFTP_DIR = os.getenv('SFTP_DIR', 'sample/')
        DST_BUCKET = os.getenv('DST_BUCKET', 'test-mikami')
        if not GCP_PROJECT or not SECRET_ID_SFTP or not SECRET_ID_KEYPASS or not SFTP_HOST or not SFTP_USER:
            raise Exception('Missing value in env.')

        key_stream = get_secret_version(GCP_PROJECT, SECRET_ID_SFTP)
        key_pass = get_secret_version(GCP_PROJECT, SECRET_ID_KEYPASS)
        private_key = paramiko.RSAKey.from_private_key(io.StringIO(key_stream.rstrip('\n')), key_pass.rstrip('\n'))
        files = get_sftp_data(SFTP_HOST, int(SFTP_PORT), SFTP_USER, private_key, SFTP_DIR, DST_BUCKET)
        print(f'put comp {files}')
    except Exception as e:
        raise

接続先の SFTP サーバのホスト情報やユーザーなどは、環境変数から取得します。 SSH 認証情報を Secret Manager から取得し、Python の SSH 接続用のライブラリ paramiko を使って、SFTPサーバにアクセスします。

SFTP サーバのファイルをオンメモリで取得したら、GCS オブジェクトとして出力します。

合わせて、関数で使用するライブラリの情報を記載した、requirements.txt ファイルも作成しました。

google-cloud-secret-manager>=2.7.2
google-cloud-storage>=1.42.0
paramiko>=2.8.0

以下のコマンドで、Cloud Functions 関数をデプロイします。

gcloud functions deploy get_sftp_to_gcs \
--region asia-northeast1 \
--runtime python37 \
--trigger-resource temp-test \
--trigger-event google.pubsub.topic.publish \
--service-account test-sftp@cm-da-mikami-yuki-258308.iam.gserviceaccount.com \
--set-env-vars SECRET_ID_SFTP=test-sftp,SECRET_ID_KEYPASS=test-key-pass,SFTP_HOST=34.85.13.90,SFTP_PORT=22,SFTP_USER=mikami_yuki,SFTP_DIR=sample/,DST_BUCKET=test-mikami

--service-account オプションで先ほどシークレットへのアクセス権を付与したサービスアカウントを指定し、--set-env-vars で関数内で取得する SFTP サーバ情報などの環境変数を指定します。今回は手動実行で動作確認するつもりなので、起動トリガーとなる --trigger-resource には任意の Pub/Sub トピックを指定しました。

デプロイが完了したようなので、実行してみます。

mikami_yuki@cloudshell:~/sftp/cf (cm-da-mikami-yuki-258308)$ gcloud functions call get_sftp_to_gcs --region asia-northeast1 --data {}
executionId: gttzo5havuz1
result: OK

result: OK とのことで、正常に実行できた模様です。

本当に GCS にファイルが作成できたか、管理コンソールから確認してみます。

指定したパスにファイルは作成できているようです。

ファイルの中身も確認してみます。 ブラウザ上で開いてみると、

SFTP サーバ上で作成したファイルと同じ内容が確認できました。 無事、SFTP サーバから GCS に、ファイルを転送することができました!

まとめ(所感)

コストパフォーマンスのよいサーバレス処理を簡単に実装できてしまう Cloud Functions、ほんとに便利ですねーv

サーバレス VPC アクセスコネクタと Cloud NAT を使えば固定 IP にもできるので、IP 制限もこわくない!

実行時間とメモリの制限はあるものの、処理を分けて Pub/Sub 経由や GCS の Put イベントで複数の Cloud Functions 関数を連携するなど、アーキテクチャを考えるのもこれまた楽しv

参考