はじめに
TIG DXユニットの真野です。夏休み自由研究連載の5本目です。
ずっと気になっていた、go-fuseを用いて、LocalStack でローカル環境にエミュレートされるS3バケットをマウントするツールを開発しました。普段はWebのAPIサーバを中心に開発しているので、FUSEとはいえファイルシステムの知識が無く、トライ&エラーの連続ですごく楽しい自由研究(工作)でした。
モチベーションは以下です。
- 業務でよくS3にアクセスするコードを書き、ローカル開発ではLocalStack上のS3を用いてテストしている
- LocalStack上のS3に事前データを置いたり、事後データの検証にいちいちアクセスコードを書くのが面倒
- 実装ミスで予期しない階層にファイルを出力してしまったりするときに、容易に視認できるようにしたい
- aws cliコマンドを叩けば良いけど、コマンドを覚えられないし手間がある
- FUSEを用いてマウントできたら、初心者フレンドリーである
- WindowsでもWSL2であればFUSEが利用できる
- 標準のエクスプローラー(explorer.exe)で、WSL2上のUbuntu-20.04上のディレクトリも見れるのでより便利
- VS Codeなどでのエディタでも確認できツールを統一できるし、ターミナルの手慣れたコマンドを利用できる(diffなど)
ポイントは、LocakStack自体がローカル(やCIでの)テスト環境ですので、これをマウントするツールもテスト支援ツールとして動かしたいということがあります。AWSなどクラウド上で稼働するランタイムのアプリケーションが直接マウントしたディレクトリを経由してS3に書き込むことは想定していません。
※動作検証したのがWSL2だけで、Macだと新し目のOSだと動かないようです(古いMacしか手持ちになく、すいません)。
LocalStackとは
2022年7月13日にGA 1.0になったと発表された、AWSの主要なサービスのAPIをローカル端末上でエミュレートするという、開発に便利なツールです。
2016年頃は、API Gateway、Lambda、DynamoDBなど8つのサービスをサポートしていましたが、今や80を超えるサービスが利用できるとのことです。わたしも現在業務で使っており、開発上ほぼすべてのユースケースを網羅できていて助かっています。どれくらいのカバレッジか気になる人はAWS Service Feature Coverage ページもあります。
FUSEとは
FUSEとはFilesystem in Userspaceの略で、ユーザーランドで手軽に動作するファイルシステムを作成するための仕組みです。FUSEではカーネルがファイルなどの操作のシステムコールを、ユーザーランド側で動作しているプロセスに転送する仕組みで、決められたインタフェースを実装すると、手軽にファイルシステムを実装できます。同僚の澁川さん著作なGoならわかるシステムプログラミング 第2版 の10章にも触れられています。
下図はWikipediaより引用した動作イメージです。左上の ls -l
をされると、カーネルにシステム要求が飛び、それをFUSEの仕組みを経由してユーザーランドのアプリケーションが応答するような流れです。
今回は右上のユーザーランド側のプロセスで、AWS SDK for Goを用いてS3 on LocalStackをバックエンドにadaptorするようなコードを書きました。
ファイル操作がカーネル→ユーザーランドと切り替わるということは、コンテキストスイッチが発生することで、性能は一般的に良くなさそうですよね。今回の用途では実際の永続化先がS3であり、I/O待ちが支配的だと思うので、裏側がS3だと分かっていればそこまでレイテンシは気にはなりませんでした(重い処理をすると当然遅いですが)。
go-fuse とは
go-fuseはFUSEのGoバインディングです。この自由研究では安直ですがStar数が多かったのでこれを採用しました。他の選択肢としてはwinfsp/cgofuse が良さそうな感じがします。
go-fuseのAPIはバージョンが1系と2系がありますが、今回うっかり1系で実装してしまったのは反省です。
デモ
作ったものを紹介します。すでにLocalStack上のS3が起動していれば不要ですが、なければ次のコマンドを実行して立ち上げます。
git clone https://github.com/ma91n/localstackmount.git |
次にlocalstackmountを起動します。Windowsの人はWSL2で実行してください。
go install github.com/ma91n/localstackmount@latest |
そうすると、 ~/mount/localstack
配下にLocalStackの全S3バケットがマウントされます。
awscliでファイルを予め登録したファイル(hello.txt)を確認→マウント上でそのファイルに1行追記→awscliで追記されていることを確認するデモをしてみました。
デモは以下のことをしています。
- 左のウィンドウで
localstackmount
を起動 - 真ん中のウィンドウで、 awscliの
s3 api list-buckets
でバケットの一覧、s3 ls --recursive
とs3 cp
コマンドでファイルをダウンロードし表示 - 右のウィンドウで、LocalStackをマウントしたディレクトリにアクセスし、先程ダウンロードしたファイルを編集・保存
- 真ん中のウィンドウに戻って、マウント経由で編集したファイルをaws cli経由で再度ダウンロードし、編集結果が反映されていることを確認
もちろん、エクスプローラからも確認できます。
GIF動画では実演してないですが、もちろんVS Codeで好きに編集・保存をしても、LocalStack上のS3に反映されます。そこそこ便利かと思います。
実装
コードはここに上げています。
詳細はリポジトリを見ていただくとして、大きな実装の流れとしてはまず以下のAPIを実装することです(多いです)。
type FileSystem interface { |
多すぎて大変! って思われた方も大丈夫です。
すべてを実装しなくても、pathfs.NewDefaultFileSystem()
と言う一律 fuse.ENOSYS(Function not implemented)
を返すデフォルト実装があるためこれを組み込んで、必要なものだけ順次、動作を確認しながら実装できます。
type FileSystem struct { |
あと、Open
など nodefs.File
を返すのですが、こういったインタフェースです。実際にファイルへの追記・編集で使われます(例えばファイルを編集して保存するとWrite、Flush、Releaseが呼ばれます)。
type File interface { |
今回開発した ma91n/localstack では、ChmodやChown、Symlinkなどは非対応にしました。かつ、Extended attributes
と書かれている GetXAttr
、ListXAttr
、RemoveXAttr
、SetXAttr
も未実装です(実装していれば適時呼ばれますが、なければノーマルな GetAttr
などにフォールバックされる仕組みなようです)。
どれがどれに紐づくか、最初はピンとこなかったのでざっくりと紹介します。
- GetAttr
- ファイルディレクトリの属性(ファイル、ディレクトリ、リンクなどの種別や、権限、サイズ、オーナー、作成日時)などを返します
- すべての操作で呼ばれます。
cd
やls
やcat
などマウントしたファイル・ディレクトリ操作で頻発に呼ばれます - かなり高速に動くこと必要です
- 初戦はテスト用のLocalStack。ファイル数は大したことがないので毎回通信で存在チェックすれば良いと思っていましたが、キャッシュを入れないとかなりもっさりでした
- Access
cd
など、ディレクトリに移動可能かの確認で呼ばれます
- Mkdir, Rename, Rmdir
- 読んだままですが、
mkdir
,mv(rename)
,rm -r
で呼ばれます
- 読んだままですが、
- Unlink
rm
で呼ばれます。削除です
- Open
- head, cat, tail, lessなどファイルを開くと呼ばれます
- Create
- touchや echo hello > hello.txt などで呼ばれます
- OpenDir
cd
やls
などでディレクトアクセスするときに呼ばれます
概ね上記の関数を実装すればファイルエクスプローラを用いてのメインどころの操作はどうにかなりました。
ファイルエディタ系は Read
、Write
、Flush
、 Release
、GetAttr
あたりを実装すれば、S3を用いた単体テストで用いるようなS3の操作は動くようになりました。
実装メモ
今までファイルシステム周りが何もわからなかったので、実装を通して感じたことを記録に残します。
- S3でディレクトリの表現について仕様が公式ドキュメントに書かれている(仕様が合ったのか)
/
で終わるとフォルダとして判定される
- 想像以上に
GetAttr
が利用される- 例えば、 mnt-point/bucket/aaa/bbb/ccc/log.txt というファイルを操作すると、
bucket
,bucket/aaa
,bucket/aaa/bbb
,bucket/aaa/bbb/ccc
,bucket/aaa/bbb/ccc/log.txt
といった親のパス全てに対してGetAttr
が呼ばれます - S3バックエンドだと、実際には
aaa/bbb/ccc/log.txt
というオブジェクトがあるだけで、実際にフォルダとしてaaa
やbbb
があるわけではないことがあるので、上記の大部分は無駄です - 最終的にはキャッシュレスは諦め、go-cacheを導入しました
- 例えば、 mnt-point/bucket/aaa/bbb/ccc/log.txt というファイルを操作すると、
- キャッシュの扱い。難しい・うまくハマると速度向上が体感できて楽しい
- キャッシュの扱いですが、例えばファイルを書き込んだ後には破棄しないと、エディタによってはアプリで持っている情報と不整合が生じて警告を出してくることがあります。別にFUSEを用いた実装に閉じた話でもないですが、適切なハンドリングが必要でした
- オブジェクトストレージと、ファイルシステムとのギャップも感じました
- 例えば、
/bucket/dir1/aaa.txt
を削除すると、GetAttr
のキャッシュとしては/bucket/dir1/aaa.txt
、/bucket/dir1
、/bucket
の3つを無効化しないと不整合になる場合があります- ※実際に
dir1/
のオブジェクトが存在するとは限らないため、aaa.txt
が消えたらbucket
だけが残る方が自然なケースがある
- ※実際に
- 例えば、
- フォルダのリネームが面倒くさい
- S3だとキーの途中をリネームすることになりますが、複数オブジェクトが存在すると面倒です
- prefixをもとにlistObjectし、対象となった全オブジェクトに対してgetObject、キーを書き換えてputObjectし、もとのキーをdeleteObjectする必要があり重い処理です
- S3マウントツールで有名なkahing/goofys も、1000個までと制約をかけているようです
- ctrl+c で停止できない理由は、ターミナルで開いていたから
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
といったコードで、チャネル経由でシグナルを拾ってアンマウントする処理を実装していたんですが、Device or resource busy
で失敗することがありました- 調べてもよくわからなかったのですが、マウントしているディレクトリじょうにターミナルで移動していると、何かしらのファイルディスクリプタを握ってしまうのか、アンマウントに失敗するようです
- 面倒くさいですが、再起動するときは
cd ~
していました(どうにかならないものか)
- 面倒くさいですが、再起動するときは
- エクスプローラー(explore.exe)で開くためにはオプションが必要
allow_other
というオプションが必要でした
- Macで動かない?
- Macでは標準でFUSEが入っていないので、osxfuseをインストールしてもらう必要がある
- go-fuseはosxfuseの3系は動くようですが、4系は動かない模様(自環境が無く未検証)
- osxfuseの3系が入るOSバージョンであれば、動作しました
- 開発環境
- Windowsで開発する場合、goosをlinuxにしないとビルドが通らないのでご注意を
実装して学べたこと
総じて、普段あまり意識しないレイヤーがどう動作するかを感じることができ、やってみて良かったと思っています。
cd
、ls
などのコマンドが、どのようなファイルシステム操作をしているか再認識したり、挙動について覚え直すキッカケなった- mvするときに、既存のファイルが存在したら上書きする or しない
- ファイルシステムとしての実装の考え方が少しわかった
- どの操作で、どのようなAPIが呼び出されるかの脳内マッピング(これくらいのAPI数で逆に成り立つのか、まぁ成り立つよねという心の天秤)
- どこにキャッシュを用いると効果的かの勘所
- 高速化の工夫と、マウントを経由しない別経路での更新(例えばAWS CLIで直接更新など)とのバランス(キャッシュの有効期限のパラメータ調整)
- 例えばVS Codeがどのような情報をファイルシステムに問い合わせているか、FUSE側のAPI呼び出しのログを見てイメージが湧いた
- VS Codeで
my-bucket/aaa/bbb/hello.txt
にあるマウントしたファイルを開くと、以下のファイルを探していたmy-bucket/aaa/bbb/git.exe
my-bucket/aaa/.git
my-bucket/aaa/HEAD
my-bucket/.git
- VS Codeで
- FUSE、思ったよりWSL2でシャキシャキ動く
- Windowsならではのハマりがもっと壮絶にあると思ったんですが、環境周りのハマりはほぼ無しで余裕でした
- 逆にMacは新しいバージョンの手持ちが無く動作検証ができず
- Windowsならではのハマりがもっと壮絶にあると思ったんですが、環境周りのハマりはほぼ無しで余裕でした
今後について
どこまでがんばるかということはあるのですが、いくつか試したいことがあります。
- go-fuseの2系のAPIに書き換える
- winfsp/cgofuse に載せ替える(Macなどのサポート的にこっちの方が良い気も..?)
Extended attributes
系のAPI対応- おそらく性能などに有利
- 各操作の goroutine 化
- 現状の実装だと、全て同期的に書いているのでマルチコアを全く行かせていません
- 一般的にはgoroutineを活用したほうが良さそうです
- ファイル自体のキャシュ
- 現状ではS3に対するファイル属性の取得のための、listObjectを中心にキャッシュしています
- S3のgetObjectは、
IfModifiedSince
と呼ばれる機能があり、指定した時間より更新がなければ304 (not modified)
を返す機能があります - これを用いた、マウント外のディレクトリにファイルをキャッシュしておき、更新がなればそのファイルを用いれば有効なケースもあるかなと目論んでいます
まとめ
- WSL2(Macは一部OS)に対応した LocalStack上のS3をマウントするツールを、go-fuse を用いて実装してみたよ
- ファイルシステムといっても、FUSEと各言語ごとのバインド(例: go-fuse)を用いれば気軽に実装できるよ
- 普段あまり意識しない人にもオススメだよ
- S3とファイルシステムのギャップは色々あるけど、工夫のしどころが多くて楽しいよ
最後まで読んでいただきありがとうございました。