← ブログに戻る

TailscaleでPiにCIデプロイ:権限3層

家のLANや tailnet (Tailscale で作る、自分の端末だけが繋がる仮想ネットワーク) の中だけに居る Raspberry Pi に、GitHub Actions から config を配りたくなりました。監視スタック (Prometheus + Alertmanager) のアラートルールを、リポジトリに push したら本番へ反映させたい、という話です。

素直にやろうとすると、だいたいここで詰まります。

制約何が起きるか
Pi にグローバルIPが無いGitHub の runner から直接 SSH できない
Pi の 22番を外に開けたくない開けた瞬間から bruteforce の的になる
CI に NOPASSWD: ALL を渡したくないCI 経由の任意コード実行が、そのまま root になる
常駐プロセスを増やしたくないself-hosted runner も VPN 常時接続も、動かし続けるコストが乗る

4つ目が地味に効きます。1〜3 は「Pi 側から GitHub にポーリングさせる」「self-hosted runner を Pi に置く」で消せますが、どちらも「常に何かが起動している」構成になります。Pi 1台のためにそれを飼うのは重すぎます。

この記事は、その4つを同時に満たす形に落とすまでの設計判断のログです。実測値は載せていません。残したいのは どこで何を選び、何を選ばなかったか のほうです。

全体像

runner を tailnet に「その job の間だけ」参加させます。

GitHub Actions の runner を一時的に tailnet へ参加させ、Pi へ SSH する全体像

流れは4行で書けます。

  1. develop への push (path filter 付き) か、workflow_dispatch で job が起動する
  2. runner の上で config を validate する
  3. runner を tailnet に join させ、Pi へ scp して ssh でコマンドを打つ
  4. job が終わると runner が消え、tailnet からも消える

「消える」が効いています。踏み台ホストを持たないので、踏み台の OS 更新も鍵のローテーションも発生しません。

判断1: self-hosted runner を Pi に置かなかった理由

一番よく見る解法はこれです。Pi に runner を常駐させれば、そもそも外から入る必要がありません。

採らなかった理由は3つあります。

  • systemd の面倒を1つ増やす。runner が落ちたら気づく仕組みが要ります。監視スタックをデプロイするための仕組みが、監視対象になります
  • runner のバージョン追随。GitHub の runner は自動更新しますが、更新に失敗したときに困るのは Pi の上です
  • バージョンずれ。runner イメージに載っている promtool / amtool と、Pi 側の Debian パッケージのそれが同じとは限りません

3つ目は後で validate の話に戻ってきます。

ここで採るのは ephemeral な join です。ephemeral は「その場限りの」という意味で、Tailscale では一度ログアウトしたらネットワークから自動的に消える端末を指します。普通に tailscale up して登録した端末は、電源を切っても管理画面に残り続けます。ephemeral な端末は残りません。CI の runner のように毎回別の使い捨てマシンが出入りする用途のための仕組みで、これを使うと端末一覧が使用済みの runner で埋まっていきません。

この形なら、runner の中身は GitHub 標準の ubuntu-latest のままで済みます。

判断2: 素の WireGuard でなく Tailscale action を使った理由

「一時的に VPN に入る」だけなら WireGuard でも書けます。ただし自前でやると、job ごとに peer 設定を作り、job の終わりに回収する処理が要ります。回収に失敗したときの掃除も自分で書くことになります。

Tailscale 側にはこれが用意されています。

- uses: tailscale/github-action@v4
  with:
    oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
    oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
    tags: tag:ci-observability

OAuth の client id / secret から使い捨ての auth key が発行され、runner が tag 付きの端末として tailnet に登場します。公式ドキュメントは、このアクションが作る端末を Ephemeral として扱い、CI 実行の直後に log out して、Tailscale 側のサーバーから自動削除する、と書いています。

つまり後片付けのコードを自分で書く必要がありません。job が途中で失敗して終わった場合でも、端末は残らずに消えます。

v4 が現行です。 v3 の記述が残っている記事が多いのですが、use-cache (tailscale バイナリのキャッシュ) は v4 では既定で有効になっているので、明示的に書く必要はありません。

到達範囲は ACL 側で閉じます。

{
  "acls": [
    { "action": "accept",
      "src": ["tag:ci-observability"],
      "dst": ["obs-pi-01:22"] }
  ],
  "tagOwners": {
    "tag:ci-observability": ["autogroup:admin"]
  }
}

この tag は CI 以外の誰も持っていません。他の tailnet member にも、Pi の他のポートにも行けません。止めたくなったら、この3行を消せば止まります。kill switch が ACL 1ブロックで済むのは、自前 WireGuard では作りにくい性質でした。

判断3: Tailscale SSH を採らず標準SSHにした理由

tailnet に入ってしまえば、Tailscale SSH という選択肢があります。鍵の配布が要らず、ACL で「誰がどのホストにどのユーザーで入れるか」を書けます。

採らなかったのは、制御の粒度が「接続まで」だからです。Tailscale SSH の ACL ルールが持つのは action / src / dst / users / checkPeriod / acceptEnv で、実行するコマンドを縛るフィールドがありません。

これは私の見立てではありません。Tailscale 自身のドキュメントに書いてあります。authorized_keys を使ってリモートユーザーが実行できるコマンドを制限しているマシンは、Tailscale SSH が向かない状況の例として挙げられています。ベンダーが自分の機能の適用外をここまで書いているのは珍しく、判断材料としては強いほうです。

私が欲しかったのは「誰が・何のコマンドを・どのパスに対して実行したか」を後から追えて、層ごとに独立して切れる状態です。そこは標準SSH + authorized_keys + sudoers のほうが硬いと判断しました。

信頼を3層に割る

結果として、CI から本番の root 権限までの間に3つの関門が直列で並びます。

Tailscale ACL・SSH鍵・narrow sudoers の3層が直列に並ぶ構造

何を許すか切り方
Tailscale ACLtag:ci-observabilityobs-pi-01:22 だけ疎通ACL のブロックを消す
SSH ed25519 鍵deploy ユーザーとしてログインauthorized_keys から1行消す
narrow sudoers列挙したコマンドだけ root で実行Cmnd_Alias から該当行を消す

分けた意味は、1層破られても次で止まることではありません。それは副次的です。本当の狙いは、3つとも別々に revoke できることでした。鍵が漏れたら鍵だけ差し替えられます。CI が暴走したら ACL だけ落とせます。sudoers を広げすぎたと気づいたら、sudoers だけ書き直せます。どれを触っても他の2つを再設定せずに済みます。

SSH 鍵は CI 専用に新しく切って、人間の鍵とは分けました。秘密鍵は GitHub Actions の secret に入れ、job の中で runner のホームに mode 600 で書き出します。runner が消えれば鍵も一緒に消えます。

narrow sudoers で気にしたこと

3層目が一番書くのが面倒で、一番効きます。

Cmnd_Alias OBS_DEPLOY = \
    /usr/bin/install -o root -g root -m 0644 /tmp/observability-*/*.yml /etc/prometheus/*.yml, \
    /usr/bin/install -d -o root -g root -m 0755 /etc/prometheus/rules, \
    /usr/bin/cp -a /etc/prometheus/*.yml /etc/prometheus/*.yml.bak.*, \
    /usr/bin/systemctl reload prometheus-alertmanager, \
    /usr/bin/systemctl restart prometheus-alertmanager, \
    /usr/bin/amtool check-config /etc/prometheus/alertmanager.yml, \
    /usr/bin/promtool check config /etc/prometheus/prometheus.yml

deploy ALL=(root) NOPASSWD: OBS_DEPLOY

気にした点が4つあります。

転送元と転送先をペアで固定する。 install をコマンド名だけで許すと、任意のファイルを任意の場所に root 権限で置けます。引数まで書けばそこは塞がります。

ただしワイルドカードで書いた範囲は、読んだときに思うより広い。 sudoers は引数を1本に連結した文字列として照合するので、* は引数と引数のあいだの空白もパスの / も跨ぎます。/tmp/observability-*/*.yml は、途中に別のオプションと別のパスを挿し込んだ呼び方にも一致します。この一致範囲を手元で確かめた結果と、可変部分を引数なしのラッパーに寄せる書き方はsudoers引数制限の4段階:*は空白を跨ぐに分けて書きました。

rm -rf の対象もパスで縛る。 バックアップの後片付けで rm -rf が要るのですが、これこそ引数を固定しないと意味がありません。/etc/prometheus/rules.bak.* のように、消してよい場所だけを書きます。

書いた後に、Pi の上で実際に通るか確かめる。 visudo -cf は文法しか見ません。パスやオプションの並びが1文字違うと、文法は通るのに実行時に弾かれます。sudo -n /usr/bin/amtool check-config /etc/prometheus/alertmanager.yml を1つずつ Pi 上で打って、パスワードを聞かれずに通ることを確認してから確定させました。「sudoers に書いた」は「sudo できる」の証拠になりません。

validate を2段に分けた理由

runner 側で amtool check-configpromtool check config / check rules を通し、それから転送して、Pi の上でもう一度 amtool check-config を通してから reload します。

同じ検査を2回やるのは冗長に見えますが、実行しているバイナリが違います。runner の Ubuntu に apt で入れた amtool と、Pi の Debian trixie に入っている prometheus-alertmanager (0.28.1+ds-1) の amtool は、同じバージョンになる保証がありません。片方で pass して片方で fail する構成は普通に書けてしまいます。

runner 側の検査は「壊れた config を tailnet に持ち込まない」ための足切りと割り切って、reload 直前の判定は Pi 側に置きました。

もう1つ、runner 側で validate すると決めたことで出てきたコストがあります。

Slack の webhook URL は config に直書きせず、api_url_file でファイル参照にしています。ところが amtool check-config参照先ファイルの実在まで見るので、runner 上でそのまま検査すると Pi にしか無いパスを指していて落ちます。かといって本物の webhook URL を runner に置きたくはありません。

ここは sed で参照先をダミーパスに差し替え、ダミーのファイルを作ってから検査を通しています。runner 側で見たいのは YAML の構造であって、URL の中身ではないので。

壊れたときに戻る仕掛け

apply ステップは、この順で並んでいます。

  1. 既存 config を *.bak.${STAMP} にコピー
  2. staging から /etc/prometheus/install
  3. Pi 側で amtool check-config
  4. systemctl reload、失敗したら restart
  5. curl http://localhost:9093/-/healthy を3秒間隔で6回まで

STAMP は先頭の validate job の output で決めて、全ステップで使い回します。こうすると bak ファイル名と CI の run が1対1で紐づきます。障害対応で「どの run が置いたバックアップか」を探す羽目にならないための細工です。

healthz が返らなければ rollback ステップが発火し、bak から戻して再 reload、再 healthz。戻せても job としては fail のままにしてあります。戻ったことと、直ったことは別なので。

もう1つ、concurrency を入れています。

concurrency:
  group: deploy-observability-${{ github.ref_name }}
  cancel-in-progress: false

cancel-in-progress: false がここでは重要です。true にすると、後発の push が走ったときに前の job が途中で殺されます。「config は差し替わったが reload が終わっていない」状態の Pi が残ります。CI の待ち時間より、中途半端な本番のほうが高くつきます。

外部から叩ける口を残す

workflow_dispatch: {} を足しておくと、push 以外の3経路から発火できます。GitHub UI の Run workflow ボタン、gh workflow run、そして REST API です。

curl -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/OWNER/REPO/actions/workflows/deploy-observability.yml/dispatches \
  -d '{"ref":"develop"}'

curl で叩けるので言語を選びません。Slack の slash command から Lambda 経由で叩く、別の CI から叩く、といった拡張がここに乗ります。

戻り値の仕様が今年変わっています。 長らくこの API は 204 No Content だけを返し、起動した run を特定できませんでした。2026年2月19日から return_run_details という省略可能な boolean が増え、渡すと 200 と一緒に run の ID・API URL・Web URL が返ります。渡さなければ従来どおり 204 のままです。GitHub CLI は v2.87.0 から対応しています。

つまり「dispatch した run を追いたければ gh run list でポーリングする」という定番の回避策は、もう必須ではありません。私はこの記事を書くために調べ直して初めて気づきました。設計メモには「202 が返る、job ID は返らない」と書いてあって、ステータスコードも仕様も両方間違っていたわけです。

inputs を宣言すれば、同じ workflow を別のホストに向けられます。

on:
  workflow_dispatch:
    inputs:
      target_host:
        description: "対象ホスト"
        default: obs-pi-01

ACL と sudoers さえ用意すれば、prod と demo で workflow ファイルを分ける必要はありません。分けると片方だけ古くなるので、分けないほうが安全でした。

まだ確かめていないこと

主経路 (push → validate → join → scp → apply → healthz) は通しましたが、以下は実火させていません。

  • rollback ステップ。意図的に壊れた config を通して healthz を落とす検証はまだです。バックアップの復元は「書いてある」だけで「動いた」ではありません
  • REST API からの外部起動。UI と gh からは起動していますが、curl 経由は未実施です
  • return_run_details。上に書いた仕様は公式の changelog とドキュメントで確認した内容で、私の手元で叩いた結果ではありません

3つとも、確認したら追記します。

この構成が向く条件

一般化するとこうなります。

  • デプロイ対象が tailnet か LAN の中に居て、グローバルIPを持たない
  • 常駐させたくない。踏み台も self-hosted runner も飼いたくない
  • CI が触ってよい操作が 列挙できるくらい少ない

3つ目が効きます。「config を置いて reload するだけ」だからコマンドを10行ちょっとで書き切れました。CI にビルドもマイグレーションも任意スクリプト実行もやらせる構成だと、sudoers を narrow に保つのは無理で、別の隔離 (コンテナなり専用ユーザーなり) を先に考えることになります。

逆に、監視 config・nginx の設定・cron 定義あたりの「置いて reload するだけ」の対象なら、tag と ACL を足してホスト側の sudoers を書くだけで横に広がります。ホスト1台ごとに sudoers を書く手間は残りますが、その手間こそが、この構成で唯一 root に触れる場所を目に見える形に留めている部分でもあります。

条件が外れたときは、方式そのものを変える判断になります。CIから入る代わりに機械側に取りに来させる。debで配って何が入っているか問い合わせられるようにする。A/Bパーティションで焼いて、壊れたら機械に自分で戻らせる。同じ仕事に5つのやり方があって、到達性・ロールバック・常駐物の数で性格が割れます。5方式を7軸で並べた表はRaspberry Pi デプロイ5方式を7軸比較にあります。この記事の構成は、そのうちの push 型にあたります。

参考

この記事で確認に使った一次情報です。設計メモを書いた時点の記憶に頼らず、記事化のときに全部引き直しました。

AIコードレビューを仕組み化する技術 関連書籍 AIコードレビューを仕組み化する技術 AIコードレビュー 自動化 | hooks 設計・CodeRabbit 導入・Conventional Comments・GitHub Actions パイプライン 書籍ページを見る →