Cloud RunとCloud Storage FUSE　コンテナからFUSEを介してCloud Storage（GCS）のオブジェクトを圧縮する方法

# はじめに
本記事はGoogle Cloud Platform（以下、GCP）でCloud Run ジョブを実行し、コンテナからFUSEを介してCloud Storage（以下、GCS）のオブジェクトを圧縮する方法について記載しています。

Cloud Storage FUSEを活用して、GCSをファイルシステムとしてマウントすることで、大容量のオブジェクトに対する操作を行うことができす。

圧縮されせずに保存されている大容量のオブジェクトに対して、Cloud Run ジョブでバッチ処理を行い圧縮することで、GCSの使用量に関するコスト削減を実現します。

Cloud Run ジョブはPytonで開発しています。

## 背景
GCPの監査証跡は、[Cloud Audit Logs](https://cloud.google.com/audit-logs?hl=ja)という仕組みによって証跡管理が行われています。
より詳しい説明は、公式ドキュメントの[Cloud Audit Logs の概要](https://cloud.google.com/logging/docs/audit?hl=ja)より確認できます。

例えばAWSの場合は、[AWS CloudTrail](https://aws.amazon.com/jp/cloudtrail/)で監査証跡を記録します。
また、AWS Control Towerを使用している場合、Landing Zoneによって、Log archiveアカウントのS3に、アカウント毎のCloudTrailとConfigのログが集約されます。
CloudTrailによって生成されるログのサフィックスは`.json.gz`となっているので、自動的に圧縮されていることが分かります。

GCPの運用では、Cloud Audit Logsの集約シンクを活用して、組織の監査ログを集約することができます。

- [組織レベルのログをサポートされている宛先に照合して転送する](https://cloud.google.com/logging/docs/export/aggregated_sinks?hl=ja)

圧縮されているログを確認したところ、`.json`のまま保存されていることに気づきました。:sweat:

現状コストを圧迫する火種にはならないものの、GCSで保管している監査ログのデータ量は対数的に増加していくため、長期保存する場合はコストに拍車がかかるのは大いに想定できます。

また、**2023年4月1日からGCSの料金が上がる**ため、監査ログを格納しているasia マルチリージョンにおけるColdline Storageのストレージも影響を受けることから、何らからの対策を打ちたいと考えました。

- [Cloud Storage の料金改定のお知らせ](https://cloud.google.com/storage/pricing-announce-apr23?hl=ja)

## ソリューション
前提としてGCSサービスの機能を用いて圧縮することはできないため、別の手段で実現する必要があります。

Dataflowの[Bulk Compress Cloud Storage Files](https://cloud.google.com/dataflow/docs/guides/templates/provided-utilities?hl=ja#bulk-compress-cloud-storage-files)も手段として候補に挙げられますが、コストや実装の自由度を考慮すると、最適解ではありません。
ソリューションとしてサーバレスのサービスを活用して、GCSに格納しているバケットを圧縮する仕組みを自前で実装することにしました。

GCPにおける代表的なサーバレスのサービスは、以下の通りです。

- [Cloud Functions](https://cloud.google.com/functions?hl=ja)
- [Cloud Run](https://cloud.google.com/run?hl=ja)
- [App Engine](https://cloud.google.com/appengine?hl=ja)

上記サーバレスのサービスを踏まえて、GCSのオブジェクトを圧縮したい場合、一度オブジェクトをダウンロードする必要があるため、Cloud Runを選択しました。

## Cloud Run
Cloud Runは、インフラストラクチャを気にすることなく、ステートレスなHTTP駆動型コンテナを実行できるフルマネージドなサーバーレスの実行環境を提供します。

サーバレスのサービスの中では、後発的なサービスとなり、2019年11月に一般提供開始されています。
以下の記事より、Knativeをベースに作られているのが分かります。

- [Cloud Run, a managed Knative service, is GA](https://cloud.google.com/blog/products/serverless/knative-based-cloud-run-services-are-ga/?hl=en)

料金は[Cloud Run の料金](https://cloud.google.com/run/pricing?hl=ja)から確認できます。
後述するCloud Run ジョブに関するコストを見積りたい場合は、「CPU が常に割り当てられるサービスの料金とジョブ」の料金を基に見積ります。

### Cloud Run ジョブ
Cloud Run ジョブは、Cloud Runで作成したアプリケーションをジョブとして実行する機能です。

[May 11, 2022](https://cloud.google.com/run/docs/release-notes#May_11_2022)にプレビューとして、リリースされています。（本記事執筆時点で現在進行形）

Cloud Run ジョブの使い道として、オンデマンドにタスクを実行したり、[**Cloud Scheduler**](https://cloud.google.com/scheduler?hl=ja)と組み合わせて、cron形式で定期実行することができるのでバッチ処理などに最適です。

Cloud Run ジョブを使用するにあたり、理解しておきたいサービスは以下の通りです。

- Cloud Logging
- Cloud Scheduler
- Container Registry/Artifact Registry
- IAM（サービス アカウント）
- Pub/Sub

:::note warn
公式ドキュメントの[Cloud Run ジョブを使ってみる](https://codelabs.developers.google.com/codelabs/cloud-starting-cloudrun-jobs-io?hl=ja#0)を参照すると、「本番環境のワークロードには、Cloud Run ジョブを使用しないでください。」の記載があります。プレビュー期間中、ミッションクリティカルのシステムなどでは使用は控えた方が良いかもしれません。
:::

## ジョブ開発
具体的な実装方法について、紆余曲折を経てCloud Storage FUSEを使用することになりましたが、その過程やナレッジについて以下に記載しています。

### Container Registryの使用方法
本記事ではContainer Registryを使用して開発しています。

以下にContainer Registryの使用方法について記載します。
イメージをプッシュするためには、[認証を構成する](https://cloud.google.com/container-registry/docs/quickstart?hl=ja#auth)を参考にしながら、以下のコマンドを実行してセットアップを行います。

```console
$ 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`

:::note warn
[gcloud docker とバージョン 18.03 以降の Docker クライアント](https://cloud.google.com/container-registry/docs/support/deprecation-notices?hl=ja#gcloud-docker)より、`gcloud docker`はバージョン 18.03以降のDockerクライアントに対してはサポートされていません。
:::

### gsutilを用いた実装
上記ソリューションの実現のため、最初は以下の構成を検討しました。

![構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221759/ec5311a2-6540-c911-0eb9-c2a6fe02e365.png)

効率的にオブジェクトのダウンロード及びアップロードを行うためには、[**gsutil**](https://cloud.google.com/storage/docs/gsutil?hl=ja)が最適です。
従って[Google Cloud Build official builder images](https://github.com/GoogleCloudPlatform/cloud-builders)で公開されているイメージを使用してコンテナを作成し、gsutilによるバッチ処理を試みました。

`cloud-builders`の軽量なイメージを使用したい場合は、Dockerfileで`slim`を指定します。

```Dockerfile
FROM gcr.io/google.com/cloudsdktool/cloud-sdk:slim
```

#### メモリエラー
一通り開発が終了しテストを行っていたところ、大容量のオブジェクトに対する処理のテストケースで、ジョブのエラーが発生し、処理が中断していました。

ログを確認したところ、`"Container terminated on signal 7."`のメッセージが出力されていました。

ダウンロードするオブジェクトの容量が大きいことは懸念していましたが、原因を究明するためにサポートに確認したところ、メモリ異常をトリガーとしたLinux OSの標準シグナルであるSIGBUS[^1][^2]が発信されたことが分かりました。

Cloud Runコンテナインスタンスの仕様として、データ永続層が存在しません。そのため、ダウンロードしたオブジェクトをメモリ上に書き込みを行ったことが原因で、メモリエラーが発生したことが分かりました。

- [ファイル システムへのアクセス](https://cloud.google.com/run/docs/container-contract?hl=ja#filesystem)

[^1]:[SIGBUS](https://man7.org/linux/man-pages/man7/signal.7.html#:~:text=SIGBUS%20%20%20%20%20%20%20P2001%20%20%20%20%20%20Core%20%20%20%20Bus%20error%20(bad%20memory%20access))
[^2]: [Signal numbering for standard signals](https://man7.org/linux/man-pages/man7/signal.7.html#:~:text=6%20%20%20%20%20%20%206%20%20%20%20%20%20%206-,SIGBUS%20%20%20%20%20%20%20%20%20%20%207,-10%20%20%20%20%20%2010%20%20%20%20%20%2010)

:::note
SDKも公開されているため、GCSをPythonで操作する場合は、以下のライブラリをインストールして操作することも可能です。

```Dockerfile
# Dockerfileの記述例
RUN pip3 install google-cloud-storage
```
:::

### Cloud Storage FUSEを用いた実装
上記メモリエラーによるコンテナシャットダウンの回避策については、[**Filestore**](https://cloud.google.com/filestore?hl=ja)または、[**Cloud Storage FUSE**](https://cloud.google.com/storage/docs/gcs-fuse?hl=ja)を活用して、データ永続層をマウントすることで、ファイルシステムに書き込みを行うことを検討しました。

- [チュートリアル: Cloud Run での Filestore の使用](https://cloud.google.com/run/docs/tutorials/network-filesystems-filestore?hl=ja)
- [チュートリアル: Cloud Run での Cloud Storage FUSE の使用](https://cloud.google.com/run/docs/tutorials/network-filesystems-fuse?hl=ja)

検討した結果、コストの観点から**Cloud Storage FUSE**を選択しました。
パフォーマンスの観点でも、**各 I/O ストリームは、gsutil とほぼ同じ速さで実行される**旨の記載があるので、期待できます。

公式ドキュメントを参考にしながら、ジョブの処理内容も見直した結果、最終的には以下の構成になりました。

![構成図2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221759/5d983b58-ad67-74a1-5775-ec9289b568a9.png)

仕組みとして、[**gcsfuse**](https://github.com/GoogleCloudPlatform/gcsfuse)を用いてGCSをファイルシステムとしてマウントしています。
内部的には[**libfuse**](https://github.com/libfuse/libfuse)によって、ユーザー空間プログラムがファイルシステムを Linuxカーネルにエクスポートしています。

コンテナからgcsfuseを使用するためには、上記チュートリアルの以下がポイントです。
コンテナのエントリポイントとして複数のプロセスを実行、管理するために[**tini**](https://github.com/krallin/tini)を使用します。

>一方、Cloud Run でネットワーク ファイル システムを使用する場合、ファイル システムのマウント プロセスとアプリケーションの両方を実行するためにマルチプロセス コンテナを使用する必要があります。

:::note warn
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](https://cloud.google.com/run/docs/configuring/memory-limits?hl=ja&authuser=0#cpu-minimum)を考慮しながら、[メモリの最大量](https://cloud.google.com/run/docs/configuring/memory-limits?hl=ja&authuser=0#max)を見直しました。

#### タイムアウト
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分間実行されることが分かりました。

- [タスクのタイムアウトを設定する（ジョブ）](https://cloud.google.com/run/docs/configuring/task-timeout?hl=ja)

公式ドキュメントよりタイムアウト値は最大1時間まで設定可能なため、最大値まで伸ばすことでジョブを完了させることができました。

よってタイムアウトについては、Cloud Runのコンテナ上で異常が発生し、IOエラーによるタイムアウトではなく、単純にデフォルト仕様が10分になっていたため、処理が中断されたことが原因でした。

## ナレッジ
### ジョブ設計
Cloud Runはファイルをダウンロードする際に、メモリに展開されファイルシステムへと書き込みを行うために、ファイルのデータサイズ分メモリを使用しているようです。

上記Cloud Runの仕様を踏まえたジョブ設計のポイントは以下の通りです。

- ジョブは可能な限り分割し、1回に処理するデータ量を抑えること
- 処理するデータ量が多い場合は、Cloud Runのメモリ及びCPUの拡張が必要
- 処理するデータ量が多く、10分以内で終わらない場合は、タイムアウト値を伸ばすなどの対策が必要

### 環境変数でジョブを制御する
Cloud Run ジョブに対して、環境変数を使用してジョブの制御を行うことができます。

- [環境変数を使用する](https://cloud.google.com/run/docs/configuring/environment-variables?hl=ja)

Pythonで環境変数を取得するために、`os.environ`を使用する場合、使い方によっては動作が異なります。

```python
import os

# キーが存在しない場合は、raise KeyError(key) from Noneが発生
test = os.environ['test']

# キーが存在しない場合でも例外にならない
test = os.environ.get('test')
```

:::note
キーが存在しない場合に、処理を停止したくないときは、`os.environ.get`を使用するのがお勧めです。
:::

### gcsfuse使用時にサブディレクトリが認識されない
gcsfuseの検証を行っていたところ、ファイルシステムとしてマウントはできるものの、マウントしたバケット配下のサブディレクトリが認識されない事象が発生しました。

GitHubの以下issuesを参考にしながら、オプションに`--implicit-dirs`を付与することで事象が解決しました。

- [Add optional implicit directory behavior #7](https://github.com/GoogleCloudPlatform/gcsfuse/issues/7)
- [GCSfuse doesn't sync all the sub-directories #829](https://github.com/GoogleCloudPlatform/gcsfuse/issues/829)

```bash
# チュートリアルの記述
gcsfuse --debug_gcs --debug_fuse $BUCKET $MNT_DIR

# オプションに--implicit-dirsを付与
gcsfuse --debug_gcs --debug_fuse --implicit-dirs $BUCKET $MNT_DIR
```

:::note
gcsfuseの使用例は公式ドキュメントの[起動スクリプトでプロセスを定義する](https://cloud.google.com/run/docs/tutorials/network-filesystems-fuse?hl=ja#defining_your_processes_in_the_startup_script)より確認できます。
:::

### Pythonでgzipを使用する
[gzip](https://ja.wikipedia.org/wiki/Gzip)は、データ圧縮プログラムのひとつです。

一般的にLinuxコマンドから実行する場合、デフォルトの圧縮レベルは`6`になっています。
圧縮レベルについて、`-1`オプションが最速で圧縮率が低く、`-9`オプションは最も遅いですが圧縮率が高くなります。 

Pythonでは、圧縮に関する標準ライブラリとして以下のライブラリが用意されています。

- [zlib](https://docs.python.org/ja/3/library/zlib.html)
- [gzip](https://docs.python.org/ja/3/library/gzip.html)

`.gz`ファイルの読み書きを行うためには、`gzip`モジュールを使用します。
圧縮する場合は、`gzip.open`を使用しますが、以下公式ドキュメントの通りにGzipFileと等価になるため、デフォルトの圧縮モードは`9`になります。

>バイナリモードでは、この関数は GzipFile コンストラクタ GzipFile(filename, mode, compresslevel) と等価です。この時、引数 encoding、errors、および newline を指定してはいけません。

例として`file_list`がファイル名のリストで、`dirpath`がGCSのサブディレクトリになる場合は、以下のコードで圧縮できます。

```python
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
    - [予算の策定に役立つ Cloud Storage の課金の仕組み](https://cloud.google.com/blog/ja/topics/developers-practitioners/check-please-billing-cloud-storage/)
- SDK（GCS）
    - [Cloud Storage client libraries](https://cloud.google.com/storage/docs/reference/libraries)
    - [google-cloud-storage](https://pypi.org/project/google-cloud-storage/)
- gustil
    - [help - Get help about commands and topics](https://cloud.google.com/storage/docs/gsutil/commands/help)