何を作ったか
main ブランチにマージすると、社内 VPN の内側にあるテストサーバーへ自動でデプロイされる仕組みを作った。
使ったのは GitHub Actions のセルフホストランナーと、Laravel アプリをその場で更新するシェルスクリプトだけで、外部サービスも追加のサーバーも使っていない。
構築自体は1日で終わったが、素直に作れたわけではない。
このサーバーには本番サイトが同居しており、「デプロイの自動化」よりも「自動化してもランナーが本番に手出しできないこと」の設計に時間を使った。
前提
- テストサーバーは社内 VPN の内側にあり、インターネットから到達できない
- 同じサーバーに本番サイトが同居している(本番
/var/www/app、テスト/var/www/app-dev) - アプリは Laravel。php-fpm の実行ユーザーは
nginx - 従来のデプロイは、作業者が VPN を張って SSH し、手でコマンドを打つ運用だった
方式の選定
CI からVPN 内のサーバーへ届ける方法として、3つの案を比較した。
| 案 | CI から VPN | 不採用の理由 |
|---|---|---|
| クラウドランナーから VPN を張って SSH | 必要 | VPN の証明書と MFA を GitHub Secrets に預けることになる |
| サーバー側に Webhook 受け口を作る | 不要 | 公開エンドポイントが攻撃面になる。本番隣接コードの改修も要る |
| セルフホストランナー | 不要 | 採用 |
セルフホストランナーを選んだ決め手は、通信が外向きだけで完結することにある。
その仕組みを次の節で見る。
GitHub Actions とランナーの役割分担
この構成での GitHub Actions は司令塔であり、手は動かさない。
GitHub はイベントの検知、ジョブの組み立て、ラベルによる配車、ログの記録と可視化、同時実行の直列化を担う。
実作業はすべてサーバー内のランナーが行う。
ランナーはサーバー内に常駐し、GitHub へ「仕事はあるか」と聞きに行くだけで、GitHub 側からサーバーへ入ってくる通信は存在しない。
だからインバウンドのポートを一つも開けず、SSH 鍵や Secrets を GitHub に登録する必要もない。
接続は約50秒つなぎっぱなしの long poll なので、マージから数秒でデプロイが始まる。
ラベル分離はセキュリティ境界ではない
ここからが本題で、本番同居サーバーにランナーを置くときの考え方を整理する。
ワークフローの runs-on にラベルを指定すれば、ジョブは狙ったランナーにだけ載る。
しかしこれは配車のルールであって、防御ではない。
ジョブの中身はワークフロー YAML に書かれた任意のシェルであり、YAML を書き換えて main にマージできる人は、ランナーの OS ユーザー権限でサーバー内の任意コマンドを実行できる。
つまり守りの本体は OS のユーザー権限になる。
「ランナーのユーザーにできないこと」が、そのまま「ワークフローを乗っ取った攻撃者にできないこと」の上限になるからだ。
なお、ワークフローの改変そのものへの対策(レビューの強制や変更の検知)も別途必要になるが、本記事では扱わない。
最小権限の設計
この前提で、ランナー用ユーザーを次のように作った。
sudo useradd -m -s /bin/bash github-runner # sudo グループには入れない
sudo groupadd -f deploy
sudo usermod -aG deploy ubuntu # 管理者の手動フォールバック用
sudo chown -R github-runner:deploy /var/www/app-dev
sudo chmod -R g+rwX /var/www/app-dev
sudo find /var/www/app-dev -type d -exec chmod g+s {} +
設計の要点は4つある。
-
github-runnerは sudo なし。書けるのはテスト環境のディレクトリと自分のホームだけ - 共有グループ
deployに管理者ユーザーも入れておく。ランナーが壊れたときに手動デプロイへ退避できる - ディレクトリに setgid を付け、今後作られるファイルが自動的に
deployグループを継承するようにする。これがないと、ランナーが作るファイルの所有が偏っていき、手動フォールバックが時間とともに壊れる -
git pull用の鍵は新規に作り、GitHub には read-only の Deploy key として登録する。鍵はサーバーの外に出さない
read-only 鍵は登録後に実際に push を試し、拒否されることを確認した。
ERROR: The key you are authenticating with has been marked as read only.
ランナーの導入と常駐化
ランナー本体の導入は、リポジトリの Settings > Actions > Runners > New self-hosted runner に表示される手順に沿えばよい。
画面に表示される登録トークンは1時間ほどで失効する使い捨てなので、取得したらそのまま進める。
# github-runner ユーザーで実行する(config.sh は root だと拒否される)
sudo -iu github-runner
mkdir -p ~/actions-runner && cd ~/actions-runner
curl -o actions-runner-linux-x64-2.335.1.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.335.1/actions-runner-linux-x64-2.335.1.tar.gz
echo "<画面に表示されるSHA256> actions-runner-linux-x64-2.335.1.tar.gz" | sha256sum -c
tar xzf actions-runner-linux-x64-2.335.1.tar.gz
chmod 700 ~/actions-runner
./config.sh --url https://github.com/example/myapp \
--token <登録トークン> --name myapp-dev-01 --labels myapp-dev --unattended
config.sh が成功すると、ランナーの恒久的な認証情報が ~/actions-runner/.credentials に作られる。
以後の GitHub との認証はこのファイルが担うので、Secrets には何も置かない。
常駐化は付属の svc.sh で systemd サービスにする。
ここだけ sudo が要るので、管理者ユーザーに戻って実行する(サービス自体は引数に指定した github-runner として動く)。
exit # sudo が使えるユーザーに戻る
sudo bash -c 'cd /home/github-runner/actions-runner && ./svc.sh install github-runner'
sudo bash -c 'cd /home/github-runner/actions-runner && ./svc.sh start'
起動後、リポジトリの Runners 一覧で Idle(緑)になれば受付開始である。
サーバー側でも journalctl に Listening for Jobs が出る。
導入時のハマりどころを3つ挙げる。
- systemd から起動されるランナーは
~/.bashrcや nvm を読まない。node や php は nvm ではなくシステム全体(/usr/local/binなど)に入れておく - ランナーのディレクトリを 700 にすると管理者ユーザーは cd できない。
svc.shはsudo bash -c 'cd ... && ./svc.sh ...'の形で叩く - Ubuntu の needrestart が対話プロンプトを出すと無人ジョブが固まる。
/etc/needrestart/conf.d/に$nrconf{restart} = 'l';を置いて抑止する
ワークフロー本体
デプロイのワークフローは1ジョブだけの素朴なものである。
name: Deploy Dev
on:
push:
branches: [main]
workflow_dispatch: {} # 手動再デプロイ用
permissions: {} # GITHUB_TOKEN の権限を剥奪(このジョブでは使わない)
concurrency:
group: deploy-dev # 同時デプロイを直列化
cancel-in-progress: false
jobs:
deploy:
runs-on: [self-hosted, linux, x64, myapp-dev]
timeout-minutes: 20
steps:
- name: Deploy in place
working-directory: /var/www/app-dev
run: |
set -euo pipefail
test "$(pwd)" = "/var/www/app-dev"
test "$(git symbolic-ref --short HEAD)" = "main"
BEFORE="$(git rev-parse HEAD)"
rollback() {
echo "::error::デプロイ失敗。${BEFORE} へ戻します。"
git reset --hard "${BEFORE}"; npm run build || true; php artisan up || true
}
trap rollback ERR
php artisan down --retry=15 || true
git pull --ff-only origin main
AFTER="$(git rev-parse HEAD)"
if [ "${BEFORE}" != "${AFTER}" ]; then
CHANGED="$(git diff --name-only "${BEFORE}" "${AFTER}")"
echo "${CHANGED}" | grep -qx 'composer.lock' && composer install --optimize-autoloader --no-interaction || true
echo "${CHANGED}" | grep -qx 'package-lock.json' && npm ci || true
npm run build
fi
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan up
trap - ERR
if php artisan migrate:status 2>/dev/null | grep -qw 'Pending'; then
echo "::warning title=Pending migrations::未適用のマイグレーションがあります。手動で適用してください。"
fi
判断が分かれそうな点だけ補足する。
-
actions/checkoutは使わない。配信対象はサーバー上で稼働しているディレクトリそのものなので、別の場所に clone しても反映されない。in-place のgit pullが正解になる -
git reset --hardではなく--ff-onlyにした。サーバー側に未コミットの差分が紛れていた場合、reset は無言で破棄するが、ff-only なら失敗して気付ける - ビルド中は
artisan downでメンテナンス表示にする。Vite のビルドは出力先をいったん空にするため、ビルド失敗時に「新しい PHP と空のアセット」という壊れた組み合わせを配信する窓が生じる。それを防ぐ - マイグレーションは自動実行しない。本番同居のホストで破壊的操作を無人実行したくないので、未適用の検知と警告に留める
運用初日に踏んだ地雷
稼働開始の数時間後、テスト環境がこのエラーで落ちた。
The stream or file ".../storage/logs/laravel-2026-07-02.log" could not be opened
in append mode: Failed to open stream: Permission denied
テスト環境を chown -R github-runner:deploy したとき、php-fpm(nginx ユーザー)が所有者として書いてきたログファイルの所有権まで奪っていた。
storage に nginx のグループ権限を配り直して復旧したが、それだけでは再発の芽が残る。
Laravel の daily ログは日付ごとに新しいファイルを作り、その権限は作成時に決まる。
setgid が保証するのは新しいファイルのグループ名の継承だけで、Monolog の既定は 0644(グループは読み取りのみ)である。
つまり、その日最初のログをデプロイ側のユーザーが作ると、php-fpm が追記できず同じエラーが再発する。
恒久対策として、ログファイルを 0664 で作らせるよう設定した。
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'permission' => 0664, // これを追加
// ...
],
ディレクトリ側の setgid(グループ名の継承)とファイル側の 0664(グループへの書き込み許可)が揃って、初めて「どちらが先にその日のログを作っても、もう一方が書ける」が成立する。
まとめ
- VPN 内サーバーへの自動デプロイは、セルフホストランナーの外向き long poll で、ポート開放も Secrets 登録もなしに成立する
- 本番同居ホストでは、ラベル分離ではなく OS ユーザー権限が防御の本体になる。sudo なしの専用ユーザー、read-only の Deploy key、setgid 付きの共有グループで固める
-
chown -Rの前に、そのツリーへ書き込む全プロセスのユーザーを洗い出す。ファイル所有者の一覧が書き込み実績になる - 複数ユーザーが書くログには、setgid だけでなくファイル作成時の permission 設定(Monolog の 0664)が要る