一、哲学
2026年3月27日、GitHubアカウントが突然停止された。その朝に気づいたことがある。現代のインフラのほとんどは「承認経済」の上に成り立っている。国家が国籍を承認する。企業がアカウントを承認する。プラットフォームが存在を許可する。承認が取り消されれば、そこに積み上げてきたものごとが消える。
GitHubをやめてPiに移行したが、それだけでは「単一障害点が移動しただけ」だ。Pi1台が壊れれば、デプロイも記録も止まる。問題の本質は承認の有無ではなく、消せる中心が存在することにある。
自治会はその思想の体現だ。非営利で、地縁に根ざし、国家の承認なしに存在する。全国の自治会・地域コミュニティがノードになれば、誰も「閉鎖」を決定できないネットワークが生まれる。そのノード管理を、GitとRaspberry Piだけで実現する。
二、設計
全体構造はシンプルだ。各Piが「自分は生きている」という情報を定期的に書き、primaryノードがそれを統合する。Macはその情報を読んでpush先を動的に決定する。
[各Pi — 毎分]
registry-heartbeat.sh
→ queue/{node-id}.json を書いてpush
[primaryPi — 毎分]
registry-consolidate.sh
→ queue/*.json を読んでnodes.jsonに統合
[Mac]
pi-push.sh {repo}
→ nodes.jsonからprimary hostを解決
→ git push toki@{host}:/repos/{repo}.git
→ post-receive hook → wrangler pages deploy → Cloudflare Pages
設計上の核心は3つある。
- コンフリクトゼロ: queueは1Pi=1ファイル。複数Piが同時に書いても衝突しない
- primary自動昇格: nodes.jsonの更新が5分以上途絶えれば、別ノードがprimaryを引き継ぐ
- 外部依存ゼロ: gitプロトコルとPython3のみ。jq不要、クラウドサービス不要
三、仕様
ファイル構造
registry.git/ ← bare repo(各Pi/Macのclone先)
~/repos/registry/ ← Pi上のworking tree
nodes.json ← primaryが管理する統合ファイル
queue/
tokiring-node-01.json ← 各Piが自分で書く(1Pi=1ファイル)
tokiring-node-02.json
nodes.json スキーマ
{
"primary": "tokiring-node-01",
"updated_at": "2026-03-28T14:21:01Z",
"nodes": [
{
"id": "tokiring-node-01",
"host": "tokiring-node-01.local",
"tunnel": null,
"status": "active",
"primary": true,
"last_seen": "2026-03-28T14:19:16Z",
"repos": ["lp", "qr", "tokiring", "hello-briefing", "corporate"]
}
]
}
フィールド定義
| フィールド | 型 | 説明 |
|---|---|---|
primary | string | 現在のprimary node ID |
updated_at | ISO8601 | consolidate最終実行時刻。5分以上古ければ昇格トリガー |
id | string | ノード識別子。ホスト名推奨 |
host | string | LAN内ホスト名またはIP |
tunnel | string|null | Cloudflare Tunnel等の外部URL。nullならhostを使用 |
status | active|inactive | last_seenが300秒以内ならactive |
repos | string[] | このノードが持つbare repoの一覧 |
四、実装
registry-heartbeat.sh(各Pi、毎分実行)
自ノードの情報を queue/{id}.json に書いてpushする。bare repoの一覧は ~/repos/*.git から自動検出する。
#!/bin/bash
PI_ID="tokiring-node-01"
REGISTRY_DIR="/home/toki/repos/registry"
REPOS=()
for d in /home/toki/repos/*.git; do
name=$(basename "$d" .git)
[ "$name" = "registry" ] && continue
REPOS+=("\"$name\"")
done
REPOS_JSON=$(IFS=,; echo "${REPOS[*]}")
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
cd "$REGISTRY_DIR"
git fetch origin --quiet
git merge --ff-only origin/main --quiet 2>/dev/null \
|| git reset --hard origin/main --quiet
cat > "queue/${PI_ID}.json" << QEOF
{ "id": "${PI_ID}", "host": "tokiring-node-01.local",
"tunnel": null, "status": "active",
"last_seen": "${NOW}", "repos": [${REPOS_JSON}] }
QEOF
git add "queue/${PI_ID}.json"
if ! git diff --staged --quiet; then
git -c user.name="TokiStorage Bot" \
-c user.email="tokistorage1000@gmail.com" \
commit -m "heartbeat: ${PI_ID} ${NOW}"
git push origin main
fi
registry-consolidate.sh(primaryのみ、毎分実行)
nodes.jsonのupdated_atが5分以上古ければ自分がprimaryに昇格し、queue/*.jsonを統合してnodes.jsonを更新する。
# primary判定: updated_atが5分以上古い or 自分がすでにprimary
DIFF=$(( $(date -u +%s) - UPDATED_EPOCH ))
if [ "$CURRENT_PRIMARY" != "$PI_ID" ] && [ "$DIFF" -lt 300 ]; then
exit 0 # 現primaryが生きている → 何もしない
fi
# queue/*.json をPython3で統合 → nodes.json更新 → push
python3 - "$PI_ID" "300" "$REGISTRY_DIR" "$NOW_ISO" << 'PYEOF'
import json, os, sys
from datetime import datetime, timezone
PI_ID, STALE, REG_DIR, NOW_ISO = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
now = datetime.now(timezone.utc)
nodes = []
for fname in sorted(os.listdir(os.path.join(REG_DIR, 'queue'))):
if not fname.endswith('.json'): continue
d = json.load(open(os.path.join(REG_DIR, 'queue', fname)))
age = (now - datetime.fromisoformat(d['last_seen'].replace('Z','+00:00'))).total_seconds()
d['status'] = 'active' if age < STALE else 'inactive'
d['primary'] = (d['id'] == PI_ID)
nodes.append(d)
json.dump({"primary": PI_ID, "updated_at": NOW_ISO, "nodes": nodes},
open(os.path.join(REG_DIR, 'nodes.json'), 'w'), ensure_ascii=False, indent=2)
PYEOF
pi-push.sh(Mac側)
registry cloneを最新化し、activeなprimaryのhostを解決してgit pushを実行する。tunnelが設定されていればtunnelを優先する。
#!/bin/bash
REPO_NAME="${1:?Usage: pi-push.sh <repo-name>}"
REGISTRY_LOCAL="$HOME/work/registry"
git -C "$REGISTRY_LOCAL" fetch origin --quiet
git -C "$REGISTRY_LOCAL" reset --hard origin/main --quiet
PRIMARY_HOST=$(python3 -c "
import json, sys
d = json.load(open('$REGISTRY_LOCAL/nodes.json'))
for n in d.get('nodes',[]):
if n['id'] == d.get('primary') and n.get('status') == 'active':
print(n.get('tunnel') or n['host']); sys.exit(0)
sys.exit(1)")
git push "toki@${PRIMARY_HOST}:/home/toki/repos/${REPO_NAME}.git" HEAD:main
五、成果物
2026年3月28日時点で稼働しているもの:
~/repos/registry.git— bare repo(Pi上)~/repos/registry/— working tree(nodes.json + queue/)~/deploy/registry-heartbeat.sh— 毎分heartbeat~/deploy/registry-consolidate.sh— 毎分consolidate~/bin/pi-push.sh— Mac側スマートpush~/work/registry/— Macのregistryclone- 7リポジトリのbare repo(lp, qr, tokiring, hello-briefing, corporate, newsletter-master, newsletter-vol1)
- 各リポジトリのpost-receiveフック → CF Pagesデプロイ
六、配布方法
新しいPiへの導入はワンライナーで完結する。pi/setup.sh はCloudflare Pagesから直接取得できるため、GitHubもgit cloneも不要だ。
curl -sSL https://tokistorage-tokiring.pages.dev/pi/setup.sh | bash -s -- \ CF_ACCOUNT_ID=xxx \ CF_D1_DATABASE_ID=xxx \ CF_API_TOKEN=xxx \ VAPID_SUBJECT=mailto:your@email.com \ VAPID_PUBLIC_KEY=xxx \ VAPID_PRIVATE_KEY=xxx
setup.shが行うこと:Node.jsのインストール確認、静的ファイルのダウンロード、daemon.jsのインストール、wranglerのインストール、.envの書き込み、crontabへの登録。
将来的には、このsetup.shがregistryへの自動登録も行う。USBに差すだけでノードがネットワークに参加し、pi-push.shが自動でそのノードを認識する。
七、導入手順
新規Pi(2台目以降)をregistryに追加する手順
# 1. setup.sh を実行(上記ワンライナー)
# 2. 既存Pi(primary)からregistryをclone
git clone toki@tokiring-node-01.local:/home/toki/repos/registry.git \
~/repos/registry
# 3. heartbeat/consolidate スクリプトを配置
curl -sSL https://tokistorage-tokiring.pages.dev/pi/registry-heartbeat.sh \
-o ~/deploy/registry-heartbeat.sh
chmod +x ~/deploy/registry-heartbeat.sh
# 4. crontab に追加
( crontab -l 2>/dev/null
echo "* * * * * bash ~/deploy/registry-heartbeat.sh >> ~/logs/registry-heartbeat.log 2>&1"
echo "* * * * * bash ~/deploy/registry-consolidate.sh >> ~/logs/registry-consolidate.log 2>&1"
) | crontab -
# 5. 動作確認(1分後)
tail -f ~/logs/registry-heartbeat.log
Macのpush先を切り替える手順
# registryのcloneが古ければ手動更新 git -C ~/work/registry fetch origin && git -C ~/work/registry reset --hard origin/main # 通常のpushはpi-push.shを使う ~/bin/pi-push.sh lp # lpリポジトリをprimaryにデプロイ ~/bin/pi-push.sh tokiring
八、留意点
- LAN外からのpushはできない: 現時点ではhostがローカルアドレス。外出先からはCloudflare TunnelでSSHポートを公開し、tunnelフィールドに登録することで解決する
- primaryの切り替えには最大5分かかる: 瞬断では昇格しない設計。頻繁な電源断が想定される環境では閾値を短くする
- SSDへの移行を推奨: SDカードは書き込み耐久性が低い。heartbeat/consolidateが毎分gitコミットを行うため、SDカードでは長期運用で障害リスクが高まる
- registry自体の消失リスク: registry.gitはPiにしか存在しない。Macにcloneがあるため再構築は可能だが、将来的には複数Pi間でregistryもミラーリングする
- SSH鍵の管理: 現在はMacの公開鍵をauthorized_keysに登録。Pi追加時は鍵の配布手順を自動化する
九、教訓と啓示
GitHubに消された日から、ここまで来るのに1日もかからなかった。プラットフォームを失った瞬間に次の構造が見えたのは、思想が先にあったからだ。インフラは思想の結晶であり、思想のないインフラは脆い。
技術的な教訓は一つに集約される。プロトコルはプラットフォームより長生きする。gitはHTTPやSSHの上で動く仕様だ。GitHubが消えてもgitは消えない。TCP/IPが存在する限り、Piはどこかのサーバーと通信できる。
自治会という組織が承認の外側にある最後の拠り所であるように、gitというプロトコルはサービスの外側にある最後のインフラだ。どちらも「消せる中心」を持たない。
消せる中心を作らない。
プロトコルはプラットフォームより長生きする。
このシステムが伝えたい啓示は一つだ。インフラを所有することは、承認を必要としないことだ。Raspberry Piは3,000円のmicroSD一枚で誰でも持てるインフラになる。それが1台でも、100台でも、設計は変わらない。消せる中心が存在しないから、誰も止められない。
承認なしで、存在できる。
それはコードの話ではなく、インフラの設計思想だ。