Operations2026.08.10 · 17 min read

自宅サーバーの操作を自作 CLI 一本に寄せた — 14 コマンドの中身と、どの端末からでも同じように動く仕組み

NCP認証基盤・クラウドインフラ

自宅のミニ PC 1台で個人プロダクトを何本か動かしている。デプロイ・起動停止・DNS・リバースプロキシ・外部公開・DB ・モバイルビルド・配布のすべてを、nr という自作の CLI 一本の下に集約した。docker compose up systemctl も直接は叩かない、という運用にしてある。狙いは効率化ではなく、手で触った結果が記録に残らないことを止めることだった。この記事は自宅サーバー3部作の第3部にあたり、全体構成と名前解決は第1部・第2部に譲って、CLI そのものの中身だけを扱う。

手で1回だけ触った結果は、どこにも残らない

この構成には状態ファイルが1つある。/srv/nishioka/registry.json で、どのアプリがどのパスにあり、どのポートとドメインを使っているかを持った JSON のレジストリだ。一覧コマンド、管理ダッシュボード、稼働中アプリを定期的に再起動する cron、バックアップ対象の決定が、すべてそこから導出される。

問題は、この情報源を迂回する方法がいくらでもあることだった。ディレクトリに入って docker compose up -d すればアプリは動く。動くがレジストリには載らない。逆に、手で止めたきり忘れたアプリはレジストリに載ったまま残る。どちらもエラーは出ない。次に「一覧に出ているものが動いているはず」という前提で自動化を書いたときに、初めて壊れる。

単一の情報源を作ること自体は難しくない。難しいのは、その情報源を迂回する経路を残さないことのほうだ。

14 のトップレベルコマンドに何が入っているか

「経路を1本にする」を実際にやると、CLI は肥る。デプロイだけを包んで終わりにはならず、そのアプリが使う DB も、cron も、外部公開も、同じ情報源の上に載せないと結局どこかで手が入るからだ。いまの内訳は、トップレベルが 14 個、その下に 36 のサブコマンドがある。Go と cobra で書いた単一バイナリで、サーバー上では /usr/local/bin/nr に置いてある。

nr のコマンド体系
nr <command>

# アプリ管理
  app deploy [name] [path]     Deploy an app
  app rm <name>                Stop and remove an app
  app start|stop|restart <name>
  app pull <name>              Pull latest code and restart app
  app logs <name>              Show app logs (follow mode)

# 共通 PostgreSQL
  db up|down|restart|logs      共通 PostgreSQL コンテナの起動・停止・ログ
  db ls                        List databases (registry + actual, with drift warnings)
  db create <name> / db rm <name>
  db dsn <name>                Print the DSN for a database
  db connect <name>            Open an interactive psql session, or run one-shot SQL with -c
  db dump <name> [file] / db dumpall [dir] / db restore <name> <file>
  db sync-bookmarks            Regenerate pgweb bookmark files from the registry

# アプリ配布 (IPA / APK)
  dist add <id> / dist upload <id> <file> / dist ls / dist rm <id>

# モバイルビルド
  build <repo> <ios|android>   Build on a build node and upload it to nr dist

# 実行ノード
  node ls                      List nodes with readiness status
  node check <name>            Show detailed readiness checks for one node

# コーディングセッション
  code [repo]                  リポジトリを指定して detached セッションを起動
  code ls / code ps / code rm <name> / code prune

# cron
  cron ls / cron add <schedule> <command> / cron edit <n> ... / cron rm <n>

# 状態確認
  ls [--json]                  List deployed apps
  status                       Show server and services status
  doctor [--json]              Detect drift between the registry and running containers

# 公開・常駐・自己更新
  expose [app] / expose --off  Expose an app to the internet via Tailscale Funnel
  serve                        Start dashboard API server
  bot / bot install / bot uninstall
  update                       Pull latest code and rebuild CLI

ここで狙っているのは網羅ではなく、状態を変える操作がこの表の外に無いことだ。nr app deploy は compose・Caddy・dnsmasq・レジストリを、nr db create は PostgreSQL と DB 側レジストリを、nr cron add は crontab を、nr expose は Tailscale Funnel の状態ファイルを触る。逆に、読むだけの操作(ls / status / doctor)には副作用を持たせていない。--json を付ければ機械可読になるので、スクリプトからも同じ情報源を読める。ドリフト検知だけは終了コードも非 0 にしてあり、cron から nr doctor || notify の形で通知に繋げられる。

細かい話だが、アプリ名を間違えたときは編集距離で候補を返すようにしてある(app 'myap' not found. did you mean 'myapp'?)。自分ひとりしか使わない CLI でも、名前を思い出せずに一覧コマンドへ戻る往復は毎日起きるので、ここは早い段階で入れて正解だった。

デプロイ1回で4か所が同時に変わる

アプリを1本公開するのに変える必要があるのは、コンテナ、リバースプロキシの設定、LAN の名前解決、レジストリの4か所だ。手でやると、順序も、どこを飛ばしたかも、頭の中にしか残らない。

デプロイ
# 以前: アプリ1つを公開するのに、順に手で変えていた
$ docker compose up -d                    # コンテナを起動
$ sudo vim /etc/caddy/Caddyfile           # リバースプロキシに追記
$ sudo systemctl reload caddy             # 反映
$ sudo vim /etc/dnsmasq.d/apps.conf       # LAN の名前解決を追加
$ sudo systemctl restart dnsmasq
# 一覧・ダッシュボードの情報源であるレジストリは、当然どこも更新されない

# いま
$ nr app deploy myapp ~/git/myapp
Deploying myapp...
  Port:   3000
  Domain: myapp.dev
Deployed! Access at: https://myapp.dev

厄介なのは、途中を1つ飛ばしても即座には壊れないことだ。名前解決の登録を忘れてもコンテナは動いているし、ポートを直接指定すればアクセスもできる。気づくのは数日後、別の端末から繋がらないときになる。

アプリは4タイプに分けた。通常の Docker アプリはポートを 3000 から自動採番してドメイン (<name>.dev)・自動 HTTPS・DNS が全部つく。静的サイトはポートを持たないが公開経路は同じ。ヘッドレスの --worker はドメインもリバースプロキシも DNS も持たない。Docker に載せない常駐プロセス用に --systemd もあり、こちらは --exec で渡した起動コマンドから unit ファイルを生成する。この分類を最初に決めておくと、デプロイの内部処理が「どの手順をスキップするか」だけで書けるようになる。

途中で失敗したとき、どこまで戻すか

1コマンドにまとめると、今度は「途中で失敗したときに中途半端な状態が残る」問題が前に出てくる。ここは順序で解いた。

01
start containercompose を起動する。ポートは 3000 から自動採番、または --port で明示指定。
02
generate proxy config登録済みアプリ全件から Caddyfile を生成し直す。
03
reload proxy再生成した設定を反映する。ここまでで HTTPS の口が開く。
04
add dns entrydnsmasq に <name>.dev を1件追加する。
05
save registryここで初めて状態ファイルに書く。途中で落ちたら書かれない。
デプロイが実際にやっている順序

効いているのは レジストリの保存を最後に置いたこと だ。Caddyfile の再生成に失敗したらコンテナを落として戻す。DNS の追加に失敗したらコンテナを落とし、メモリ上のレジストリからもそのアプリを消して Caddyfile を作り直す。どの段で落ちても、ファイルには「デプロイ成功」が書かれない。エラーメッセージにも (rolled back) / (no changes persisted) のどちらかを必ず入れて、巻き戻したのか、そもそも何も書いていないのかが読んで分かるようにしてある。

状態ファイルの書き込み自体は、同じディレクトリに一時ファイルを作って rename で上書きする形にした。読み手が途中の内容を見ることがない。読み込み・変更・保存が並行プロセスから走りうる箇所は flock で囲ってある。

ここで一度踏んだのが、Caddyfile を全アプリ分まとめて作り直す構造の副作用だ。ドメインを持たないタイプのアプリがレジストリにある状態で再生成が走ると、ドメインが空文字のまま空のキーブロックが生成され、リロードが落ちた。壊れるのはそのアプリ 1本ではなく、全アプリのリバースプロキシ設定の反映だ。生成対象からドメイン未設定のアプリを除外して直したが、「全件から作り直す」設計は、1件の不正が全体を止める という性質を持っていることを、この時点まで意識していなかった。

消す順序は、作った順の逆にしなかった

削除は基本的に作成の逆順だが、1つだけ意図的に順序を変えている。外部公開(Tailscale Funnel の解除)を、レジストリから消したあとの最後に置いた。

理由は、失敗したときに残る状態の悪さが対称ではないからだ。中途半端に消えたアプリは、もう一度削除コマンドを叩けば片付く。それに対して外部公開が生きているのに、ローカルにその記録が無い状態は自力では気づけない。あとで別のサービスが同じポートに載ったとき、その公開設定をそのまま引き継いでしまう。

なので解除に失敗した場合は、公開状態のファイルをあえて残したままエラーを返すようにした。「記録は消えたが公開だけ残っている」を作らないほうを優先している。削除の途中で DNS エントリの削除に失敗した場合は、逆に Caddyfile にアプリを書き戻して再生成する。

公開そのものにも制約を2つ入れた。Funnel はマシン単位で HTTPS:443 を握る仕組みなので、同時に公開できるアプリは1つだけにしてある。別のアプリを nr expose すると、現在の公開を置き換える旨を表示してから入れ替わる。もう1つは拒否リストで、自前の認証を持たない管理系アプリは名前を指定しても公開できない。公開時にはアプリ名のタイプ確認を挟むが、この種のアプリはその手前で弾かれる。

どの端末から打っても同じコマンドが通る

ここまでの話は全部「サーバー上で nr を叩いたら」の話だ。しかし実際に作業しているのはノート PC で、サーバーに SSH で入ってから叩く、という一段が挟まると、その一段は必ずどこかで省略される。省略された日に docker compose up -d が始まる。

そこで、同じバイナリが自分の居場所を見て振る舞いを変えるようにした。自分の NIC にサーバーの IP が付いていればそのまま実行し、付いていなければ同じコマンドを SSH でサーバー側の nr に転送する。判定は環境変数でも設定ファイルでもなく、net.InterfaceAddrs() を引いて固定 IP と一致するかを見るだけにした。設定ファイルは同期を忘れるが、NIC の IP は嘘をつかない。

cli/cmd/root.go と cli/internal/remote/remote.go
// addRemoteCommand wraps cmd's RunE so that when not running on the server,
// the command is forwarded to the server via SSH.
func addRemoteCommand(cmd *cobra.Command) {
	original := cmd.RunE
	cmd.RunE = func(c *cobra.Command, args []string) error {
		return forwardOrRun(c, args, original)
	}
}

// IsLocal checks if this machine has the server's IP address.
func IsLocal() bool {
	addrs, err := net.InterfaceAddrs()
	if err != nil {
		return false
	}
	for _, addr := range addrs {
		if ipNet, ok := addr.(*net.IPNet); ok {
			if ipNet.IP.String() == config.ServerIP {
				return true
			}
		}
	}
	return false
}

コマンド定義側は addRemoteCommand(cmd) を1行足すだけでいい。RunE を包み直しているので、転送に対応させるのに各コマンドの実装は変わらない。実際、ls / status / doctor / app * / db * / code * / node * / build / expose は全部この1行だけで端末を選ばなくなった。

SSH の接続先も1段フォールバックを入れてある。まずホスト名で繋ぎ、失敗したら固定 IP で1回だけやり直す。自宅の DNS を自分で管理している以上、「名前解決を直したいのに名前解決が要るコマンドしか無い」状態は避けたかった。

cli/internal/remote/remote.go
// runWithHostFallback runs the command built for ServerHost, retrying once
// against ServerIP when it fails (e.g. .home DNS unavailable). beforeRetry,
// if non-nil, runs before the fallback attempt.
func runWithHostFallback(build func(host string) *exec.Cmd, beforeRetry func()) error {
	if err := build(config.ServerHost).Run(); err != nil {
		if beforeRetry != nil {
			beforeRetry()
		}
		return build(config.ServerIP).Run()
	}
	return nil
}

例外は3つある。nr update はサーバー側とローカル側の両方のバイナリを入れ替えるので転送では済まない。nr dist upload はファイル本体を SCP で送り、SSH 側ではメタデータの更新だけを叩く分担になっている。nr serve は systemd 配下で常駐する API サーバーなので、そもそも人が叩くコマンドではない。

SSH の向こう側で壊れるのは引数のほうだった

転送そのものは ssh user@host nr ... を叩くだけで、難しくない。難しいのは引数をどう積み直すかのほうだ。

素直にやるなら os.Args をそのまま横流しすればよさそうに見える。やめた。手元のシェルが1回展開したあとの文字列がそこにあり、それをもう1回リモートのシェルに渡すと二重展開になる。短縮フラグ(-d)と長いフラグ(--desc)、--port 3000--port=3000 の書き分けもそのまま流れていく。

代わりに、cobra が解析し終わった値から組み立て直すことにした。コマンドパスは親を辿って復元し、Flags().Visit明示的に指定されたフラグだけを列挙するので、既定値は転送されない。フラグは常に --key=value の形に正規化して1語にまとめる。

cli/internal/remote/forward.go
// BuildForwardArgs reconstructs a CLI invocation for SSH forwarding from
// Cobra's parsed values. It never reads os.Args: the command path is walked
// up to (but excluding) root, and explicitly-set flags are re-emitted as
// --key=value so quoting survives the SSH hop.
func BuildForwardArgs(root, cmd *cobra.Command, args []string) []string {
	var path []string
	for c := cmd; c != nil && c != root; c = c.Parent() {
		path = append([]string{c.Name()}, path...)
	}

	result := make([]string, 0, len(path)+len(args)+4)
	result = append(result, path...)
	result = append(result, args...)

	cmd.Flags().Visit(func(f *pflag.Flag) {
		if f.Value.Type() == "bool" {
			if f.Value.String() == "true" {
				result = append(result, "--"+f.Name)
			}
		} else {
			result = append(result, fmt.Sprintf("--%s=%s", f.Name, f.Value.String()))
		}
	})

	return result
}

もう一段、リモート側のシェルに渡す直前でクォートする。ここで1つ、実際に踏んでから直した箇所がある。エスケープ対象に = を入れているのは、macOS の既定ログインシェルである zsh が、= で始まる語を「そのコマンドの絶対パスに置換する」機能(equals 展開)を持っているからだ。tmux の完全一致指定は =name という書式なので、bash なら素通りする文字列が zsh のノードでだけコマンド名として解釈されて壊れる。クォートが必要な文字集合は、渡す相手のシェルによって違う

cli/internal/remote/remote.go
// ShellQuote quotes s for safe use in a remote shell command line. The
// special set includes '=' because zsh (macOS's default login shell) performs
// equals-expansion on words starting with '=' — e.g. tmux's exact-match
// target `=name` — which bash would pass through untouched.
func ShellQuote(s string) string {
	if s == "" {
		return "''"
	}
	if !strings.ContainsAny(s, " \t\n'\"\\$`!#&|;(){}[]<>?*~=") {
		return s
	}
	return fmt.Sprintf("'%s'", strings.ReplaceAll(s, "'", "'\"'\"'"))
}

「同じコマンドがどこからでも通る」の実装コストは、SSH を張る部分ではなく、1回パースした引数を、別のシェルで安全に再構成する部分に全部乗っている。ここを os.Args の横流しで済ませていたら、たまに壊れて再現しないバグを持ち続けることになっていたと思う。

実行ノードという概念を1つ足す

ミニ PC は N150 の4コアで、常駐しているアプリと同じ箱の上で開発ビルドやテストを回すと素直に競合する。手元には Mac mini(M2 Pro / RAM 32GB)もあり、こちらは普段ほとんど遊んでいる。そこで CLI に実行ノードという概念を足して、仕事の一部を Mac へ回せるようにした。

ノードの定義はサーバー上の /srv/nishioka/nodes.json に手で書く JSON 1エントリだけにした。マシンを1台増やすのに CLI のリリースは要らない。ファイルが存在しないときはサーバー自身だけの単一ノードとして振る舞うので、この機能を入れる前の挙動と完全に同じになる。

cli/internal/node/node.go
// Node is one machine nr can run work on. A node with an empty SSHHost is the
// local machine (the server nr itself runs on); remote nodes are reached over
// SSH with BatchMode key auth.
//
// TmuxPath and ClaudePath must be absolute for remote nodes: sshd gives
// non-interactive shells a bare PATH (on macOS just /usr/bin:/bin:...), so
// Homebrew and ~/.local/bin binaries would not resolve by name.
type Node struct {
	Name       string   `json:"name"`
	SSHUser    string   `json:"sshUser,omitempty"`
	SSHHost    string   `json:"sshHost,omitempty"`
	Home       string   `json:"home"`
	GitBase    string   `json:"gitBase"`
	TmuxPath   string   `json:"tmuxPath,omitempty"`
	ClaudePath string   `json:"claudePath,omitempty"`
	Roles      []string `json:"roles"`
	OS         string   `json:"os,omitempty"`
	Arch       string   `json:"arch,omitempty"`
}

sshHost が空のノードがローカル、という定義にしてある。ローカルは必ず1つ、というのを読み込み時に検証しているので、以降のコードで「ローカルが見つからない」分岐を書かなくて済む。実行系は Executor インタフェース1枚に寄せ、ローカルなら直接プロセスを起動、リモートなら SSH 越しに走らせる。SSH 側の書き込みも、一時ファイルへ書いてから同一ディレクトリ内で mv するようにして、ローカル側の原子的書き込みと挙動を揃えてある。

ハマったのは PATH だ。macOS の sshd が非対話シェルに渡す PATH は /usr/bin:/bin:... しかないので、Homebrew の /opt/homebrew/bin~/.local/bin も通らない。手で SSH して動くコマンドが、CLI から走らせると「見つからない」で落ちる。結局、リモートノードでは実行ファイルを絶対パスで持つことを必須にし、設定を読み込む時点で欠けていればエラーにするようにした。

代わりに、準備が整っているかを確認するコマンドを用意した。到達性・tmux・CLI 本体・初期化済みか・リポジトリ置き場の5点を、SSH 1往復のスクリプトでまとめて確認して ok / ng のトークンだけ返させている。1項目ごとに SSH を張ると、5往復のレイテンシがそのままセッション起動の待ち時間になるからだ。

ノードの一覧と準備状況
$ nr node ls
NAME      HOST                 ROLES        STATUS
nishioka  (local)              code,deploy  ready
macmini   user@192.168.0.25    code,build   ready

$ nr node check macmini
✓ reachable
✓ tmux (/opt/homebrew/bin/tmux)
✓ claude (/Users/user/.local/bin/claude)
✓ claude 初期化済み (~/.claude.json)
✓ gitBase (/Users/user/git)
ready

セッションをどのマシンで動かすかを自動で決める

ノードを足して最初に載せたのは、コーディングセッションの起動だった。 nr code <repo> を叩くと、そのリポジトリで detached の tmux セッションを1つ立てる。どのマシンで立てるかは、指定が無ければ自動で決まる。

cli/internal/codesession/codesession.go
// localSessionLimit is how many concurrent local sessions automatic placement
// allows before overflowing to a remote node. The local machine also runs all
// the production apps, so parallel dev builds/tests must not crowd it out.
const localSessionLimit = 6

	local := nodes.Local()
	if localActive < localSessionLimit {
		return local, nil
	}
	for _, n := range nodes.CodeNodes() {
		if n.IsLocal() {
			continue
		}
		if ready, hasRepo := probe(n); ready && hasRepo {
			return n, nil
		}
	}
	// Every remote candidate is unreachable, unprepared, or missing the repo:
	// an overloaded local session beats refusing to start one at all.
	return local, nil

設計として意識的に非対称にしたのは、明示指定と自動配置でフォールバックの扱いを変えたところだ。--node macmini と書いたのに準備ができていない場合、黙って別のマシンへ回すのは親切ではなくバグの温床になる。「そのノードで動かしたい」と言った以上、動かせない理由が返るべきだ。逆に自動配置で全部の遠隔候補が落ちていたら、混んでいるローカルで起動するほうが、起動を拒否するよりましだ

しきい値の 6 にも理由がある。ローカルは本番アプリが全部載っている箱なので、開発用のビルドやテストがそれを押し出してはいけない。localSessionLimit はチューニング値ではなく「本番の取り分を確保する」という方針の表現になっている。

一覧・停止・一括停止はノードをまたいで1つのビューにした。到達できないノードがあっても失敗にせず、警告を出して部分的な一覧を返す。全滅で落ちるより、「この台は見えていない」と明記した部分結果のほうが運用では使える。停止も、名前のパターンではなく実際の横断一覧に照合してから殺すので、無関係の tmux セッションを名前の偶然で巻き込むことがない。

ノード横断のセッション一覧
$ nr code ps
NAME         REPO    NODE      CREATED
webapp-7k2a  webapp  nishioka  2026-08-10 09:12
myapp-q4mn   myapp   macmini   2026-08-10 09:41

iOS のビルドだけは Mac に回すしかない

ノードの2つ目の用途がモバイルビルドだ。ここは分散の設計判断というより、Xcode が macOS でしか動かないという単純な事実からくる。Linux のミニ PC にどれだけ手を入れても IPA は出ない。だから Mac mini 側に build ロールを持たせ、CLI からは1コマンドに見えるようにした。

01
push committed HEADサーバー側 ~/git/<repo> の HEAD を、ビルドノード上の専用 workspace へ refs/build/head として push する。
02
build on the nodeeas.json があれば eas build --local、capacitor.config.* なら Gradle + apksigner。SSH 越しに出力をそのまま流す。
03
fetch artifact生成された IPA / APK を scp でサーバーへ回収する。
04
upload to distそのまま nr dist upload に渡し、バージョンを採番して配布ページに載せる。
nr build が実際にやっている順序

ここで一番効いている判断は、ビルドノードの作業ツリーを一切触らないことだ。Mac 側にも同じリポジトリの clone があるが、そこでビルドすると「手元の未コミット変更が混ざった何か」が出来上がる。代わりに、ビルド専用のリポジトリを別に用意して、1回のビルドにつき1コミットだけを refs/build/head へ push する形にした。ビルドされたものが必ず特定の SHA に対応する、という保証がこれで得られる。生成物のファイル名にも短縮 SHA を入れてある。

cli/internal/mobilebuild/mobilebuild.go
// pushHead force-updates refs/build/head in the node's workspace repo to sha,
// creating the workspace repo on first use.
func pushHead(src string, n node.Node, repo, sha string) error {
	initScript := fmt.Sprintf(
		"mkdir -p \"$HOME/build/artifacts\"; [ -d \"$HOME/build/%s/.git\" ] || git init -q \"$HOME/build/%s\"",
		repo, repo)
	if out, err := n.Executor().Run("sh", "-c", initScript); err != nil {
		return fmt.Errorf("workspace init on %s: %w: %s", n.Name, err, strings.TrimSpace(string(out)))
	}

	push := exec.Command("git", "-C", src, "push", "-q",
		fmt.Sprintf("%s@%s:build/%s", n.SSHUser, n.SSHHost, repo),
		"+"+sha+":refs/build/head")
	push.Env = append(os.Environ(), "GIT_SSH_COMMAND=ssh -o BatchMode=yes -o ConnectTimeout=5")
	if out, err := push.CombinedOutput(); err != nil {
		return fmt.Errorf("git push to %s: %w: %s", n.Name, err, strings.TrimSpace(string(out)))
	}
	return nil
}

サーバー側に未コミットの変更があれば警告を出すが、止めはしない。ビルドされるのはコミット済み HEAD のみ と明示して進める。ここを黙って進めると「直したはずの修正が入っていない」で30分溶ける。

プロジェクト種別は自動判定にした。eas.json があれば Expo として eas build --localcapacitor.config.* があれば Capacitor として Gradle と apksigner を回す。両方ある場合は Expo を優先する(Capacitor 対応を後から足したときに、既存の Expo アプリの挙動を変えないため)。JDK のバージョンすら両者で違うので、パイプラインは PATH ごと分けてある。

非対話 SSH 固有の面倒も1つある。iOS の署名では login keychain がロックされたままセッションが始まるので、パイプラインが明示的にアンロックし、codesign がダイアログなしで秘密鍵を使えるよう設定してからビルドに入る。gitignore されているがビルドに必要なファイルは、ビルドノード側の所定ディレクトリに置いておき、checkout 後に上から被せる。リポジトリに入っていないものは、リポジトリの外で管理するしかないので、その置き場所を1か所に決めてパイプラインに知らせておく。

ビルドが終わると scp で成果物を回収し、そのまま nr dist upload に渡してバージョンを採番する。手元で試した範囲では、同じモノレポのテストがミニ PC で 83 秒、Mac mini で 14.4 秒だった(2026-07-10 実測)。この差はコアの数だけの話ではなく、本番アプリと同じ箱で並列度を上げられないことの代償でもある。

CLI を唯一の経路にはできない

ここまでやっても、迂回経路は消せない。ローカル開発用に compose を自前で立てるツールはいくつもあるし、検証のために手で1本上げることもある。「全部この CLI を通せ」という運用ルールは、自分ひとりでも守り切れない。

そこで、経路を塞ぐのをあきらめて、ずれを検知する側に回した。稼働中の compose プロジェクトを Docker のラベルから読み、レジストリに登録されたアプリのパス(またはそのディレクトリ名)に該当しないものをドリフトとして報告する。レポートのみで、勝手には止めない。

ドリフト検知
$ nr doctor
Drift check:
  ⚠ supabase-myapp      running      ~/git/myapp/supabase (nr 管理外)

1 件: nr で立てていない compose プロジェクトが稼働中。停止は `docker stop` で判断。
注: compose ラベルを持たない素の `docker run` コンテナは検知対象外。

実装で1つ選択がある。docker compose ls を使うほうが素直だが、メタデータを持たない CLI 発のスタックを取りこぼす。実際に検知したかったのがまさにそれだったので、コンテナのラベルを直接読む方式にした。ドリフトがあるときは終了コードを非 0 にしてあり、nr doctor || notify の形で cron から通知に繋げられる。

検知できない範囲を、検知結果の下に書いておくようにした。compose のラベルを持たない素の docker run は対象外だと明記していないと、「何も出なかった」が「何もない」と読み替えられる。検知ツールの一番危険な壊れ方はそこだと思う。

PostgreSQL を1つだけ立てて共有する

アプリごとに DB コンテナを立てていくと、常駐プロセスもメモリも本数どおりに増える。ミニ PC 1台という前提ではそこが先に効くので、新規アプリ用に PostgreSQL を1つだけ常駐させ、そこに相乗りさせることにした。既存のアプリ、特に DB を内蔵しているものは触らず、そのまま別コンテナで動かし続けている。

分離の粒度はサービスごとに1データベース、ユーザーは共通のスーパーユーザー1つにした。ここは弱いところを弱いと認めた上での判断で、アプリ間の権限境界は事実上ない。1つのアプリが侵害されれば全 DB に届く。だから後述のとおり、外部公開アプリの本番データはここに載せない。

アプリ側の統合コストは小さい。共有の Docker ネットワーク(nishioka-shared)を attach して、DSN を環境変数で渡すだけになる。LAN からは postgres.home:5432 で引けるが、bind をサーバーの LAN アドレスに限定してあるので、外からは SSH トンネル前提になる。

アプリ側の docker-compose.yml
# アプリ側の compose がやることは、共有ネットワークを attach するだけ
services:
  app:
    networks: [default, shared]
    environment:
      - DATABASE_URL=${DATABASE_URL}   # postgres://<user>:***@postgres:5432/myapp

networks:
  shared:
    name: nishioka-shared
    external: true

DB 操作も同じ CLI に寄せた。psql 直叩きや docker exec をやめた理由はアプリ側とまったく同じで、手で作ったデータベースは DB のレジストリに載らないからだ。nr db ls はレジストリと実際の pg_database をマージして表示し、片側にしか無いものを missing / orphan として警告する。nr db dsn で接続文字列を、nr db connect で対話 psql(-c で単発 SQL)を、nr db dump / dumpall / restore でバックアップと復元を、それぞれ同じ入口から叩ける。

立ち上げでは2つ踏んだ。

1つ目はイメージ側の変更だ。PostgreSQL 18 系のイメージは /var/lib/postgresql/<version>/docker を data dir として使う方式に変わっていて、従来どおり data に直接マウントすると initdb が拒否する。マウント先を1階層上げ、ホスト側にバージョン別のサブディレクトリが並ぶ形に追従した。

2つ目は healthcheck の pg_isready で、接続先データベースを省略していたために存在しないデータベースへ繋ぎに行き、10 秒ごとに FATAL をログへ吐き続けていた。サーバーは応答しているのでヘルスチェック自体は通り、コンテナは healthy のままだ。症状がログの汚れだけだったので、見に行くまで気づかなかった。

共通 DB の docker-compose.yml(該当部分)
# PostgreSQL 18 系のイメージは /var/lib/postgresql/<version>/docker を
# data dir として使う方式に変わった。従来どおり data を直接マウントすると
# initdb が拒否する。マウント先を1階層上げて追従する。

services:
  db:
    image: pgvector/pgvector:pg18
    volumes:
      # - ./data:/var/lib/postgresql/data   # 17 まではこれでよかった
      - ./data:/var/lib/postgresql
    healthcheck:
      # 接続先 DB を省略すると、存在しない DB に繋ぎに行って
      # FATAL を 10 秒ごとに吐き続ける(コンテナは healthy のまま)
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d postgres"]

どこから相乗りをやめるか

共通 DB に載せるものは、次の範囲に限った。

  • 個人開発の通常のアプリ。CRUD しかしないもの全般。
  • pgvector で足りるベクトル検索・RAG 用途。イメージに同梱してあるので、各 DB で拡張を1回有効化するだけで済む。
  • 自分専用で、センシティブでないデータ。
  • 単独の論理ダンプから復元できれば十分なもの。

載せずに別コンテナを立てるものは次のとおり。

  • 外部に公開しているアプリの本番データ。権限境界が無いので、相乗りさせた時点で他の DB まで巻き込む前提になる。
  • PostGIS・TimescaleDB のような追加拡張を前提にするもの。共通 DB のイメージを1アプリの都合で変えると、全アプリが影響を受ける。
  • すでに DB を内蔵している大規模アプリ。メンテナンス責任がこちらに無いものは、そのまま別コンテナで置いておく。
  • ストリーミングレプリケーションや PITR が要るもの。共通 DB の運用はダンプとリストアまでしか面倒を見ない。

拡張の線引きだけは補足しておきたい。拡張を1つ足すたびに、共通 DB は「その拡張が入った DB」になる。全アプリが同じイメージを共有しているので、1アプリの都合が全体の前提に変わってしまう。pgvector だけを例外にしたのは、用途が広く、後から入れ替えるとロケール周りで非互換を踏む可能性があったからで、実データベースがまだ1つも無い初期段階でイメージごと切り替えた。判断そのものより、判断できるうちに判断したことのほうが効いている。

最後に、一本化をあえてやめた箇所がある。nr app rm myapp はコンテナ・Caddy・DNS・公開設定を巻き戻すが、データベースは消さない。消すには nr db rm をデータベース名のタイプ確認つきで叩く必要がある。共有資源をアプリ削除の巻き添えにしないための意図的な分離で、一本化のご利益は「1コマンドで全部戻る」ことにあるが、それは復旧できる方向にだけ効かせたい。

自分自身を入れ替えるコマンドを持たせる

端末が複数ある以上、同じバイナリが複数の場所に存在する。バージョンがずれると、転送されたコマンドがサーバー側で「そんなサブコマンドは無い」と言い出す。なので更新も CLI に入れた。nr update は、まずサーバー側を更新し(git pull go build → バイナリ差し替え → 常駐 API の再起動)、そのあとローカル側の同じ手順を回す。順序が逆だと、更新の途中で自分だけ新しくなったバイナリが古いサーバーへ転送するという一番厄介な状態を作る。

バージョン文字列は git describe --tags --always の結果を ldflags で埋め込んでいる。nr --version が「どのコミットのバイナリか」を返すので、挙動が食い違ったときにまずここを見れば切り分けが1手で済む。常駐 API の再起動が失敗したときは、新しいバイナリだけが入って API が落ちている状態になるので、そこは Updated! を出さずにエラーで返すようにした。

更新まわりでもう1つ効いた修正がある。CLI の更新はバイナリを差し替えるだけで、初期セットアップスクリプトは走らない。つまりCLI のバージョンだけ上がって、その下の実体が無いサーバーが最初に出来上がる。この状態で DB コマンドを叩くと、ディレクトリが無いという生の docker エラーが出た。nr db up に自己ブートストラップ(ディレクトリ作成、compose ファイルの配置、パスワードの生成)を持たせ、他のサブコマンドは未配置なら nr db up を案内するようにして塞いだ。

まとめ

自作 CLI に寄せて効いたのは、コマンドを覚えなくてよくなったことではなく、状態を変える経路が1本になったことだった。手数はほとんど変わっていない。変わったのは、実態と記録がずれる余地のほうだ。

設計として残ったのは5つある。状態ファイルへの書き込みは最後に置き、途中の失敗は戻すこと。迂回経路は塞ぎ切れないので、検知する側に回り、そのうえで検知できない範囲を明記すること。共有資源は、一括操作の外に置くこと。同じコマンドがどこからでも通るようにするなら、引数は1回パースした値から組み立て直すこと。そして、機械を1台増やすコストは JSON 1エントリまで下げておくこと。

利用者が自分ひとりでも、この問題は起きる。原因は人数ではなく、状態が複数の場所に分かれて存在していて、そのどれもが手で書き換えられるという構造のほうにある。

GoCLI 設計DockerSSHPostgreSQL