0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

何を作ったか

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(緑)になれば受付開始である。
サーバー側でも journalctlListening for Jobs が出る。

導入時のハマりどころを3つ挙げる。

  • systemd から起動されるランナーは ~/.bashrc や nvm を読まない。node や php は nvm ではなくシステム全体(/usr/local/bin など)に入れておく
  • ランナーのディレクトリを 700 にすると管理者ユーザーは cd できない。svc.shsudo 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 で作らせるよう設定した。

config/logging.php
'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)が要る
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?