自分が欲しかったデプロイツールを作る
(ruuda.nl)- Deptoolは、DNSとWebサーバー設定を自前で運用するために作られたデプロイツールで、変更計画を先に表示し、確認後に対象ホストへ適用する
- クラスター全体の設定を事前にレンダリングしてGitで管理し、ホストごとに
/var/lib/deptool配下へコミット別ディレクトリを置いたうえで、currentシンボリックリンクを切り替えてバージョンをアトミックに切り替える - デプロイ前に各ホストでロックを取得し、ローカルが把握しているコミットと実際のデプロイ状態を比較して古い計画を中止し、影響を受けるすべてのホストのロックを確保できた場合にのみ進行する
- サービスはsystemdユニットとして実行され、設定変更時に再起動し、起動失敗時には以前のknown-goodバージョンへリンクを戻して再起動することで、ミリ秒単位の自動ロールバックを行う
- リモート実行はSSHを転送レイヤーとしてのみ使う静的エージェント方式で、Flatcar LinuxのようにPythonやパッケージマネージャーがない環境でもcoreutilsだけで自動インストールできる
Deptoolを作った背景
- 欧州のデジタル主権について書いた記事を、米国ホスティングや米国が支配するハイパースケーラー上に載せるという矛盾を避けるため、ブログを欧州へ移す作業から始まった
- DNSもCloudflareに依存していたため、DNSサーバーも自前で運用する必要が生じた
- 既存のWebサーバーは小さなVM上でNginxと証明書更新用のLegoを動かし、Nginx設定はNixで生成したうえで、小さなPythonスクリプトでサーバーへコピーしてNginxを再起動する方式だった
- DNSサーバーを運用するには最低2台のサーバー、さらに多くのsystemdユニット、設定ファイル、zonefileが必要になり、従来のスクリプトでは不十分になった
- NixOSへ移行する選択肢もあったが、最小限のベースOSと読み取り専用chroot内に必要なバイナリだけを含めてサービスを動かす現在の方式を維持し、新しいデプロイツールを作ることにした
Deptoolの使い方
- Deptoolは、クラスター設定の変更計画を先に表示し、確認を得てから対象ホストへ適用する
- DNSレコード更新の例では、
deptool deploy実行後にs4.ruuda.nlとs5.ruuda.nlに対してnsd設定ファイルの変更とnsd.serviceの再起動が計画として表示される - デプロイ失敗時には自動ロールバックが適用され、例では
prodクラスターの2台のホストへ適用するか確認した後、0.99秒で成功している - 出力は対象ホスト、変更されるアプリケーション、変更ファイル、再起動するsystemdユニットを分けて表示し、デプロイ前に実際に行われる作業を確認できる
欲しかったデプロイツールの条件
-
高速
- 設定更新は1秒未満であるべきで、大西洋横断のpingでも100ms程度なのだから、本質的にそれ以上遅くなる理由はないと考える
-
予測可能
- ツールは何を行うかを先に見せ、その通りに実行すべき
- OpenTofuのようにplanとapplyを分離する方式を求めている
- Ansibleのcheck modeは、命令的なステップが実行された後でないと連鎖的な変更が見えないことがあり、checkと実行の間にホスト状態が変わるのも防げないため、信頼しにくいと考える
-
安全
- Nginx設定が壊れてもWebサーバーが数分間停止しないよう、ツールはミリ秒単位で自動ロールバックできる必要がある
-
単純
- 必要なのは、ノートPCからサーバーへ設定ファイルをコピーし、いくつかのsystemdユニットを再起動することだけ
- すべてのデプロイ問題を解決したり、制御フローや任意コード実行を提供したりする必要はない
- 設定ファイルのテンプレート処理は別ツールに任せられ、YAMLテンプレートの問題意識はgenerateと別のファイル生成ツールに分離されている
-
宣言的であること
- 設定からファイルやアプリケーションを削除したら、サーバー側でも削除されるべき
- 明示的なクリーンアップ手順を追加する必要がなく、忘れてdriftや残骸ファイルが発生してはならない
-
初期セットアップ不要
- サーバーをプロビジョニングした直後から管理できるべき
- エージェント、デーモン、依存関係を手動でインストールしたり、ホストを登録したりする手順が必要なら、その手順自体をまた自動化しなければならなくなる
設定生成とデプロイの分離
- 核となる考え方は、設定生成とデプロイを分離すること
- 職場でDavidが作った Unsible は、Ansible playbookを段階的に実行せず、ローカルでtarballを作ってホストへ送り、ファイルを配置する方式を取っている
- 既存の単純なデプロイスクリプトも外部で設定をビルドし、スクリプト自体はファイルコピーに近い役割だった
- NixOSもこの考え方をローカルシステムに適用したものと見なせ、Nixから学べる点は、生成物を複数バージョンが共存できる場所に保存し、system administrationの命令的な部分をいくつかのシンボリックリンクを切り替える小さなアクティベーション段階に限定することだ
- この設計はパッケージ管理とシステム設定の両方によく合う
Deptoolの動作方式
-
クラスター全体の設定を事前レンダリングする
- クラスター全体の設定ファイルを事前生成し、ディスク上のディレクトリに保存する
- ディレクトリツリーは2段階の深さで、最上位に対象ホストごとのディレクトリがあり、その下にアプリケーションごとのディレクトリがある
-
Gitリポジトリに入れる
- 設定ディレクトリをGitリポジトリに入れると、バージョン間の差分を比較でき、クラスター全体で何が変わったかを確認できる
- diffstatで影響を受けるホストと変更されたアプリを把握でき、各設定ファイルの正確なdiffも見られる
-
ホスト上の隔離ディレクトリにファイルを実体化する
- すべてのファイルを
/var/lib/deptool配下に置くことで、他の要素と干渉しないようにする - デプロイするコミット名でディレクトリを作るため、複数バージョンをディスク上に共存させられる
currentシンボリックリンクがデプロイ済みバージョンを指すようにし、バージョンをアトミックに切り替えられる- 削除されたファイルは次バージョンに実体化されないため、残骸ファイルは発生しない
- 特定の場所にファイルを要求するアプリケーション向けには、ファイルシステム上の必要な場所から
/var/lib/deptoolを指すシンボリックリンクを作れる - シンボリックリンクの作成・削除はアトミックではないが、ファイル内容の更新時ではなく、リンクの追加・削除時にだけ必要になる
- 以後のデプロイバージョンにそのシンボリックリンクが含まれなければ、diffによって削除が必要だと分かるため、ファイルは残らない
- すべてのファイルを
-
リモート追跡refでデプロイ状態を記録する
- 管理者のノートPCから、各ホストにデプロイされたコミットを追跡する
- デプロイ状態はクラスター全体の属性ではなく、ホストごとの属性である
- ある変更が特定ホストに影響しないなら、そのホストへ新しいコミットをデプロイする必要はない
- この情報によりクラスター差分をオフラインで計算でき、その差分がデプロイ計画となってミリ秒単位で表示できる
-
デプロイ前に対象ホストでロックを取得する
- SSHで接続してロック要求を送り、その要求にはそのホストにデプロイされていると考えているコミットを含める
- ロックを取得できれば計画は有効であり、ロックを解放するまで他のデプロイはそのホストで進められないため、計画は有効なまま保たれる
- 変更の影響を受けるすべてのホストのロックを保持した場合にのみデプロイが進む
- refが古く、別の何かがすでにホストにデプロイされているなら、その計画はstaleなので中止する
- その後ローカルrefを更新すれば、次回実行時には最新の計画を見られる
-
systemdユニットを再起動する
- すべてのサービスはsystemdユニットとして動作し、すばやく起動するため、不確実な場合は再起動を選ぶ
- アプリケーション設定が変わったら、影響を受けるsystemdユニットを再起動する
- ユニットの起動に失敗した場合は、シンボリックリンクを以前のknown-goodバージョンへ戻して再起動し、ミリ秒単位の自動ロールバックを可能にする
楽観的同時実行モデル
- Deptoolのデプロイには楽観的同時実行の要素がある
- 現在のクラスター状態を把握していると仮定して計画を立て、その仮定が間違っていたらやり直す
- 競合がないときは非常に高速で、個人インフラを1人が同じノートPCからデプロイするケースはこれに当たる
- 複数人が継続的にデプロイを試みる環境では、1人だけが成功して他は再試行になるため、性能は大きく悪化しうる
- このモデルは
git pushと同じで、数百人や数千台規模のサーバーには拡張しないが、個人インフラ用途には十分である - 自分でツールを作れば、自分の正確なユースケースに合わせて最適化できる
エージェントの構築
-
Flatcar Linuxと初期ホスト制約
- Webサーバーは Flatcar Linux 上で動作している
- Flatcar LinuxはイメージベースOSで、userspaceが非常に小さく、coreutilsとBashはあるが、パッケージマネージャーもPythonもない
- 攻撃面の削減には有利だが、何かをインストールするには不向きである
- ツールを動かすためにまず何かをインストールしなければならないなら、そのインストール手順を自動化するという新たな問題が生じる
-
SSHを転送レイヤーとしてのみ使う
- 新しいホストを外部から管理する必要があるため、SSHとpasswordless sudoを利用できる
- SSHで直接コマンドを実行する方式は、ハンドシェイクが遅いだけでなく、argvがSSH境界を安全に越えられず、shell-over-SSHのword splittingやescapingの問題にも対処しなければならない
- Deptoolは、予測可能な場所にある引数なしの単一プログラムを実行し、このプログラムをエージェントとして使う
- エージェントはstdinからメッセージを読み、stdoutへ応答を書く
- SSHはソケットのような転送手段としてのみ使われ、ユーザー制御入力はSSHやshellコマンドに入らないため、escapingの問題を回避できる
-
静的バイナリを使う
- エージェントは静的バイナリとしてビルドする
- カーネル以外に何があるかを仮定せず、有用な処理をする前に数MBのコードを解析しなければならないインタプリタも不要である
- Ansibleは最悪の欠点を mitigate した後でも、接続のたびに数MBのPythonモジュールを転送し、しかもFlatcarにはPythonがない
-
commitベースのパスにバイナリを置く
- エージェントバイナリは、ビルドしたcommitを含むパスに保存する
- 接続の両端で同じバージョンを実行することが保証され、プロトコル互換性の問題が起きない
- パスは
/var/lib/deptool/bin/deptool-<version>-<commit>の形式である
-
まずバイナリがあると仮定する
- SSHハンドシェイクはコストが高いため、プローブや冪等なインストール手順に無駄遣いしない
- エージェントバイナリは約1.6MBで、転送禁止にするほど大きくはないが、無料でもない
- クラスター設定の変更はDeptoolの更新よりはるかに頻繁に起こるため、通常はバイナリはすでに存在するとみなす
-
実行失敗時にバイナリをインストールする
- バイナリの起動に失敗したら、2本目のSSH接続でインストールを行う
- 実行コマンドは次の通り
uname -sm
&& sudo mkdir -p /var/lib/deptool/{bin,apps,store}
&& sudo dd status=none of=<remote_bin_path>
&& sudo chmod +x <remote_bin_path>
&& sudo sha256sum <remote_bin_path>
- まずstdoutから1行を読み取って
unameの出力を取得し、それに基づいてOSとCPUアーキテクチャを判定し、そのプラットフォーム用のエージェントバイナリを送る - バイナリをstdinへ書き込むと、リモート側の
ddがそれをディスクへ書き込む - 最後にstdoutからもう1行を読み取り、リモートで計算されたshasumを確認して転送成功を検証する
- このプロセスは標準化されたcoreutilsプログラムにのみ依存する
- その後エージェントの起動を再試行すれば成功するはずで、エージェントはディスクがいっぱいにならないよう古いバージョンを掃除する
エージェント方式の効果とコスト
- リモートホストでエージェントを実行して通信する方法を得られる
- リモートホストにcoreutils以外の要件なく自動インストールできる
- 両端が同じバージョンを実行するため、プロトコル互換性は構造的に保証される
- ユーザー制御入力はSSHベースのソケット経由でのみ渡され、SSHやshellコマンドには入らないため、escapingの問題や長さ制限を避けられる
- 通常ケースではSSHハンドシェイクは1回だけで済み、レイテンシは小さい
- 新しいマシンへのデプロイやツール更新後のようなまれな場合には、追加で2回の接続と1回限りの1.6MB転送が必要になる
ControlMasterを使えば、その後の接続オーバーヘッドの大半を省略でき、全体コストは数秒程度になる- この場合は1秒未満のデプロイにはならないが、それでもAnsibleより良いと考える
- 設定をデプロイして少し修正し、再度デプロイする流れでは、SSHが基底接続を維持できるため、デプロイは即時に感じられる
利用結果と公開
- Deptoolはこの1か月、個人インフラ管理に使われている
- 接続前に正確な計画を即座に見られ、自動ロールバックもあるのは良いが、最大の変化は1秒未満デプロイである
- 正しいデプロイ方法に数分かかるなら、フィードバックループを縮めるためにサーバー上で直接ファイルを編集したくなるが、Deptoolではローカルで修正してデプロイするほうが、SSHでサーバーに入りエディタを開くより速い
- 摩擦が最も少ないやり方が正しいやり方になり、適用されたすべての修正はGit履歴に残る
- 何かを壊しても、壊れたと認識する前にDeptoolがロールバックする
- Deptoolは個人的な問題を正確に解くために作られており、あらゆる人のあらゆるデプロイ問題を解こうとしないことこそが、このユースケースで光る要素になっている
- 特にイメージベースのOSで有用かもしれず、Codeberg と GitHub で公開されており、詳しい manual も提供されている
1件のコメント
Lobste.rsの意見
このプロジェクトが LLM生成テキストを一切入れていないことを明示している点 が本当にうれしい: not putting LLM-generated text anywhere near this
ツール自体もよく練られていて設計も良さそうだが、自分はしばらく NixOS を使い続けると思う
ぜひ使ってみたい。systemdベースのサービスをデプロイするために自作した仕組みの、より洗練された版のように見える
チュートリアルを見る限り良さそうだが、ローカル状態をどう扱うのがよいのか気になる。たとえばアプリのsqliteデータベースをどこに置くべきか、ドキュメントでは見つけられなかった
それと、アプリのバイナリをサーバーへ転送してsystemdユニットから使えるようにする方法があるのかも気になる。ないなら、バイナリ配布をどう処理しているのか知りたい
/var/lib/<yourapp>ですアプリケーションをsystemdユニットとして実行するなら、
StateDirectory=を使って、systemdに適切なユーザー所有でディレクトリを作らせることができます自分が運用しているアプリケーションは、このNixベースのスクリプトで小さなEROFSイメージにビルドしており、そのスクリプトにはイメージをサーバーへプッシュする機能も含まれています。以前は別ステップでしたが、今はビルドとプッシュを1段階にまとめており、一意なディレクトリに入るので複数バージョンを共存させられます
ビルド結果にはファイルパスを含むJSONも含まれており、それをクラスター設定として取り込んでsystemdユニットにレンダリングし、その後Deptoolでデプロイします。つまり1つのツールがイメージ配布を担当し、Deptoolは有効化 を担当する構成です
コンテナを使うなら通常はレジストリにプッシュし、サーバー側には何を取得するかを指定する設定ファイルだけがあるので、その部分はDeptoolだけで管理できます
別のアプローチとしては bootable containers を使う方法もかなり良い
まだ惜しいのは、適切なホスト上で実際に
bootc update --applyを実行してくれる仕組みがないこと。自動更新メカニズムはあるがオーケストレーションされていないので、クラスターでは望ましくない形だ今は手作業でやっているが、最終的に実行するのはbootcコマンド1つだけなので、後でスクリプト化するのは簡単そう
新しいデプロイツールが出るたびに少し懐疑的に見てしまうが、これは 設計が良く、よく練られている ように思える
sshコマンドをそのまま使うのも正しい選択に見える。ユーザーは自分のこのsshが実際に動くことを分かっているし、非常に特殊な設定やパッチ済みのsshバイナリを使っているかもしれない外部ライブラリでsshを自前実装しようとするツールは、一部のユーザーにとって障害になる可能性が高い
EROFS をどう使っているのか、そしてなぜ使っているのかをもっと詳しく知りたい
Flatcarにはパッケージマネージャーがないため、ソフトウェアと依存関係を何らかの形で自前で載せる必要があり、その方法の1つが自己完結型のファイルシステムイメージです
OCIイメージではPodmanやDockerのような別ツールがtarをどこかに展開し、オーバーレイマウントのスタックを構成する必要がありますが、すでにファイルシステムイメージであれば、
RootImage=でsystemdユニットから直接実行できますNixでイメージをビルドしていて、本当に最小構成しか入っていません。Nginxバイナリ、LibreSSL、libc、いくつかの共有ライブラリだけで、Bashすらありません
これは 多層防御 の一部です。Nginxにリモートコード実行の脆弱性があったとしても、攻撃者は次段階のエクスプロイトを作る材料がほとんどないファイルシステム名前空間内で実行され、ファイルシステム全体も読み取り専用です。単に読み取り専用でマウントしているからではなく、EROFS自体に書き込み機能がないからです
以前はSquashfsを使っていて問題なく動いていましたが、あのファイルシステムはライブCD時代を前提に設計されています。EROFSは現代のシステムにより合ったトレードオフを選んでいますが、正直自分の用途では測定可能な差はないと思います
イメージがより小さいのは確かですが、それは圧縮設定を変えているためです。理論上は、異なるバージョンのイメージ間でデータを再利用したい場合、EROFSは 内容定義チャンク化 により適しているはずですが、まだ実際のイメージ転送には使っていません
ちょうど友人とシンプルなデプロイ戦略を議論していたところにこの記事が出てきて、私たちが辿り着いていた結論にかなり近い
ただ、この構成では シークレット管理 をどうしているのか気になる
「Prompting the deployment tool I wish I had」と言っていたが、
https://codeberg.org/ruuda/deptool/…
ある意味では、浮動小数点数たちが Rust を使うよう自分を説得したというのはすごい
良く言えばRustは「規律ある」言語で、強い慣習とツール体系があります。どちらもLLMには助けになります
奇妙なことに、LLMは少なくとも少し誘導してやれば、いくつかの言語よりもRustで より短いプログラム を生成する傾向があります。どうせすべてのコードを読んで手直しするつもりなので、自分にとっては短いほうが良いのです
これで シークレット はどう扱っているのか気になる。好みのワークフローがあるのか、EROFSイメージに入れるのか、それともsystemdで注入するのか知りたい
そのディレクトリはLegoユニットでは読み書き可能でマウントし、Nginxユニットでは読み取り専用でマウントしています