こんにちは、アンドパッドの tomtwinkle です。
9月にDocker HubのMinIOのコミュニティ版(minio/minio:latest)が利用できなくなりましたね。
以前からMinIOはコミュニティ版を今後更新しない旨を案内していましたが、ついに来たかという感じです。
Docker HubのMinIOイメージも、現在はアーカイブされています。
私の担当しているプロダクトでは、Amazon S3を利用している処理の動作確認に、MinIOを使ったS3互換APIのテストを組み込んでいました。Mockだけでは、S3固有の挙動を事前に確認しきれません。
たとえば、バケットポリシーや認証情報が不足したときにAccessDeniedが返るケースです。エンドポイント形式が合わないときのリクエスト失敗や、GetObjectのBodyが途中で途切れたときにEOFと区別する処理もあります。
今回、Docker HubからMinIOのイメージを取得できなくなったことで、重い腰を上げて対応を決意した次第です。
なぜRustFSへ切り替えるのか
以前からMinIOはコミュニティ版を今後更新しない旨を案内していました。MinIO公式のLegalページでは、オープンソース製品が2025年9月にEOLとなり、メンテナンス対象外になったと説明されています。MinIOのサーバーリポジトリもアーカイブされています。
そこで、別のS3互換オブジェクトストレージへ切り替えることにしました。移行先を決めるときに見たのは、次の3点です。
- テストで利用するS3 APIをサポートしていること
- 継続して更新される見込みがあること
- 既存のテスト環境へ組み込みやすいこと
検討時点でRustFSは 1.0.0-rc.6 がリリースされており、正式版に近いところまで来ていました。S3互換性マトリクスを確認すると、必要なバケット操作やオブジェクト操作に対応していました。
テストに必要な機能がだいたいそろっていたため、RustFSを移行先に決めました。
この記事を書き終わった後に、RustFS v1.0.0 GAがリリースされました! 詳細は、RustFS公式ブログのAnnouncing RustFS 1.0.0 GA: Production-Ready, Open Source, S3-Compatible Object Storageをご覧ください。
テスト環境をRustFSに置き換えてみる
まずはサクッとCIの実行環境をMinIOからRustFSにリプレイスしてみて、失敗しているテストがないかを確認してみます。
MinIOからRustFSへの変更点を、CI設定のBefore/Afterで見てみます。
Before: MinIO
services: s3: image: minio/minio:latest command: server /data --console-address ":9001" ports: - "9000:9000" - "9001:9001" environment: MINIO_ROOT_USER: "test-access-key" MINIO_ROOT_PASSWORD: "test-secret-key"
バケット作成やポリシー設定は、mc(MinIO Client)を利用していました。
mc alias set local "http://localhost:9000" \ "test-access-key" "test-secret-key" mc mb "local/example-test-bucket" mc anonymous set-json example-bucket-policy.json \ "local/example-test-bucket"
After: RustFS
RustFSではmcをそのまま使えないため、AWS CLIで同じ初期化を行うように置き換えます。
services: s3: image: rustfs/rustfs:latest ports: - "9000:9000" environment: RUSTFS_VOLUMES: /data RUSTFS_REGION: "us-west-2" RUSTFS_ADDRESS: "0.0.0.0:9000" RUSTFS_CONSOLE_ENABLE: "false" RUSTFS_ACCESS_KEY: "test-access-key" RUSTFS_SECRET_KEY: "test-secret-key"
CIジョブからは、AWS CLIでRustFSに接続して初期化します。
AWS_ACCESS_KEY_ID="test-access-key" AWS_SECRET_ACCESS_KEY="test-secret-key" AWS_DEFAULT_REGION="us-west-2" AWS_EC2_METADATA_DISABLED="true" S3_ENDPOINT="http://localhost:9000" aws configure set default.s3.addressing_style path # RustFSの起動を確認してから、テスト用リソースを初期化する aws --endpoint-url "${S3_ENDPOINT}" s3api list-buckets aws --endpoint-url "${S3_ENDPOINT}" s3api create-bucket \ --bucket "example-test-bucket" \ --create-bucket-configuration LocationConstraint="${AWS_DEFAULT_REGION}" aws --endpoint-url "${S3_ENDPOINT}" s3api put-bucket-policy \ --bucket "example-test-bucket" \ --policy file://example-bucket-policy.json
実際のCIでは、S3 APIが応答するまで待ってから初期化を進めました。起動待ちに失敗した場合はテストを開始せず、環境構築の失敗としてCIを失敗させます。RustFSが起動していないのか、テストそのものが失敗したのかを分けるためです。
CIを実行してみたところ、S3 SelectのAllowQuotedRecordDelimiterを指定したCSV読み取り処理でのテストが失敗しました。
調べてみるとRustFSはS3 Select自体には対応していますが、このオプションには対応していませんでした。
S3 SelectをなくしてCSVを読む
今回の失敗は、S3 Selectそのものではなく、AllowQuotedRecordDelimiterという特定のオプションへの依存が原因でした。さらにAWSは、S3 SelectおよびS3 Glacier Selectへの新規顧客アクセスを2024年7月25日から終了すると発表しています。
S3 SelectはCSVを読み取る前にヘッダーを検証するために利用されていました。
RustFSへの切り替えを進めるため、特定のS3 Selectオプションに依存せず、共通のS3 APIだけでCSVを読み取る形に変更を試みます。
Tips: AllowQuotedRecordDelimiterが必要だった理由
CSVの値に改行を含む場合があるためです。例えば、次のようなCSVです。
id,name,comment 1,山田,"1行目 2行目"このように引用符で囲まれたフィールド内の改行を、レコード区切りとして扱わずに読み取るため、S3 Selectの
AllowQuotedRecordDelimiterを有効にしていました。AWSのCSVInput仕様でも、引用符内のレコード区切りを許可する設定と説明されています。デフォルト値は
falseです。trueにしない場合、引用符内の改行を含むCSVはS3 Selectでエラーになります。今回のテストがこの設定を必要としていた理由は、複数行の値を含むCSVを正しく扱うためでした。
壊さずに置き換えるためのポイント
特に注意したのは、ヘッダーを読んだあともCSVの先頭を壊さないことです。ここで少し困りました。
ヘッダーを検証するために、まずAWS SDK for Go v2のGetObjectで取得できるio.ReadCloserからヘッダー分の情報を読み取ります。そのあと、同じReaderを継続してCSVのParse処理に利用すると、ヘッダー分だけすでに読み取られた状態になってしまい、正常なParseが行えません。
次のコードでは、GetObjectのレスポンスからBodyを取り出し、ヘッダー検証後に同じBodyをCSVデコーダーへ渡しています。readHeaderBytesはヘッダーの元のバイト列を返す処理、validateHeaderはヘッダーを検証する処理を表しています。
import ( "bytes" "context" "encoding/csv" "errors" "io" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" ) func readCSV(ctx context.Context, client *s3.Client) error { output, err := client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String("example-bucket"), Key: aws.String("example.csv"), }) if err != nil { return err } defer output.Body.Close() body := output.Body // io.ReadCloser header, err := readHeaderBytes(body) if err != nil { return err } if err := validateHeader(bytes.NewReader(header)); err != nil { return err } reader := newCSVReader(header, body) for { _, err := reader.Read() if errors.Is(err, io.EOF) { return nil } if err != nil { return err } } } func newCSVReader(header []byte, body io.Reader) *csv.Reader { // bodyはすでにヘッダーを読み取ったあとの位置にある。 // CSVを先頭から読み取れるよう、headerをbodyの前に連結する stream := io.MultiReader(bytes.NewReader(header), body) return csv.NewReader(stream) }
io.Readerには読み取り位置を戻すSeekがありません。AWS SDK for Go v2のGetObjectが返すBodyはio.ReadCloserです。io.ReadCloserはio.Readerとして読み取れますが、io.Seekerを実装しているとは限らないため、Seekで先頭へ戻す方法には依存できません。
先に読み取ったヘッダーをbytes.NewReaderで再び先頭に置き、io.MultiReaderで残りのBodyと連結します。io.MultiReaderはヘッダーを返し終えたあと、ヘッダーの次の位置にあるBodyをそのまま読み続けるため、Body全体を読み直したり、S3へ再リクエストしたりする必要がありません。
おまけ: ストリームで読んだ効果
ここで、思わぬ副産物がありました。
GetObjectが返すio.ReadCloserをCSVデコーダーへ直接渡したことで、オブジェクト全体をバッファへ読み込まずに処理できました。メモリ使用量を抑えやすくなった、というおまけです。
ただし、ストリームにしただけでメモリ使用量が一定になるわけではありません。読み取ったレコードをすべて保持すれば、メモリは増え続けます。入力件数に上限を設け、上限に達してもEOFまでデコードを続けて、後続のCSV変換エラーや解析エラーを見落とさないようにしました。
主目的はRustFSへの切り替えでしたが、結果として読み取り途中のエラーを扱いやすくしつつ、不要なメモリ割り当ても抑えられました。
さいごに
MinIOからRustFSへの移行で一番の論点になったのは、CI上で失敗したAllowQuotedRecordDelimiterを使うS3 Selectのテストをどう置き換えるかでした。
今回は、S3 Selectの特定オプションに依存していたCSVの読み取りを共通のS3 APIで構成し直し、CIの起動設定とテストをRustFSへ切り替えました。既存のCSV仕様とエラー分類を確認したことで、S3互換サーバーを変えても利用者から見える挙動は変えずに済みました。
ストリーム化は、その対応にともなうメモリ面のおまけです。S3互換サーバーの移行では、対応しているAPIに加えて、既存の処理がどのAPIに依存しているかを先に確認するのが大事ですね。
Happy Hacking!!
参考
- MinIO Legal
- MinIO Docker Hub
- MinIO server repository
- RustFS 1.0.0-rc.6
- RustFS S3互換性マトリクス
- Amazon S3でデータのクエリを最適化する方法
We are hiring!
アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を大募集しています。数あるユーザーの課題を解決するには何が最善かを考え抜き、よりよいプロダクトを作りたい!と思われる方はぜひぜひご応募ください!