はじめに
本記事はGoogle Cloud Platform(以下、GCP)でCloud Run ジョブを実行し、コンテナからFUSEを介してCloud Storage(以下、GCS)のオブジェクトを圧縮する方法について記載しています。
Cloud Storage FUSEを活用して、GCSをファイルシステムとしてマウントすることで、大容量のオブジェクトに対する操作を行うことができす。
圧縮されせずに保存されている大容量のオブジェクトに対して、Cloud Run ジョブでバッチ処理を行い圧縮することで、GCSの使用量に関するコスト削減を実現します。
Cloud Run ジョブはPytonで開発しています。
背景
GCPの監査証跡は、Cloud Audit Logsという仕組みによって証跡管理が行われています。
より詳しい説明は、公式ドキュメントのCloud Audit Logs の概要より確認できます。
例えばAWSの場合は、AWS CloudTrailで監査証跡を記録します。
また、AWS Control Towerを使用している場合、Landing Zoneによって、Log archiveアカウントのS3に、アカウント毎のCloudTrailとConfigのログが集約されます。
CloudTrailによって生成されるログのサフィックスは.json.gz
となっているので、自動的に圧縮されていることが分かります。
GCPの運用では、Cloud Audit Logsの集約シンクを活用して、組織の監査ログを集約することができます。
圧縮されているログを確認したところ、.json
のまま保存されていることに気づきました。
現状コストを圧迫する火種にはならないものの、GCSで保管している監査ログのデータ量は対数的に増加していくため、長期保存する場合はコストに拍車がかかるのは大いに想定できます。
また、2023年4月1日からGCSの料金が上がるため、監査ログを格納しているasia マルチリージョンにおけるColdline Storageのストレージも影響を受けることから、何らからの対策を打ちたいと考えました。
ソリューション
前提としてGCSサービスの機能を用いて圧縮することはできないため、別の手段で実現する必要があります。
DataflowのBulk Compress Cloud Storage Filesも手段として候補に挙げられますが、コストや実装の自由度を考慮すると、最適解ではありません。
ソリューションとしてサーバレスのサービスを活用して、GCSに格納しているバケットを圧縮する仕組みを自前で実装することにしました。
GCPにおける代表的なサーバレスのサービスは、以下の通りです。
上記サーバレスのサービスを踏まえて、GCSのオブジェクトを圧縮したい場合、一度オブジェクトをダウンロードする必要があるため、Cloud Runを選択しました。
Cloud Run
Cloud Runは、インフラストラクチャを気にすることなく、ステートレスなHTTP駆動型コンテナを実行できるフルマネージドなサーバーレスの実行環境を提供します。
サーバレスのサービスの中では、後発的なサービスとなり、2019年11月に一般提供開始されています。
以下の記事より、Knativeをベースに作られているのが分かります。
料金はCloud Run の料金から確認できます。
後述するCloud Run ジョブに関するコストを見積りたい場合は、「CPU が常に割り当てられるサービスの料金とジョブ」の料金を基に見積ります。
Cloud Run ジョブ
Cloud Run ジョブは、Cloud Runで作成したアプリケーションをジョブとして実行する機能です。
May 11, 2022にプレビューとして、リリースされています。(本記事執筆時点で現在進行形)
Cloud Run ジョブの使い道として、オンデマンドにタスクを実行したり、Cloud Schedulerと組み合わせて、cron形式で定期実行することができるのでバッチ処理などに最適です。
Cloud Run ジョブを使用するにあたり、理解しておきたいサービスは以下の通りです。
- Cloud Logging
- Cloud Scheduler
- Container Registry/Artifact Registry
- IAM(サービス アカウント)
- Pub/Sub
公式ドキュメントのCloud Run ジョブを使ってみるを参照すると、「本番環境のワークロードには、Cloud Run ジョブを使用しないでください。」の記載があります。プレビュー期間中、ミッションクリティカルのシステムなどでは使用は控えた方が良いかもしれません。
ジョブ開発
具体的な実装方法について、紆余曲折を経てCloud Storage FUSEを使用することになりましたが、その過程やナレッジについて以下に記載しています。
Container Registryの使用方法
本記事ではContainer Registryを使用して開発しています。
以下にContainer Registryの使用方法について記載します。
イメージをプッシュするためには、認証を構成するを参考にしながら、以下のコマンドを実行してセットアップを行います。
$ gcloud auth configure-docker
Adding credentials for all GCR repositories.
WARNING: A long list of credential helpers may cause delays running 'docker build'. We recommend passing the registry name to configure only the registry you are using.
After update, the following will be written to your Docker config file located at [/Users/<Your Name>/.docker/config.json]:
{
"credHelpers": {
"gcr.io": "gcloud",
"us.gcr.io": "gcloud",
"eu.gcr.io": "gcloud",
"asia.gcr.io": "gcloud",
"staging-k8s.gcr.io": "gcloud",
"marketplace.gcr.io": "gcloud"
}
}
Do you want to continue (Y/n)? Y
Docker configuration file updated.
セットアップ完了後、アプリケーションを配備しているルートディレクトリで以下のコマンドを実行し、イメージのビルド及びプッシュを行います。
- ビルド
$ docker build -t <イメージ名> .
- タグ付け
$ docker tag <イメージ名> asia.gcr.io/<プロジェクト名>/<イメージ名>:tag1
- イメージのプッシュ
$ docker push asia.gcr.io/<プロジェクト名>/<イメージ名>:tag1
gcloud docker とバージョン 18.03 以降の Docker クライアントより、gcloud docker
はバージョン 18.03以降のDockerクライアントに対してはサポートされていません。
gsutilを用いた実装
上記ソリューションの実現のため、最初は以下の構成を検討しました。
効率的にオブジェクトのダウンロード及びアップロードを行うためには、gsutilが最適です。
従ってGoogle Cloud Build official builder imagesで公開されているイメージを使用してコンテナを作成し、gsutilによるバッチ処理を試みました。
cloud-builders
の軽量なイメージを使用したい場合は、Dockerfileでslim
を指定します。
FROM gcr.io/google.com/cloudsdktool/cloud-sdk:slim
メモリエラー
一通り開発が終了しテストを行っていたところ、大容量のオブジェクトに対する処理のテストケースで、ジョブのエラーが発生し、処理が中断していました。
ログを確認したところ、"Container terminated on signal 7."
のメッセージが出力されていました。
ダウンロードするオブジェクトの容量が大きいことは懸念していましたが、原因を究明するためにサポートに確認したところ、メモリ異常をトリガーとしたLinux OSの標準シグナルであるSIGBUS12が発信されたことが分かりました。
Cloud Runコンテナインスタンスの仕様として、データ永続層が存在しません。そのため、ダウンロードしたオブジェクトをメモリ上に書き込みを行ったことが原因で、メモリエラーが発生したことが分かりました。
SDKも公開されているため、GCSをPythonで操作する場合は、以下のライブラリをインストールして操作することも可能です。
# Dockerfileの記述例
RUN pip3 install google-cloud-storage
Cloud Storage FUSEを用いた実装
上記メモリエラーによるコンテナシャットダウンの回避策については、Filestoreまたは、Cloud Storage FUSEを活用して、データ永続層をマウントすることで、ファイルシステムに書き込みを行うことを検討しました。
検討した結果、コストの観点からCloud Storage FUSEを選択しました。
パフォーマンスの観点でも、各 I/O ストリームは、gsutil とほぼ同じ速さで実行される旨の記載があるので、期待できます。
公式ドキュメントを参考にしながら、ジョブの処理内容も見直した結果、最終的には以下の構成になりました。
仕組みとして、gcsfuseを用いてGCSをファイルシステムとしてマウントしています。
内部的にはlibfuseによって、ユーザー空間プログラムがファイルシステムを Linuxカーネルにエクスポートしています。
コンテナからgcsfuseを使用するためには、上記チュートリアルの以下がポイントです。
コンテナのエントリポイントとして複数のプロセスを実行、管理するためにtiniを使用します。
一方、Cloud Run でネットワーク ファイル システムを使用する場合、ファイル システムのマウント プロセスとアプリケーションの両方を実行するためにマルチプロセス コンテナを使用する必要があります。
Cloud Storage FUSEはOSSになるのでGoogle Cloudサポートのサポート対象外です。
使用する際は自己責任になるため、ご注意ください。
メモリエラー
Cloud Storage FUSEを用いた実装に変更後、以前の課題だった大容量のオブジェクトのダウンロード処理は、ファイルシステムをマウントすることでクリアできました。
再度テストを実行中、またメモリエラーが発生し、オブジェクトのダウンロード処理が中断していました。
- 出力例
Memory limit of 512 MiB exceeded with 512 MiB used. Consider increasing the memory limit, see https://cloud.google.com/run/docs/configuring/memory-limits
原因を確認したところ、ファイルシステムに格納されたファイルをメモリに展開して圧縮処理を実行する際に、設定されているメモリ容量を超えるオブジェクトのデータ容量を扱おうとしたことが原因でした。
公式ドキュメントの必要な最小 CPUを考慮しながら、メモリの最大量を見直しました。
タイムアウト
Cloud Run ジョブのCPU及びメモリ変更後、メモリエラーは解消され大容量のオブジェクトに対する処理も問題なく実行できるようになりましたが、今度はタイムアウトが発生しました。
- 出力例
Terminating task because it has reached the maximum timeout of 600 seconds. To change this limit, see https://cloud.google.com/run/docs/configuring/task-timeout
タイムアウトが起きるまではジョブは正常に処理を行っていたことが確認しました。
原因を確認したところ、Cloud Run ジョブのタイムアウト仕様は、デフォルト最大10分間実行されることが分かりました。
公式ドキュメントよりタイムアウト値は最大1時間まで設定可能なため、最大値まで伸ばすことでジョブを完了させることができました。
よってタイムアウトについては、Cloud Runのコンテナ上で異常が発生し、IOエラーによるタイムアウトではなく、単純にデフォルト仕様が10分になっていたため、処理が中断されたことが原因でした。
ナレッジ
ジョブ設計
Cloud Runはファイルをダウンロードする際に、メモリに展開されファイルシステムへと書き込みを行うために、ファイルのデータサイズ分メモリを使用しているようです。
上記Cloud Runの仕様を踏まえたジョブ設計のポイントは以下の通りです。
- ジョブは可能な限り分割し、1回に処理するデータ量を抑えること
- 処理するデータ量が多い場合は、Cloud Runのメモリ及びCPUの拡張が必要
- 処理するデータ量が多く、10分以内で終わらない場合は、タイムアウト値を伸ばすなどの対策が必要
環境変数でジョブを制御する
Cloud Run ジョブに対して、環境変数を使用してジョブの制御を行うことができます。
Pythonで環境変数を取得するために、os.environ
を使用する場合、使い方によっては動作が異なります。
import os
# キーが存在しない場合は、raise KeyError(key) from Noneが発生
test = os.environ['test']
# キーが存在しない場合でも例外にならない
test = os.environ.get('test')
キーが存在しない場合に、処理を停止したくないときは、os.environ.get
を使用するのがお勧めです。
gcsfuse使用時にサブディレクトリが認識されない
gcsfuseの検証を行っていたところ、ファイルシステムとしてマウントはできるものの、マウントしたバケット配下のサブディレクトリが認識されない事象が発生しました。
GitHubの以下issuesを参考にしながら、オプションに--implicit-dirs
を付与することで事象が解決しました。
# チュートリアルの記述
gcsfuse --debug_gcs --debug_fuse $BUCKET $MNT_DIR
# オプションに--implicit-dirsを付与
gcsfuse --debug_gcs --debug_fuse --implicit-dirs $BUCKET $MNT_DIR
gcsfuseの使用例は公式ドキュメントの起動スクリプトでプロセスを定義するより確認できます。
Pythonでgzipを使用する
gzipは、データ圧縮プログラムのひとつです。
一般的にLinuxコマンドから実行する場合、デフォルトの圧縮レベルは6
になっています。
圧縮レベルについて、-1
オプションが最速で圧縮率が低く、-9
オプションは最も遅いですが圧縮率が高くなります。
Pythonでは、圧縮に関する標準ライブラリとして以下のライブラリが用意されています。
.gz
ファイルの読み書きを行うためには、gzip
モジュールを使用します。
圧縮する場合は、gzip.open
を使用しますが、以下公式ドキュメントの通りにGzipFileと等価になるため、デフォルトの圧縮モードは9
になります。
バイナリモードでは、この関数は GzipFile コンストラクタ GzipFile(filename, mode, compresslevel) と等価です。この時、引数 encoding、errors、および newline を指定してはいけません。
例としてfile_list
がファイル名のリストで、dirpath
がGCSのサブディレクトリになる場合は、以下のコードで圧縮できます。
import gzip
import shutil
for _ in file_list:
with open(f'{dirpath}/{_}', 'rb') as f_in:
with gzip.open(f'{dirpath}/{_}.gz', 'wb') as f_out:
shutil.copyfileobj(f_in, f_out)
おわりに
費用対効果について、Cloud Runのランニングコストも発生しますが、為替変動や料金体系変更などの不確実性も踏まえて、コスト削減結果が期待できる見積りです。
クラウドの本質は従量課金です。
クラウドサービスの仕様及び料金体系を理解し、ひと手間かけることで、料金を下げることができます。
以上です。
参考
- GCS
- SDK(GCS)
- gustil