3-6. デプロイ - rsync / GitHub Actions / systemd / ロールバック設計

所要時間: 50-60分(Level 3 の総合プロジェクト的位置付け、2-3セッション) ゴール: 「ローカルで動くもの」を「世界に公開されている本番環境」に安全かつ繰り返し可能に届けられる。失敗時のロールバック手段を持っている コミット内容: GitHub Actions 経由で VPS にデプロイする最小構成を ~/learn/linux/day18

この章が終わるとできること

  • systemd + current symlink でアトミックな切替が組める
  • rsync -avz --delete差分のみ転送 できる
  • GitHub Actions で SSH 鍵を Secrets 経由で使う手順が書ける
  • ブルー/グリーン / ローリング / カナリア の違いを即答できる
  • graceful shutdown を Go で実装できる
  • DB マイグレーションの Expand and Contract パターンを説明できる
  • 失敗時に 5分以内にロールバック できる

Linux 章全体とのつながり(最終章)

  • Day 1-7(ファイル・テキスト・パイプ)── デプロイスクリプトの基礎
  • Day 8-14(シェル・systemd・cron・logs)── 常駐・定期・観測の柱
  • Day 15-19(SSH・ユーザー・Nginx・FW・監視)── ネットワーク越しの本番運用
  • 今日(Day 20) ── 上記すべてを束ねる総合演習

これができると何が嬉しいか

  • 金曜の夕方にデプロイしても怖くない」状態を作れる
  • 障害時、5分以内にロールバック ができる組織を作れる
  • 新規プロジェクトでデプロイ環境を ゼロから3時間 で組める

大前提: デプロイは「最も事故が起きるイベント」

統計上、本番障害の原因の 過半数がデプロイに起因します(Google SRE 本などで繰り返し言及)。

なぜか:

  • 未テストの変更が初めて本番環境と接する瞬間
  • 環境差分が露呈する(本番にしかない設定、データ量、外部API)
  • 複数コンポーネントの整合性が問われる(アプリ + DB + キャッシュ + 設定)
  • 時間圧力下で実施されがち(金曜の17時、リリース日固定、緊急 hotfix)

「デプロイが怖い」状態は不健全。理想は 「1日に何十回デプロイしても怖くない」状態を作ること。これを実現する3つの原則:

  1. 自動化: 人間が SSH してコマンドを叩かない。同じ手順を機械が毎回実行する
  2. 可逆性: 失敗したら5分以内に元に戻せる(ロールバック)
  3. 段階的展開: 1台 → 数台 → 全台 と段階を踏み、早期に異常検知

これを満たすデプロイ設計は、新規プロジェクトでも数日で組めます。今日はその設計と実装パターンを身に着けます。

これは Linux 章の最後のレッスン。SSH / ユーザー / Nginx / FW / 監視のすべてを束ねた総合演習です。Week 12 の総合プロジェクトでも、ここで学ぶデプロイパターンが土台になります。


セッション①: デプロイパターンと最小構成(25-30分)

0. 録画スタート

mkdir -p ~/log ~/learn/linux/day18
cd ~/learn/linux/day18
script ~/log/linux_day18.log

1. デプロイの代表パターン

デプロイ手法の進化と現代の選択肢

パターン仕組み規模学習コスト
手動 (SCP/SSH)ローカルから手で送る個人低(だが事故率高)
rsync + systemd差分転送 + サービス再起動小〜中
CI/CD (GitHub Actions等)リポジトリpushで自動デプロイ小〜大
コンテナ (Docker)イメージ化して配布中〜大
オーケストレーション (k8s)ローリング、宣言的大規模
PaaS (Heroku, Render, Fly.io)プラットフォーム任せ任意
サーバーレス (Lambda, Cloud Run)関数 or コンテナ単位任意低〜中

選び方の指針:

  • 個人プロジェクト: PaaS or rsync + systemd(学習用ならrsyncで仕組みを理解)
  • スタートアップ: PaaS or Docker + Fly.io / Render
  • 中規模: Docker + ECS / k8s
  • 大規模: k8s + GitOps(ArgoCD など)

今日学ぶのは「rsync + systemd + GitHub Actions」: 一番「中身が見える」構成。これを理解すると、他のどの方法もブラックボックスにならない。

1-A. rsync vs scp vs git pull の使い分け

ざっくり言うと

「ローカルからサーバーにファイルを届ける」3つの代表手段。用途も性質も全然違う

  • scp = 単純コピー(毎回フル転送)
  • rsync = 差分転送(変わったぶんだけ送る)
  • git pull = サーバー側で取得(リポジトリと同期)

まず素朴な「scp -r で送る」パターン

scp -r ./app deploy@prod:/var/www/
ローカル                    サーバー
[app/ 1000ファイル]   ───→ [app/ 1000ファイル全部上書き]
                  全部送る
この方式の問題発生フロー
1万ファイルのプロジェクトを scp -r
   ↓
1ファイルずつ SSH 接続 open/close でオーバーヘッド
   ↓
30分かかる
   ↓
途中で切れたら最初から
   ↓
差分が変わるたびに毎回フル転送

rsync: 「差分だけ送る」

rsync -avzP ./app/ deploy@prod:/var/www/app/
#       -a: archive(権限・タイムスタンプ保持)
#       -v: verbose
#       -z: 圧縮
#       -P: 進捗表示 + 途中再開
ローカル                    サーバー
[app/ 1000ファイル]         [app/ 1000ファイル]
                チェックサム比較
                  ↓
              [変わった3ファイルだけ送信]

イメージ: 「変わった荷物だけ宅配」。30 分が 30 秒になる。

rsync が天才な理由

Andrew Tridgell(Samba 作者)が考案したローリングチェックサムで、巨大ファイルの中で「どこが変わったか」をブロック単位で判別する。1996 年から 20 年以上使われている技術。

git pull: 「サーバー側で git fetch して取得」

# サーバーで
ssh deploy@prod
cd /var/www/app
git pull origin main
[GitHub] ──→ [サーバー]
        git pull
ローカルを経由しない

イメージ: 「サーバーが直接ダウンロード」。ローカルの状態に依存しない。

git pull デプロイの問題発生フロー
git pull で本番更新
   ↓
.env や log/ など本番固有の変更ファイルがあると merge conflict
   ↓
あるいは git reset --hard で強制更新 → 本番固有ファイル消失
   ↓
ビルド成果物(バイナリ)は別途必要 → サーバーで go build
   ↓
本番サーバーに開発ツールチェーンが必要
   ↓
セキュリティと再現性が壊れる

本番に git をクローンして pull で更新する設計は推奨されない。理由は「ローカルでビルドした成果物を送る方が再現性高い」「本番サーバーに git も Go コンパイラも要らない」から。

対比表

scprsyncgit pull
転送方式フルコピー差分のみgit の差分
中断時最初からやり直し続きから再開自動再試行
大量小ファイル遅い圧倒的に速い速い
進捗表示なし(古いscp)ありあり
フィルタなし--exclude で除外.gitignore
プロトコルSSHSSHHTTPS/SSH
ビルド成果物送れる送れる送れない(リポジトリ外)
本番に必要なものsshdsshdgit + ビルドツール
用途単発の小ファイルデプロイの定番「サーバー上で展開」

一番覚えやすい説明

  • scp = 「毎回フル転送」(小ファイル単発用)
  • rsync = 「差分転送 + 再開可能」(デプロイの定番)
  • git pull = 「サーバーで取得」(推奨されない、ビルド成果物が別途必要)

OpenSSH 9.0 (2022) から scp は内部的に SFTP を使うように変わり、さらに「scp はもう非推奨、rsync か sftp を使え」と OpenSSH プロジェクトが明言している。

結論: 単発の小ファイル以外は全部 rsync


2. systemd で Go アプリをサービス化

ビルドしたバイナリを ./myapp & でバックグラウンド起動するのは、本番では絶対に NG です。自動再起動・ログ統合・依存関係解決・セキュリティ強化を全部やってくれる systemd にサービスとして登録します。

  • 何のコマンドか: /etc/systemd/system/myapp.service というユニットファイルにアプリの起動方法・実行ユーザー・再起動ポリシーを宣言的に記述する
  • いつ使うか: Go / Node / Python のアプリを本番に乗せる時、サーバー再起動時に自動起動させる時、Restart=on-failure でクラッシュ自動復旧させる時
  • 解決する具体的な問題: 「nohup ./app & で起動 → ログがどこ?落ちたら誰が再起動?再起動したら自動起動する?」という全部の宿題を、ユニットファイル1枚で解決する
# /etc/systemd/system/myapp.service
[Unit]
Description=My Go Application
After=network.target
 
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/myapp/current
ExecStart=/var/www/myapp/current/bin/myapp
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
 
# 環境変数(.env から)
EnvironmentFile=/var/www/myapp/shared/.env
 
# セキュリティ強化
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
 
# graceful shutdown
KillSignal=SIGTERM
TimeoutStopSec=30
 
[Install]
WantedBy=multi-user.target
# 反映
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
sudo systemctl status myapp
 
# ログ
sudo journalctl -u myapp -f
 
# 設定リロード(コードを入れ替えた後)
sudo systemctl restart myapp     # 完全再起動
sudo systemctl reload myapp      # SIGHUP を送る(アプリ側で対応していれば設定だけ再読み込み)

systemd の役割

Linux の init システム(プロセス番号1のプロセス)。すべてのサービスの起動・監視・依存関係を管理する。

昔の方式 (SysVinit, Upstart):

  • シェルスクリプトで起動/停止を実装(/etc/init.d/myapp
  • 依存関係は手作業
  • 並列起動できず遅い

systemd の革新:

  • 宣言的: ユニットファイルで「何が必要か」を書くだけ
  • 依存関係解決: After=network.target で順序制御
  • 並列起動: 起動時間が劇的に短縮
  • プロセス監視: 落ちたら自動再起動(Restart=on-failure
  • リソース制限: cgroups 経由で CPU/メモリ制限
  • ログ統合: journal に自動的に集まる

賛否両論あるが、現代の Linux ディストリ(systemd 採用: Ubuntu, Debian, RHEL, Arch…)でデファクト。

systemd unit を本番用に強化する各項目の「なぜ必要か」

ざっくり言うと

ユニットファイルには「本番なら必ず入れる」項目がいくつかある。それぞれ過去に起きた事故を防ぐために存在する。

物語1: User= がないと root で動いてしまう
# NG: User= を書かない
[Service]
ExecStart=/var/www/myapp/bin/myapp
ExecStart は systemd 自身が起動する
   ↓
systemd は root で動いている
   ↓
明示しないとアプリも root で動く
   ↓
アプリの脆弱性で侵入されたら、即 root 権限

イメージ: 「正面玄関を開けっぱなしで店番している」

# OK
User=deploy
Group=deploy

これで「アプリが乗っ取られても deploy ユーザーの権限まで」に限定される。

物語2: Restart= がないと落ちたら起き上がらない
# NG: Restart= 指定なし(デフォルトは no)
ExecStart=/var/www/myapp/bin/myapp
深夜2時にアプリがクラッシュ
   ↓
systemd は何もしない
   ↓
朝までサービス停止
   ↓
ユーザー「サイト落ちてる」と Twitter で炎上
# OK
Restart=on-failure
RestartSec=5s

イメージ: 「自動で起き上がるロボット」。倒れても 5 秒後に立ち上がる。

物語3: LimitNOFILE= がないとファイルディスクリプタ枯渇で死ぬ
# NG: LimitNOFILE= 指定なし(systemd デフォルトは 1024)
ExecStart=/var/www/myapp/bin/myapp
アプリが多数の接続を受ける
   ↓
1024 個目で "too many open files" エラー
   ↓
新規接続を受け付けられない
   ↓
ユーザーから見ると「サーバー応答しない」
# OK
LimitNOFILE=65535

イメージ: 「店の駐車場を 10 台から 1000 台に拡張」

物語4: TimeoutStopSec= がないと終了処理が途中で殺される
# NG: TimeoutStopSec 指定なし
KillSignal=SIGTERM
systemctl restart myapp
   ↓
systemd が SIGTERM を送信
   ↓
アプリが進行中リクエストを処理中
   ↓
デフォルトの 90 秒経過
   ↓
SIGKILL で強制終了 → トランザクション宙ぶらりん
# OK
KillSignal=SIGTERM
TimeoutStopSec=30

graceful shutdown と組み合わせて「30 秒以内に進行中処理を完了させる」契約。

物語5: NoNewPrivileges= で sudo を物理的に塞ぐ
# 標準
ExecStart=/var/www/myapp/bin/myapp
アプリの脆弱性で攻撃者がコード実行
   ↓
攻撃者が sudo / setuid バイナリを試行
   ↓
権限昇格成功 → root 化
# OK
NoNewPrivileges=true

これで「アプリプロセスとその子孫は二度と特権を取れない」設定。setuid バイナリ(passwd, sudo 等)も無効化される。

物語6: ProtectSystem= / PrivateTmp= でファイルシステムを守る
攻撃者がアプリ経由で /etc/passwd を書き換えようとする
   ↓
ProtectSystem=full なら /usr /etc が読み取り専用に
   ↓
書き込み失敗で攻撃停止
NoNewPrivileges=true
PrivateTmp=true        # /tmp が他プロセスから隔離
ProtectSystem=full     # /usr /boot /etc が ro
ProtectHome=read-only  # /home が ro
対比表: 各項目の役割
項目何を防ぐ設定例
User=root で動く事故User=deploy
Restart=クラッシュ後の長時間停止Restart=on-failure
LimitNOFILE=FD 枯渇LimitNOFILE=65535
KillSignal= + TimeoutStopSec=終了処理途中での強制終了SIGTERM + 30
NoNewPrivileges=侵入後の権限昇格true
PrivateTmp=/tmp 経由の情報漏洩true
ProtectSystem=システムファイル書き換えfull
ProtectHome=ホームディレクトリ覗き見read-only
EnvironmentFile=シークレットのコード混入/var/www/myapp/shared/.env
一番覚えやすい説明
  • User = アプリの権限を絞る
  • Restart = クラッシュ自動復旧
  • LimitNOFILE = 接続数枠を広げる
  • TimeoutStopSec = graceful shutdown の時間枠
  • NoNewPrivileges / Protect* = 侵入時の被害最小化

これらは全部「過去の事故から生まれた防衛機構」。1 項目ずつ「これがないと何が起きるか」を物語で覚えると、忘れない。

ユニットファイルの主要項目一覧

項目意味
Description人間用の説明
After / Before依存関係(順序)
Requires / Wants必要条件(強い/弱い)
Type=simple/forking/notify/oneshotプロセスの起動方式
User / Group実行ユーザー(root以外推奨)
WorkingDirectoryカレントディレクトリ
ExecStart起動コマンド
ExecReloadreload 時のコマンド
Restartno/on-failure/always
EnvironmentFile環境変数ファイル
KillSignal停止時のシグナル(デフォルト SIGTERM)
TimeoutStopSec強制停止までの待機時間
NoNewPrivilegessudoを使えない(セキュリティ)
ProtectSystemファイルシステム保護
LimitNOFILEファイルディスクリプタ上限

3. 「動く構成」の最小: GitHub → rsync → systemd restart

systemd でサービス化したら、次は コードを送り込む仕組み です。Capistrano 由来の「symlink で切り替える」パターンが、シンプルさと即時ロールバックを両立する定番設計です。

  • 何のコマンドか: releases/YYYYMMDD-HHMMSS/ にバイナリを置き、current シンボリックリンクで「今動かしているリリース」を指す設計と、それを実装するシェルスクリプト
  • いつ使うか: 小〜中規模プロジェクトで Docker までは要らない時、rsync で差分転送する高速デプロイを組む時、過去5世代を残して即時ロールバックを担保したい時
  • 解決する具体的な問題: 「上書きデプロイ中に中途半端な状態でリクエストを受ける」事故、「前のバージョンに戻したいが、もう消した」事故を、symlink 1本で同時に解決する

ディレクトリ設計(Capistrano 風):

/var/www/myapp/
├── current -> releases/20260514-123456    # symlink(現在のバージョン)
├── releases/
│   ├── 20260514-100000/                   # 過去のリリース
│   ├── 20260514-110000/
│   └── 20260514-123456/                   # 最新
└── shared/
    ├── .env                               # 環境ごと固有、リリースをまたいで永続
    ├── log/
    └── data/
# サーバー側のディレクトリ準備(初回のみ)
ssh deploy@prod-web
sudo mkdir -p /var/www/myapp/{releases,shared}
sudo chown -R deploy:deploy /var/www/myapp
mkdir -p /var/www/myapp/shared/{log,data}
touch /var/www/myapp/shared/.env       # 後で本番用設定を入れる
chmod 600 /var/www/myapp/shared/.env
# ローカル → サーバーのデプロイスクリプト(deploy.sh)
cat > deploy.sh <<'EOF'
#!/bin/bash
set -euo pipefail
 
APP_NAME=myapp
HOST=deploy@prod-web
REMOTE_BASE=/var/www/${APP_NAME}
RELEASE=$(date +%Y%m%d-%H%M%S)
REMOTE_RELEASE=${REMOTE_BASE}/releases/${RELEASE}
 
echo "==> ビルド"
GOOS=linux GOARCH=amd64 go build -o bin/myapp ./cmd/myapp
 
echo "==> リリースディレクトリ作成"
ssh ${HOST} "mkdir -p ${REMOTE_RELEASE}/bin"
 
echo "==> rsync でアップロード"
rsync -avz --delete \
    bin/myapp \
    ${HOST}:${REMOTE_RELEASE}/bin/
 
# 共有ディレクトリへの symlink を作る
ssh ${HOST} "ln -nfs ${REMOTE_BASE}/shared/.env ${REMOTE_RELEASE}/.env"
 
echo "==> current symlink を切替"
ssh ${HOST} "ln -nfs ${REMOTE_RELEASE} ${REMOTE_BASE}/current"
 
echo "==> systemd restart"
ssh ${HOST} "sudo systemctl restart ${APP_NAME}"
 
echo "==> ヘルスチェック"
sleep 3
HEALTH=$(curl -fsS http://prod-web/health || echo "FAIL")
if [ "$HEALTH" = "OK" ]; then
    echo "==> デプロイ成功"
else
    echo "==> ヘルスチェック失敗、ロールバックします"
    PREV=$(ssh ${HOST} "ls -1t ${REMOTE_BASE}/releases | sed -n '2p'")
    ssh ${HOST} "ln -nfs ${REMOTE_BASE}/releases/${PREV} ${REMOTE_BASE}/current"
    ssh ${HOST} "sudo systemctl restart ${APP_NAME}"
    exit 1
fi
 
echo "==> 古いリリースを削除(直近5個だけ残す)"
ssh ${HOST} "cd ${REMOTE_BASE}/releases && ls -1t | tail -n +6 | xargs -r rm -rf"
 
echo "==> 完了: ${RELEASE}"
EOF
 
chmod +x deploy.sh

symlink を current にする設計の意味

current は最新リリースへのシンボリックリンク。これによって:

  • アトミックな切替: ln -nfs new_release current は瞬時。中途半端な状態が存在しない
  • 即時ロールバック: ln -nfs old_release current で前バージョンに戻る(秒単位)
  • 複数バージョンを保持: 過去5世代を releases/ に残しておき、いつでも戻れる

Ruby on Rails の Capistrano が広めたパターン。Laravel の Envoyer、Node の PM2 deploy、Python の Fabric 等、多くのデプロイツールがこの構造を採用。

ln -nfs のフラグ:

  • -n: ターゲットがディレクトリの場合、参照先のディレクトリの中に作るのを防ぐ(事故防止に必須
  • -f: 既存のリンクを上書き
  • -s: シンボリックリンク

4. GitHub Actions で自動化

deploy.sh を手元から叩く運用は、人為ミスと属人化の温床です。main ブランチへの push を機械が拾って自動でデプロイする仕組みに昇格させます。

  • 何のコマンドか: .github/workflows/deploy.yml で「テスト → ビルド → SSH → rsync → systemd restart → ヘルスチェック → 失敗時ロールバック」を YAML で宣言する
  • いつ使うか: チーム開発で誰でも安全にデプロイできるようにする時、main への merge を本番反映に直結させる時、environment: production で承認フローを入れる時
  • 解決する具体的な問題: 「特定の人しかデプロイできない」「手順書通りやらない人がいる」「ロールバックを忘れる」を、自動化と YAML 化で根本解決する
# .github/workflows/deploy.yml
name: Deploy to Production
 
on:
  push:
    branches: [main]
  workflow_dispatch:  # 手動実行も可能に
 
concurrency:
  group: production-deploy
  cancel-in-progress: false   # 重複実行を許さない
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
      - name: Test
        run: go test ./...
      - name: Build
        run: GOOS=linux GOARCH=amd64 go build -o bin/myapp ./cmd/myapp
      - uses: actions/upload-artifact@v4
        with:
          name: binary
          path: bin/myapp
 
  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production   # 環境ごとの承認・シークレット管理
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: binary
          path: bin/
 
      - name: Setup SSH
        env:
          SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_HOST: ${{ secrets.SSH_HOST }}
        run: |
          mkdir -p ~/.ssh
          echo "$SSH_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
 
      - name: Deploy
        env:
          HOST: deploy@${{ secrets.SSH_HOST }}
        run: |
          chmod +x bin/myapp
          RELEASE=$(date +%Y%m%d-%H%M%S)
          REMOTE=/var/www/myapp/releases/$RELEASE
          ssh $HOST "mkdir -p $REMOTE/bin"
          rsync -avz bin/myapp $HOST:$REMOTE/bin/
          ssh $HOST "ln -nfs /var/www/myapp/shared/.env $REMOTE/.env"
          ssh $HOST "ln -nfs $REMOTE /var/www/myapp/current"
          ssh $HOST "sudo systemctl restart myapp"
 
      - name: Health check
        env:
          URL: https://${{ secrets.DOMAIN }}/health
        run: |
          for i in {1..10}; do
            if curl -fsS $URL; then
              echo "Health check passed"
              exit 0
            fi
            sleep 3
          done
          echo "Health check failed"
          exit 1
 
      - name: Rollback on failure
        if: failure()
        env:
          HOST: deploy@${{ secrets.SSH_HOST }}
        run: |
          PREV=$(ssh $HOST "ls -1t /var/www/myapp/releases | sed -n '2p'")
          ssh $HOST "ln -nfs /var/www/myapp/releases/$PREV /var/www/myapp/current"
          ssh $HOST "sudo systemctl restart myapp"

GitHub Actions のポイント

environment: production: GitHub の Environments 機能。

  • 承認者を必須化できる(プロテクション)
  • 環境ごとに別のシークレットを持てる
  • 直近の deploy 履歴が UI で見える

concurrency: 同時実行制御。

  • 連続push で同時に2つのデプロイが走るのを防ぐ
  • 古い実行をキャンセル or 完了まで待機

シークレット管理: ${{ secrets.XXX }} で参照。

  • リポジトリのSettings > Secrets and variables で設定
  • 絶対にコードに直書きしない
  • GitHub Actions ログにシークレットは自動マスクされる(出力されても ***

5. 環境変数とシークレット投入のタイミング

シークレットをどこで管理するか

方法場所用途
リポジトリ直書きコード絶対NG
.env ファイル + gitignore本番サーバー小規模OK
GitHub Actions SecretsCIデプロイ時に注入
クラウドのシークレットマネージャAWS Secrets Manager 等中〜大規模
HashiCorp Vault専用サーバーエンタープライズ

「いつ」秘密が注入されるかが重要:

パターンA: ビルド時注入

  • GitHub Actions で .env を生成 → サーバーへ rsync
  • シンプル、シークレットが Actions のログに出るリスクあり

パターンB: 実行時注入(推奨)

  • サーバーの /var/www/myapp/shared/.env を手で配置(または専用の安全な手段で)
  • systemd の EnvironmentFile で読み込む
  • デプロイ時にシークレットが流れない

パターンC: シークレットマネージャから動的取得

  • アプリ起動時に AWS Secrets Manager / Vault から取得
  • ローテーションが容易
  • シークレットがディスクに残らない
# 本番サーバーの .env の中身(例)
DATABASE_URL=postgres://user:pass@db.internal:5432/myapp
REDIS_URL=redis://redis.internal:6379
JWT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
LOG_LEVEL=info
 
# パーミッション必須
chmod 600 /var/www/myapp/shared/.env
chown deploy:deploy /var/www/myapp/shared/.env

セッション②: 高度なパターンとロールバック設計(25-30分)

6. graceful shutdown と ゼロダウンタイム

デプロイのたびにユーザーが 502 を見る運用は、本番品質ではありません。進行中のリクエストを最後まで処理してから停止する graceful shutdown と、LB 連携によるゼロダウンタイムを設計します。

  • 何のコマンドか: SIGTERM を受け取ったアプリが「新規受付停止 → 進行中処理を完了 → close → exit」する流れを、Go なら server.Shutdown(ctx)、systemd なら KillSignal=SIGTERM + TimeoutStopSec=30 で実現する
  • いつ使うか: 本番への通常デプロイ、ローリングアップデート時、k8s の Pod 終了時、メンテナンスで意図的にプロセスを止める時
  • 解決する具体的な問題: 「再起動した瞬間 502 が出る」「途中で殺されたトランザクションが宙ぶらりん」「外部APIへのリクエストが中途半端」を、シグナルハンドラと timeout 設計で根絶する

graceful shutdown の意味

サービスを停止する時、進行中のリクエストを最後まで処理してからプロセス終了すること。途中で殺すと:

  • ユーザー側で 502 / 500 が見える
  • DBのトランザクションが中途半端で残る
  • 外部APIへのリクエストが宙ぶらりん

実装パターン(Go の例):

server := &http.Server{Addr: ":8080"}
go func() {
    if err := server.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatalf("listen: %s", err)
    }
}()
 
// SIGTERM を受け取って graceful shutdown
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
<-stop
log.Println("shutting down...")
 
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
    log.Fatalf("shutdown: %s", err)
}

systemd 側:

KillSignal=SIGTERM
TimeoutStopSec=30      # 30秒以内に終わらなければ SIGKILL

ロードバランサ連携でゼロダウンタイム

単一インスタンスでは「graceful でも、その瞬間 LB に新規リクエストが来たら困る」問題。

解決パターン:

  1. LB のヘルスチェック失敗: アプリ側で /health を 503 にする → LB が外す
  2. 少し待つ: 30秒 sleep(LBのチェック間隔 + 余裕)
  3. アプリを停止: 進行中のリクエストを処理して終了
  4. 新バージョン起動
  5. /health を 200 にする: LB が戻す

複数インスタンスがあるなら、1台ずつローリングで切替。

7. Blue-Green vs Rolling vs Canary の使い分け

ざっくり言うと

新バージョンを本番に出す方式は3つの主要パターンがあり、リスクの取り方とコストが違う。

  • Blue-Green = 2セット用意して LB で切替(即時・コスト2倍)
  • Rolling = 1台ずつ順番に置き換え(中庸・k8s 標準)
  • Canary = ごく一部のユーザーだけ先行(最小リスク・実装複雑)

まず素朴な「全台一斉再起動」のパターン

[v1] [v1] [v1]
   ↓ 全部止めて新バージョンに
[v2] [v2] [v2]

この方式の問題:

全台停止
   ↓
切替の数十秒間、全ユーザーが 502
   ↓
v2 にバグ → 全ユーザー影響 → ロールバックも全停止
   ↓
本番障害確定

これを解決する3つの戦略を見ていく。

Blue-Green: 「2セット用意して LB を切り替えるだけ」

[初期]
  LB ──→ Blue (v1)
        Green (待機)

[Green に v2 をデプロイ]
  LB ──→ Blue (v1)        ← 本番継続
        Green (v2)        ← スモークテスト

[切替: LB のターゲット変更]
  LB ──→ Green (v2)
        Blue (v1, アイドル) ← ロールバック用に残す

[問題あれば LB を Blue に戻す]
  LB ──→ Blue (v1)        ← 秒で戻る

イメージ: 2 台のステージ。次の演目を裏で準備して、カーテンを引いて舞台ごと切り替える。

Rolling: 「1台ずつ置き換え」

[初期: 3台すべて v1]
  LB ──→ app1(v1), app2(v1), app3(v1)

[app1 を v2 に]
  LB ──→ app1(v2), app2(v1), app3(v1)   ← 1/3 のリクエストが v2

[app2 も v2 に]
  LB ──→ app1(v2), app2(v2), app3(v1)

[全台 v2 完了]
  LB ──→ app1(v2), app2(v2), app3(v2)

イメージ: 回転寿司レーンの皿を1枚ずつ取り替える

k8s の kubectl rollout がまさにこれ。

Canary: 「炭鉱のカナリア」で先行検知

[v2 を 5% のトラフィックだけに送る]
  LB ──95%──→ v1
       └─5%──→ v2     ← ここで何か起きても影響は5%だけ

[5分後、エラー率正常なら 25% に拡大]
  LB ──75%──→ v1
       └─25%──→ v2

[徐々に 100% へ]

由来: 炭鉱でカナリアを先に行かせて、ガス漏れをカナリアの死で検知した故事。「少数のユーザーで先に試して被害を限定」が思想。

イメージ: 新しいお弁当を、まず社員食堂で 5 人に試食してもらってから全社販売

対比表

Blue-GreenRollingCanary
インフラコスト2倍(2セット)1倍(同じ台数で順送り)1倍〜1.1倍
切替速度秒(LB変更)数分〜(台数分)数十分〜数時間(段階的)
ロールバック秒(LBを戻す)数分(逆方向ローリング)トラフィック比率を戻す
障害影響範囲全ユーザー(切替時)一部のユーザーごく一部(5% から)
v1/v2 同時存在あり(切替後アイドル)あり(過渡期)あり(長時間)
実装難易度中(LB操作)中(ヘルスチェック必須)高(Service Mesh等)
適したサービス規模中〜大(資金あり)中〜大(k8s 標準)大(高可用性要求)

切替時のフロー比較

[Blue-Green]
  ─────全ユーザー v1─────│─────全ユーザー v2─────
                          ↑ 切替の瞬間

[Rolling]
  ─────全ユーザー v1─────│─v1+v2混在─│─全ユーザー v2─
                          段階的に置換

[Canary]
  ─────全ユーザー v1─────│─5%─│─25%─│─50%─│─100% v2─
                          長時間段階リリース

一番覚えやすい説明

  • Blue-Green = 舞台2つ、カーテンで切替(即時・コスト2倍)

  • Rolling = 皿を1枚ずつ取り替える(k8s 標準・コスト同じ)

  • Canary = 試食を5人に → 100人に(最小リスク・実装複雑)

  • 何のコマンドか: 同じ構成のサーバーセット(Blue / Green)を用意し、片方を待機させて新バージョンをデプロイ、LB のターゲットを切り替えるだけで本番反映する手法

  • いつ使うか: 大規模リリースでリスクを最小化したい時、本番と同じ環境で事前スモークテストしたい時、瞬時のロールバックが必須の金融・医療系

  • 解決する具体的な問題: 「リリース後に問題発覚 → 復旧に30分」を「LB を戻すだけで数秒」に短縮し、ストレス無く頻繁にリリースできる土壌を作る

[現状: Blue が production]
  LB ──→ Blue (v1)
        Green (停止 or アイドル)

[Green に v2 をデプロイ]
  LB ──→ Blue (v1)
        Green (v2) ← テスト用URLで動作確認

[切替: LB のターゲットを Green に]
  LB ──→ Green (v2)
        Blue (v1, アイドル)

[問題があれば LB を Blue に戻すだけ]
  LB ──→ Blue (v1)

ブルー/グリーンの本質

2つの完全な本番環境を持ち、LB の振り先を切り替えるだけで即時切替・即時ロールバック。

メリット:

  • 即時ロールバック: LB の設定を戻すだけ(数秒)
  • 本番と同じ環境でテスト可能: 切替前にスモークテスト
  • 長時間並行稼働で問題検出

デメリット:

  • コスト2倍: インフラを2セット持つ
  • DB スキーマ変更が複雑: 両バージョンと互換性が必要
  • ステートフルなものは注意: セッション、キャッシュ

AWS なら CodeDeploy + ALB / ELB で実装可能。手動でも nginx upstream を書き換えれば実現できる。

8. ローリングデプロイ

ブルー/グリーンはコストが2倍。それを避けつつ段階的に新バージョンに切り替えるのがローリングデプロイで、Kubernetes 標準の方式でもあります。

  • 何のコマンドか: N台のインスタンスを「1台ずつ古いバージョン → 新バージョンへ」順番に置換していくデプロイ手法。k8s では kubectl rollout がこれ
  • いつ使うか: アプリインスタンスが3台以上ある時、コストを抑えつつ段階展開したい時、k8s や ECS で標準的にデプロイする時
  • 解決する具体的な問題: 「全台一気に再起動 → 全停止」を避けつつ、ブルー/グリーンのインフラ2倍コストも避ける中庸の選択
[初期: app1, app2, app3 すべて v1]
  LB ──→ app1(v1), app2(v1), app3(v1)

[app1 を v2 にアップデート]
  LB ──→ app1(v2), app2(v1), app3(v1)   ← 1/3 のリクエストが v2

[app2 も v2 に]
  LB ──→ app1(v2), app2(v2), app3(v1)

[全台 v2 に]
  LB ──→ app1(v2), app2(v2), app3(v2)

ローリングの利点と注意点

  • コスト効率: ブルー/グリーンと違ってインフラ2倍にならない
  • 段階的検証: 一部のユーザーで先に新バージョンが動くため、早期検知可能
  • k8s 標準: kubectl rollout がまさにこれ

注意: v1 と v2 が同時に存在する瞬間がある。

  • DB 互換性: v2 でテーブル変更があると v1 が壊れる
  • API 互換性: フロントと API のバージョン整合性
  • キャッシュ: v1 が書いたキャッシュを v2 が読む(formatが違うと事故)

9. カナリアデプロイ(概念)

ローリングをさらに慎重にしたのがカナリアデプロイです。新バージョンを最初にごく一部のユーザー(5% など)にだけ届け、エラー率を観測しながら徐々に拡大します。

  • 何のコマンドか: LB の重み付け or Feature Flag or Service Mesh(Istio など)で、新バージョンへのトラフィック比率を 5% → 25% → 100% と段階的に上げる手法
  • いつ使うか: 大規模サービスで小さな問題でも炎上する時、新機能の本番影響を最小限のユーザーで先行検証したい時、A/B テストと組み合わせる時
  • 解決する具体的な問題: 「ローリングだとすでに全インスタンスの一部が新バージョン → 影響範囲が広い」を、ユーザー単位で最小化する
v2 を 5% のトラフィックに送って、エラー率を観測:
[LB] ──95%──→ v1
      └─5%──→ v2

[5分後、エラー率正常なら 25% に拡大]
[LB] ──75%──→ v1
      └─25%─→ v2

[徐々に 100% へ]

カナリアの命名由来

炭鉱でカナリアを先に行かせて、ガス漏れなどの異常をカナリアの死で検知した故事から。「少数のユーザーで先に試して被害を限定」が思想。

実装: Service Mesh(Istio, Linkerd)、LB の重み付けFeature Flag(LaunchDarkly, Unleash)。

どこまでやるか: 大規模サービス向け。小規模なら「ローリングで十分」。

10. DB マイグレーションの注意

アプリのコードは何度でも戻せますが、DBのスキーマ変更は戻せません。デプロイ事故の多くは、コードではなく DB マイグレーションが引き金になっています。

  • 何のコマンドか: ALTER TABLE / CREATE INDEX などの DDL を、本番に流す前に「後方互換を保つ形に分解」する設計指針
  • いつ使うか: カラムを rename したい時、NOT NULL 制約を追加する時、ENUM を変更する時、大きいテーブルにインデックスを張る時
  • 解決する具体的な問題: 「v1 アプリと v2 アプリが同時稼働する瞬間に DB が壊れる」「DDL を流したが旧アプリがクエリエラーで死ぬ」を、Expand and Contract パターンで根本回避する

スキーマ変更の難しさ

コードは戻せるが、DBの変更は戻すのが難しい。特に破壊的変更(DROP COLUMN, RENAME など)。

安全な変更パターン(後方互換を保つ):

  1. カラム追加(nullable): 既存コードは無視できる → 安全
  2. テーブル追加: 既存に影響なし → 安全
  3. インデックス追加: 動作変わらず → 安全(ただし大きいテーブルで時間がかかる)

危険な変更パターン:

  1. カラム削除: 旧コードが死ぬ
  2. カラム rename: 旧コードが死ぬ
  3. NOT NULL 制約追加: 既存データに NULL があると失敗
  4. データ型変更: 旧コードと新コードの解釈不一致

2段階デプロイで安全に:

例: namefull_name にリネームしたい

  • 第1リリース: full_name を追加(nullable)、両方を書く(dual-write)、読みは name
  • データ移行: バッチで name の値を full_name にコピー
  • 第2リリース: 読みも full_name に切替、name を読まないように
  • 第3リリース: name カラム削除

Expand and Contract パターン」と呼ばれる。

11. ロールバック戦略の3層

「失敗したら戻せる」は単純な話ではありません。アプリ・DBスキーマ・データの3層それぞれにロールバック設計が必要で、層が深くなるほど難易度が跳ね上がります。

  • 何のコマンドか: 「アプリは symlink で5秒」「DB スキーマは Expand and Contract で原則戻さない」「データはバックアップから restore(数時間〜)」と層別に戦略を持つ考え方
  • いつ使うか: 本番障害発生時、デプロイ前のリスク評価、新機能の DB 変更が必要な時、データ破壊バグが発生した時
  • 解決する具体的な問題: 「コードを戻したのに DB が新スキーマのままで動かない」「バックアップを戻すとデータが数時間分消える」のような連鎖事故を、層ごとの方針整理で防ぐ

ロールバックの段階

1. アプリのロールバック(5秒〜30秒):

# current symlink を1つ前に戻す
ssh deploy@prod "ln -nfs /var/www/myapp/releases/$PREV /var/www/myapp/current"
ssh deploy@prod "sudo systemctl restart myapp"

2. DBスキーマのロールバック(数分〜時間):

  • 後方互換な変更ならロールバック不要(v1 と v2 が両方動く)
  • 破壊的な場合: down migration を実行 → ただしデータ消失リスク
  • 推奨: 破壊的変更を本番に出さない設計(Expand and Contract)

3. データのロールバック(数時間〜日):

  • 大量のレコード変更を取り消す
  • 通常はバックアップから restore(point-in-time recovery)
  • データロスが発生する可能性大

教訓: コードは何度でも戻せる、DBは戻せないと心得る。マイグレーションは特に慎重に。

12. アンチパターン集

アンチパターン: 本番に SSH して手動編集

# NG
ssh prod-web
cd /var/www/myapp
vim main.go        # 本番でコード編集
go build
./myapp &

何が悪いか:

  • 再現性ゼロ: 何をやったか後で誰も分からない
  • Git と本番が乖離: コードレビュー無し、テスト無し
  • ロールバック不可能: 「直前の状態」が記録されていない
  • 属人化: その人しか変更を知らない

緊急の hotfix でも、必ず Git にコミット → デプロイを踏む。「あと5分」をケチると数日のデバッグになる。

アンチパターン: デプロイログなし

本番でデプロイしたが、いつ・誰が・何を・どんな結果かが残っていない。

翌日トラブル発生 → 「昨日のデプロイで何が変わった?」が分からず途方に暮れる。

正解:

  • CI/CD のログを永久保存(GitHub Actions は90日デフォルト、長期保存設定可能)
  • Slack 通知: デプロイ成功/失敗を残す
  • デプロイダッシュボード: いつ・誰が・何を・成否がひと目で分かる

アンチパターン: ロールバック手段なし

「壊れたら直しに行く」だけのデプロイ。新規push が前提で、ロールバックスクリプトが用意されていない。

結果: 本番障害発生 → 「直前のcommitを git revert してpushして30分待つ」 → その間ずっとユーザー被害。

正解:

  • releases/ ディレクトリで複数世代を保持
  • ワンクリック or 1コマンドで前バージョンに戻せる
  • 練習: 平時に「ロールバック訓練」を実施(カオスエンジニアリング)

アンチパターン: 環境ごとに違うデプロイ手順

  • 開発環境: make dev
  • ステージング: ./deploy_staging.sh
  • 本番: 「特別な手順書」を運用ドキュメントから探す

結果: ステージングでは動いて本番で動かない、本番特有の事故。

正解: 環境変数だけ違って手順は同じにする。GitHub Actions で environment: staging/production を切替、デプロイスクリプトは共通。

13. 実例: 完全なステップバイステップ

シナリオ: Go API を VPS (Ubuntu) にデプロイする。新規セットアップから運用開始まで。

# ===== Phase 1: サーバー初期セットアップ =====
# (Day 14, 16 で学んだ手順、再掲)
 
ssh root@<vps-ip>
adduser deploy
usermod -aG sudo deploy
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
 
# sshd 堅牢化
sed -i 's/^PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sshd -t && systemctl reload sshd
 
# ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
 
# fail2ban
apt update && apt install -y fail2ban nginx
systemctl enable --now fail2ban
 
# ===== Phase 2: アプリディレクトリ準備 =====
exit  # rootから抜けて
ssh deploy@<vps-ip>
 
sudo mkdir -p /var/www/myapp/{releases,shared/{log,data}}
sudo chown -R deploy:deploy /var/www/myapp
sudo touch /var/www/myapp/shared/.env
sudo chmod 600 /var/www/myapp/shared/.env
sudo chown deploy:deploy /var/www/myapp/shared/.env
 
# .env に本番設定を書く(ローカルで作って scp で送るか、直接編集)
cat > /var/www/myapp/shared/.env <<'EOF'
DATABASE_URL=postgres://app:pass@localhost:5432/myapp
JWT_SECRET=長い秘密文字列
LOG_LEVEL=info
EOF
chmod 600 /var/www/myapp/shared/.env
 
# ===== Phase 3: systemd サービス =====
sudo tee /etc/systemd/system/myapp.service > /dev/null <<'EOF'
[Unit]
Description=My Go Application
After=network.target
 
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/myapp/current
ExecStart=/var/www/myapp/current/bin/myapp
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
EnvironmentFile=/var/www/myapp/shared/.env
KillSignal=SIGTERM
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
 
[Install]
WantedBy=multi-user.target
EOF
 
# deploy ユーザーが systemctl restart myapp を NOPASSWD で叩けるよう sudoers 設定
sudo visudo -f /etc/sudoers.d/deploy
# deploy ALL=(root) NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl status myapp
 
sudo systemctl daemon-reload
sudo systemctl enable myapp
# まだ start しない(バイナリがないので)
 
# ===== Phase 4: Nginx リバプロ =====
sudo tee /etc/nginx/sites-available/myapp > /dev/null <<'EOF'
upstream go_app {
    server 127.0.0.1:8080;
    keepalive 32;
}
 
server {
    listen 80;
    server_name app.example.com;
 
    location = /health {
        access_log off;
        proxy_pass http://go_app;
    }
 
    location / {
        proxy_pass http://go_app;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}
EOF
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
 
# (Let's Encrypt で HTTPS 化は別途 certbot を使う)
 
# ===== Phase 5: 初回デプロイ =====
# ローカルに戻って
exit
 
cd ~/learn/linux/day18/myapp
# 上記の deploy.sh を実行
./deploy.sh
 
# ===== Phase 6: 動作確認 =====
curl http://app.example.com/health
# OK が返れば成功
 
# ===== Phase 7: GitHub Actions 設定 =====
# .github/workflows/deploy.yml をリポジトリに追加
# GitHub Secrets に SSH_PRIVATE_KEY, SSH_HOST, DOMAIN を登録
# main にpushすると自動デプロイ

練習課題

# 1. ローカルで mini production を作る(Vagrant or Multipass の Ubuntu VMが理想)
# 簡易版: Docker でやる
# (Docker / VM ツール群の詳細は「インフラ」章で扱う。
#  ここでは「使い捨ての Linux 環境を手元に用意する手段」とだけ捉えておけばOK)
 
mkdir -p ~/learn/linux/day18/myapp
cd ~/learn/linux/day18/myapp
 
# 簡易Goアプリ
# (Go言語の構文・モジュール (go.mod) は「Go」章で扱う。
#  この章では「graceful shutdown する HTTP サーバーを書いた既製バイナリ」程度の理解で十分。
#  Go 未インストールなら https://go.dev/dl/ から入手、あるいは
#  代わりに 8080 で LISTEN する任意のサーバー(例: python3 -m http.server 8080)で symlink 切替の練習だけしてもOK)
cat > main.go <<'EOF'
package main
 
import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)
 
func main() {
    version := os.Getenv("APP_VERSION")
    if version == "" {
        version = "v0.0.1"
    }
    mux := http.NewServeMux()
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "OK")
    })
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello from %s (PID=%d)\n", version, os.Getpid())
    })
 
    srv := &http.Server{Addr: ":8080", Handler: mux}
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()
    log.Printf("started %s", version)
 
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
    <-stop
    log.Println("shutting down...")
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    srv.Shutdown(ctx)
    log.Println("stopped")
}
EOF
 
# 2. ビルドして起動
go mod init myapp
go build -o bin/myapp .
APP_VERSION=v1 ./bin/myapp &
APP_PID=$!
 
# 3. ヘルスチェック
curl http://localhost:8080/health
curl http://localhost:8080/
 
# 4. graceful shutdown を確認
kill -SIGTERM $APP_PID
# ログに "shutting down..." → "stopped" と出るはず
 
# 5. シンボリックリンク切替パターンを試す
mkdir -p releases/v1 releases/v2 shared
cp bin/myapp releases/v1/myapp
APP_VERSION=v2 go build -o releases/v2/myapp .
 
ln -nfs $(pwd)/releases/v1 current
$(pwd)/current/myapp &
APP_PID=$!
curl http://localhost:8080/
 
# 切替(symlink を v2 に)
ln -nfs $(pwd)/releases/v2 current
kill -SIGTERM $APP_PID
sleep 1
$(pwd)/current/myapp &
APP_PID=$!
curl http://localhost:8080/
# v2 が返るはず
 
# 6. ロールバックを試す
ln -nfs $(pwd)/releases/v1 current
kill -SIGTERM $APP_PID
sleep 1
$(pwd)/current/myapp &
curl http://localhost:8080/
# v1 に戻る
 
kill -SIGTERM $!

締め: git で証跡を残す

exit  # script から抜ける
 
cd ~/learn/linux/day18
git init
git add .
git commit -m "feat(linux): デプロイ最小構成・symlink切替・graceful shutdown の検証"

チェックリスト

  • systemd ユニットファイルを書ける
  • current symlink によるアトミック切替の意味を説明できる
  • graceful shutdown の必要性と Go での実装パターンを説明できる
  • GitHub Actions で SSH 鍵を Secrets 経由で使う手順を書ける
  • ブルー/グリーン、ローリング、カナリアの違いを即答できる
  • DB マイグレーションで後方互換を保つ「Expand and Contract」を説明できる
  • アプリ・DB・データの3層ロールバックの違いを言える
  • シークレット投入のタイミング3パターンを使い分けられる
  • アンチパターンを5つ即答できる

詰まった時のチートシート

やりたいことコマンド
systemd ユニット読み込みsudo systemctl daemon-reload
サービス起動sudo systemctl start myapp
サービス再起動sudo systemctl restart myapp
状態確認sudo systemctl status myapp
自動起動有効化sudo systemctl enable myapp
ログ追従sudo journalctl -u myapp -f
symlink 切替ln -nfs <new> <symlink>
rsync 同期rsync -avz --delete src/ host:/dst/
シグナル送信kill -SIGTERM <pid>
直近リリース一覧ls -1t /var/www/myapp/releases
ヘルスチェックcurl -fsS https://example.com/health
GitHub Actions ローカル実行act (nektos/act)

「実務OK」基準

  • 新規プロジェクトでデプロイ環境をゼロから3時間で構築できる: VPS初期化 → systemd → Nginx → CI/CD
  • 「金曜の夕方にデプロイしても怖くない」設計ができる: 自動化 + ロールバック手段あり
  • シークレットを Git にコミットしない: 環境変数管理を体に染み込ませる
  • graceful shutdown を実装する癖がついている
  • DBマイグレーションを2段階に分ける思考ができる: 「カラム削除」を直接やらない
  • デプロイ失敗時、5分以内に元に戻せる

さらに深掘るなら

  • 書籍: 『継続的デリバリー』(Jez Humble) - CD の原典、必読
  • 書籍: 『SRE サイトリライアビリティエンジニアリング』- デプロイの章は実務必読
  • 公式: https://docs.github.com/en/actions - GitHub Actions 全機能
  • 公式: https://www.freedesktop.org/software/systemd/man/ - systemd 公式
  • Kubernetes: kubectl rollout がローリングデプロイの教科書実装
  • Capistrano: https://capistranorb.com/ - symlinkデプロイの元祖
  • Spinnaker: Netflix 製のデプロイツール、カナリア・ブルーグリーンの参考

アンチパターン / 初心者やらかし事例

NG 1: SSH 鍵を Secrets ではなく Git にコミット

# .github/workflows/deploy.yml に秘密鍵を直書き
ssh-key: |
  -----BEGIN OPENSSH PRIVATE KEY-----
  ...

→ 公開リポジトリなら世界中から見える。プライベートでも履歴から復元可能。 → 対策: GitHub Secrets に保存。CI 内で ${{ secrets.SSH_KEY }} で呼ぶ。

rsync -avz ./build/ user@server:/var/www/app/   # 古いファイルと混ざる

→ 切替中に古いファイルと新しいファイルが混在 → 一瞬の中途半端状態が顧客に露出。 → 対策: releases/v123/ に展開 → current → v123アトミック切替

NG 3: kill -9 でアプリを止めて DB セッション残骸

kill -9 $(pidof myapp)   # 進行中のリクエストが死ぬ、DB セッションが残る

→ 対策: kill -SIGTERM で graceful shutdown。Go なら signal.Notify(c, syscall.SIGTERM) → サーバーの Shutdown(ctx)

NG 4: DB マイグレーションでカラム即削除

ALTER TABLE users DROP COLUMN old_email;   # 旧バージョンのアプリがまだ動いている

→ 旧アプリが「ない列」を SELECT して 500 エラー。 → 対策: Expand and Contract ─ ①新カラム追加 → ②両方書く → ③新を読む → ④旧を消す、の4段階。

NG 5: 「金曜 17:00 デプロイ」

→ 障害時の対応リソースが最小。週末対応で疲弊する。 → 対策: 「いつでもデプロイできる」設計を作る。曜日制限は 対症療法


自己評価チェックリスト

  • systemd ユニットファイルを書ける
  • symlink によるアトミック切替の意味を説明できる
  • graceful shutdown の Go 実装パターンを答えられる
  • GitHub Actions の Secrets を使った SSH デプロイを書いた
  • ブルー/グリーン と ローリング の違いを即答できる
  • DB マイグレーションの Expand and Contract を説明できる
  • 「5分以内ロールバック」の手順を即答できる

Linux 章 卒業

ここで Linux 章 全21レッスンは完走。

  • Day 1-7(Level 1): ターミナル・ファイル・テキスト操作
  • Day 8-14(Level 2): シェル・systemd・cron・logs で 単一サーバー本番品質
  • Day 15-20(Level 3): SSH・ユーザー・Nginx・FW・監視・デプロイで 本番公開

ここで身につけた 「本番運用視点」 は、他のすべての章を貫く土台になる。コードを書く時に「これは本番でどうなるか」を常に考える習慣が、ここで芽生えているはず。

次は Week 12 の 総合プロジェクト ─「Go で書いた API を VPS に本番デプロイし、ドメインを当てて公開する」に挑戦する。Linux 章で身につけた技能をすべて束ねる集大成。