FUSEを使ってコマンドをファイルでラップする

少し前からFUSEに興味が湧いて色々作りたいものを考えていました。年末年始にある程度動くものができたので一旦まとめてみます。

github.com

ちなみに数日前に同僚のharasouさんが cat するたびに内容が変わるファイル?を作った という記事を上げていて、内容がかなり被っているので二番煎じ感がすごいです。

何をするものか

大体GitHubのREADMEに書いていますが、以下のような設定ファイルをyamlで定義してnanafshiを使ってマウントすると、読み込んだり書き込んだりした時に内部でコマンドを実行するファイルが作成されます。

shell: /bin/bash
services:
  - name: dir
    files:
      - name: example
        read:
          command: curl http://www.example.com/

      - name: writable
        read: 
          command: |
            touch /tmp/file
            cat /tmp/file
        write: 
          async: true
          command: echo $FUSE_STDIN >> /tmp/file

このyamlを使ってマウントしてやると、設定ファイルの構造に従ってディレクトリとファイルが作成されます。

$ nanafshi -c config.yml /mnt/nanafshi

$ tree /mnt/nanafshi
/mnt/nanafshi
└── dir
    ├── example
    └── writable

以下のように作成されたファイルをcatなどで読み込むと、内部で実行したコマンドの標準出力が表示されます。また、書き込みの場合はコマンドを実行するだけですが、環境変数として入力されたデータを渡せるようになっています。

$ cat /mnt/nanafshi/dir/example # curl http://www.example.com/ の結果
<!doctype html>
<html>
<head>
    <title>Example Domain</title>
...

内部コマンドで使用できる環境変数についてはGitHubの方でまとめていますが、ファイルを開いたプロセスのPIDやUIDなどもあり、必要に応じて増やしていくつもりです。

また、Macの場合は標準では使用できませんが、FUSE for macOS をインストールすればLinuxと同様に使えます。Windowsは未検証です。

何に使うのか

設定ファイルの動的生成

はじめに紹介した記事でharasouさんも言及していますが、アプリケーションの設定ファイルのパラメータをAPIやデータベースなどから取ってくるといったことができそうです。

外部ログ基盤との連携

readとwrite両方に対応していますので、例えば bash_history のようなログファイルを以下の設定で用意してやれば、外部のログ管理基盤と直接やりとりしてログを保存/取得し、複数ホスト間で共有するといったこともできます。

services:
  - name: logs
    files:
      - name: bash_history
        read:
          # read時はGETリクエストで全ログを取得
          command: curl https://store.example.com/tokibi/bash_history
        write:
          # write時はPUTリクエストで保存
          command: curl -X PUT -H "Text:$FUSE_STDIN" https://store.example.com/tokibi/bash_history

このようなファイルはコンテナ上のログのような揮発性の高いデータの保存に対して有用になりそうです。ただ、パフォーマンス面は全く検証できていないので、本番環境で使えるような信頼性を担保するところが目標です。

コマンド単位の権限移譲

ファイル内部で実行されるコマンドは、nanafshiのプロセスからforkされて実行されるようになっています。なので、rootユーザがnanafshiを起動してマウントした場合は、ファイルアクセス時に実行されるコマンドもrootユーザの権限で実行されます。

また、ファイルアクセス時にどのようなコマンドが実行されるかは、該当のファイルをどのように参照しても(多分)見えないので、設定ファイルの所有権を厳密に設定すればファイルアクセスを行うユーザから見て内部で何が起きているか調べる手段がないという状態になります。

これらの特性を利用すれば、設定ファイルに記載したコマンドのみに限定して、その内容を隠蔽しつつ他のユーザに実行権限を委譲するといったことが可能です。例えばコンテナプロセスに対してバインドマウントでrootユーザで起動したnanafshiのファイルを提供すれば、Linuxのcapabilityなどを無視して色々なコマンドの実行権限を委譲できてしまいます。ただの脆弱性にもなりかねないので、基本的には専用のユーザなどを用意して起動することをおすすめします。

システム全体の複雑性がとても高くなるので、安易に色々やると痛い目にあいそうですね。

今後の予定

  • テスト, ログ周りちゃんとする
  • ディレクトリ, ファイルごとに所有権を設定できるようにする
  • bash組み込みのreadコマンドのような対話的なコマンドを差し込めるようにしたい
    • ファイルを開く時にMFAとか入れたい
  • read時のcache実装
  • write時のFUSE_STDIN環境変数あたりでインジェクションできそうなので対策する

まとめ

設定ファイルにコマンドを書けばいいだけなので、FUSEを使って色々と簡単に試せると思います。面白そうな使い方があったら教えてください。

あと、nanafshiという名前の由来について会社でよく聞かれるので書いておくと、コマンドをファイル(Node)に擬態させる的なイメージから、枝葉に擬態する生き物を探してナナフシが見つかったのでそのまま採用しました(枝に擬態するのでちょっと違いますが)。あとfilesystemなのでnanafushiのuを削ってfsにしています。

cronsunを試してみる

この記事は GMOペパボ Advent Calentar 2018の12月7日(金)担当分です。風邪やらなんやらでとんでもない遅刻をしています。

最近色々あるので大量のジョブを捌けそうなTime-based Job Schedulerを探しています。いわゆるcronです。fault tolerant distributed cron という夢のようなキーワードで検索してみるといい感じのが見つかりました。

dkronはいくつか紹介記事がありましたが、cronsunの方は見つからなかったのでとりあえず動かしてみようと思います。

build

cronsunはgolang製なのでbuildしてバイナリを作ります。今回はdockerのgolangイメージを使ってbuildしました。

$ git clone https://github.com/shunfei/cronsun
$ cd cronsun
$ docker run -it -v "$(pwd):/cronsun" golang bash

root@6649eb0f477b:/go# cd /cronsun
root@6649eb0f477b:/cronsun# export GO111MODULE=on
root@6649eb0f477b:/cronsun# go mod vendor
root@6649eb0f477b:/cronsun# bash ./build.sh

buildに成功すると dist ディレクトリ以下にバイナリと設定ファイルが作成されています。

root@6649eb0f477b:/cronsun# tree ./dist
dist
|-- conf
|   |-- base.json
|   |-- db.json
|   |-- etcd.json
|   |-- mail.json
|   |-- security.json
|   `-- web.json
|-- cronnode
|-- cronweb
`-- csctl

起動してみる

cronsun はArchitectureに記載されている通り、ジョブ情報をetcdに持ち、ジョブの実行結果をMongoDBに保存するようになっています。

起動するためにはこの2つが必要になるので、今回は docker-compose.yml を次のように作成して試してみます。データの永続化は特に気にしていません。

version: '3'
services:
  etcd:
    image: quay.io/coreos/etcd
    command: etcd -name=cronsun1 -advertise-client-urls http://etcd:2379  -listen-client-urls http://0.0.0.0:2379

  mongo:
    image: mongo

  cronsun-node:
    image: ubuntu
    volumes:
      - ./dist:/dist
    depends_on:
      - etcd
      - mongo
    command: /dist/cronnode -conf /dist/conf/base.json

  cronsun-web:
    image: ubuntu
    ports:
      - 7079:7079
    volumes:
      - ./dist:/dist
    depends_on:
      - etcd
      - mongo
    command: /dist/cronweb -conf /dist/conf/base.json

併せて設定ファイルも修正しておきます。dist/conf 以下の db.jsonetcd.json がデフォルトではlocalhostを見るようになっているので、docker-composeに記載したサービス名にそれぞれ変更しておきます。

db.json

{
  "Hosts": [
    "mongo:27017"
  ],
  ...
}

etcd.json

{
    "Endpoints":[
        "http://etcd:2379"
    ],
    ...
}

設定ファイルの修正が終わったら、docker-compose up で各コンテナを起動します。起動後に http://localhost:7079 にアクセスすると無事ダッシュボードが表示されました。初期状態だと中国語ですが右上の歯車アイコンから英語に切り替えられます。

f:id:tokibix:20181224183405p:plain

echo Hello を実行するだけのJobを作成してみました。秒単位まで指定できるcron形式の他に、様々な設定方法がありGitHubwikiにまとめられています。

f:id:tokibix:20181224183525p:plain

ログページを見てみるとnode上で動いていることがわかります。

f:id:tokibix:20181224183558p:plain

APIも用意されているようで、 curl http://localhost:7079/v1/version などを叩いてみるとcronsunのバージョンが表示されるのですが、この辺はwikiにまとまっていなかったので作成中かもしれません。

とりあえず動かすところまではできたので、気が向いたらnodeを大量に作って負荷テスト的なこともしてみようと思います。