2-7. Level 2 総復習 - Go サーバーを「本番品質」で常駐化する

所要時間: 60-90分(がっつりなら3-4セッション分) ゴール: Level 2 で学んだ全要素(シェル / bash / systemd / cron / logs)を統合し、Go サーバーを「本番運用に耐えるレベル」で立ち上げ・運用できる コミット内容: 一連の構築ログと設定ファイル群を ~/learn/linux/day207/ に保存

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

  • Go HTTP サーバーを systemd 経由で常駐化し、再起動・自動復旧を設定できる
  • 専用ユーザー + 最小権限 + ハードニング(NoNewPrivileges= 等)で本番品質に上げる
  • cron + flock + 失敗通知のセットで日次バックアップを自動化できる
  • logrotate で /var/log/myapp/ を JSON 構造化ログ + 圧縮で運用できる
  • 障害シナリオ(kill / OS reboot)から 自動復活 を実機で確認できる

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

  • Linux サーバーで本番運用できる人」として現場で名乗れる
  • VPS をゼロから1時間で「本番品質」セットアップできる
  • ここで詰まる箇所が見えるので、Level 3(ネットワーク・複数台)の前に補強できる

大前提: Level 2 で何を身につけたか

ざっくり言うと

ここまでの6レッスンで身につけた 6つの武器 を統合して、「Go サーバーを本番品質で立ち上げる」プロジェクトを実装する。 それぞれ単独でも使えるが、組み合わさって初めて本番運用に耐える

知識の地図

[2-1 シェル基礎]
   export, PATH, .zshenv
        ↓
[2-2 bash スクリプティング]
   set -euo pipefail, "$@", クォート
        ↓
[2-3 制御構造]
   [[ ]], for, while read, case, 関数
        ↓ ↓ ↓ ↓
   全てのスクリプトの土台に
        ↓
[2-4 systemd]
   .service unit, Restart=on-failure, journald
        ↓
[2-5 cron]
   crontab 5フィールド, flock, systemd timer
        ↓
[2-6 ログ運用]
   syslog, journald, logrotate, 構造化JSON

棚卸し表

Lesson何を学んだか本プロジェクトでの使用箇所
2-1: シェル基礎bash/zsh の起動ファイル、PATH、exportsystemd unit の EnvironmentFile=, デプロイスクリプトの環境設定
2-2: bash スクリプティングクォーティング、set -euo pipefail、変数展開デプロイスクリプト、バックアップスクリプト
2-3: 制御構造if, for, while, 関数、テスト健全性チェック、引数バリデーション
2-4: systemdunit ファイル、Type、Restart、journaldサーバー常駐化の本体
2-5: croncrontab、>/dev/null 2>&1、systemd timer日次バックアップ、ヘルスチェック
2-6: ログ運用syslog、journald、logrotate、構造化ログアプリログ、運用ログのローテーション

一番覚えやすい説明

  • 2-1〜2-3 = 武器(シェルとスクリプト)
  • 2-4 = 常駐化の本体(systemd)
  • 2-5 = 定期実行(cron/timer)
  • 2-6 = 観測性(ログ)
  • 本日: これらを 1つのプロジェクトに統合して、相互に絡み合って初めて本番品質になることを体感する

本日のミッション

「自作 Go HTTP サーバー」を以下の品質で本番稼働させる:

[要件]
1. 専用ユーザー (myapp) で動く(root 実行禁止)
2. systemd で常駐、クラッシュ時に自動再起動、5回失敗で停止
3. ログは構造化 JSON、journald に集約、journal 上限1GB
4. 設定(DBパス等)は EnvironmentFile=/etc/myapp/env で外出し
5. メモリ上限 128M、CPU 上限 50% で過剰消費を防ぐ
6. 毎日午前3時に /var/lib/myapp をバックアップ (cron)
7. バックアップは7日間保持、それ以上は自動削除
8. アプリのリクエストログは logrotate で14日 + gzip 圧縮
9. ヘルスチェック失敗時に Slack 通知(or stub)
10. プロセスを kill -9 しても自動復活する障害テストを通過

ここまで全部できれば、「Linux サーバーで Go アプリを動かせる人」を名乗ってOK。


セッション①: アプリ本体の準備(15-20分)

0. 録画スタート

mkdir -p ~/log ~/learn/linux/day207
cd ~/learn/linux/day207
script ~/log/linux_day207.log

1. Go アプリのソース

cat > ~/learn/linux/day207/main.go <<'EOF'
package main
 
import (
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)
 
type config struct {
	Port     string
	LogLevel slog.Level
	DataDir  string
}
 
func loadConfig() config {
	c := config{
		Port:    getenv("PORT", "8080"),
		DataDir: getenv("DATA_DIR", "/var/lib/myapp"),
	}
	switch getenv("LOG_LEVEL", "info") {
	case "debug":
		c.LogLevel = slog.LevelDebug
	case "warn":
		c.LogLevel = slog.LevelWarn
	case "error":
		c.LogLevel = slog.LevelError
	default:
		c.LogLevel = slog.LevelInfo
	}
	return c
}
 
func getenv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}
 
func main() {
	cfg := loadConfig()
 
	// 構造化ログを標準出力に(journald が拾う)
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: cfg.LogLevel,
	}))
	slog.SetDefault(logger)
 
	// データディレクトリ確保
	if err := os.MkdirAll(cfg.DataDir, 0750); err != nil {
		slog.Error("failed to create data dir", "err", err)
		os.Exit(1)
	}
 
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		defer func() {
			slog.Info("request",
				"method", r.Method,
				"path", r.URL.Path,
				"remote", r.RemoteAddr,
				"elapsed_ms", time.Since(start).Milliseconds(),
			)
		}()
		fmt.Fprintln(w, "hello from myapp")
	})
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		// 簡易ヘルスチェック: データディレクトリに書き込み可能か
		f, err := os.CreateTemp(cfg.DataDir, "health-*")
		if err != nil {
			w.WriteHeader(http.StatusServiceUnavailable)
			_ = json.NewEncoder(w).Encode(map[string]string{"status": "unhealthy", "err": err.Error()})
			return
		}
		_ = os.Remove(f.Name())
		_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
	})
	mux.HandleFunc("/crash", func(w http.ResponseWriter, r *http.Request) {
		slog.Error("intentional crash for testing")
		os.Exit(1)
	})
 
	srv := &http.Server{
		Addr:              ":" + cfg.Port,
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
 
	// graceful shutdown
	idle := make(chan struct{})
	go func() {
		sigs := make(chan os.Signal, 1)
		signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
		<-sigs
		slog.Info("shutdown signal received, draining...")
		_ = srv.Shutdown(nil)
		close(idle)
	}()
 
	slog.Info("server starting", "port", cfg.Port, "data_dir", cfg.DataDir)
	if err := srv.ListenAndServe(); err != http.ErrServerClosed {
		slog.Error("server error", "err", err)
		os.Exit(1)
	}
	<-idle
	slog.Info("server stopped cleanly")
}
EOF

このアプリで意識した設計ポイント

  • 設定は環境変数経由PORT, LOG_LEVEL, DATA_DIR を環境変数で上書き可能。これが EnvironmentFile= で systemd から渡せる形になる
  • 構造化ログを標準出力にslog.NewJSONHandler(os.Stdout, ...)。journald が拾うので自前ファイル書き込み不要
  • graceful shutdownSIGTERM を受けたら接続を drain して終了。systemd の systemctl stop で正しく止まる
  • /health エンドポイント:監視・ヘルスチェック用。書き込み可否までチェックする
  • /crash:障害シナリオの再現用(本番では削除する)

2. ビルドと配置

Go バイナリをビルドして、本番想定の場所に専用ユーザーで配置 します。/opt/<app> 配置、専用ユーザー作成、データディレクトリの権限設定、という典型的なデプロイ手順を一通り体験します。

  • 何のコマンドか: go build でバイナリ生成、useradd --system でログイン不可の専用ユーザー作成、mkdir + chown で配置先と権限設定
  • いつやるか: 新しいサーバーへの初回デプロイ、デプロイ手順書の標準テンプレ作成、root 実行を避ける環境への移行作業
  • 解決する具体的な問題: 「./myapp を home ディレクトリ直下で動かして root 権限のまま」という素人運用から、/opt/myapp + 専用ユーザー + 適切な権限という本番品質構成へ移行します
cd ~/learn/linux/day207
go mod init myapp 2>/dev/null
go build -o myapp main.go
 
# 配置
sudo mkdir -p /opt/myapp
sudo mv myapp /opt/myapp/myapp
 
# 専用ユーザー作成
sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp 2>/dev/null || true
 
# データディレクトリ
sudo mkdir -p /var/lib/myapp
sudo chown -R myapp:myapp /var/lib/myapp /opt/myapp
sudo chmod 750 /var/lib/myapp
 
# 確認
ls -la /opt/myapp /var/lib/myapp

3. 環境変数ファイル

アプリの設定(DBパス、ポート、ログレベル等)は コードに焼き込まず外部ファイルに分離 するのが本番運用の鉄則。systemd の EnvironmentFile= で読み込む形に揃えます。

  • 何のファイルか: KEY=VALUE 形式の改行区切りテキストを /etc/myapp/env に配置し、systemd unit から EnvironmentFile= で参照
  • いつ作るか: 環境(dev/stg/prod)ごとに設定を変えたい時、秘密情報をリポジトリから分離する時、設定変更のたびにバイナリを再ビルドしたくない時
  • 解決する具体的な問題: 「設定値をハードコードして環境変更時に再ビルドが必要」「秘密情報を git に commit してしまう」を、chmod 640 + chown root:myapp の権限設定で安全に外部化します
sudo mkdir -p /etc/myapp
sudo tee /etc/myapp/env > /dev/null <<'EOF'
PORT=8080
LOG_LEVEL=info
DATA_DIR=/var/lib/myapp
EOF
 
# 権限を絞る(秘密情報を入れた時の事故防止)
sudo chmod 640 /etc/myapp/env
sudo chown root:myapp /etc/myapp/env
 
# 確認:myapp ユーザーから読めるが、世界からは読めない
sudo -u myapp cat /etc/myapp/env

セッション②: systemd で常駐化(20-25分)

4. service unit ファイル

Level 2 の集大成として、「本番品質」と呼べるレベルの service unit を一気に書きます。最小構成からセキュリティ強化、リソース制限まで全部入りです。

  • 何を書くか: [Unit] で依存、[Service] で実行設定 + Restart + セキュリティ強化 + リソース制限、[Install] で自動起動先
  • いつ使うか: 新規アプリの本番デプロイ unit のテンプレート、既存 unit のレビュー基準、社内のデプロイガイドの雛形
  • 解決する具体的な問題: 「とりあえず動く unit」を脱却し、NoNewPrivileges=true ProtectSystem=full でハードニング、MemoryMax=128M CPUQuota=50% でリソース暴走を予防、StartLimitBurst=5 で無限再起動を回避、までを一度に達成します
sudo tee /etc/systemd/system/myapp.service > /dev/null <<'EOF'
[Unit]
Description=My Application HTTP Server
Documentation=https://example.com/myapp/docs
After=network-online.target
Wants=network-online.target
 
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/env
ExecStart=/opt/myapp/myapp
 
# 再起動ポリシー
Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=300
StartLimitBurst=5
 
# セキュリティ強化
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/myapp
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
 
# リソース制限
LimitNOFILE=65535
MemoryMax=128M
CPUQuota=50%
 
[Install]
WantedBy=multi-user.target
EOF

5. 起動

unit ファイルを置いただけでは動きません。daemon-reload で systemd に認識させ、enable --now自動起動有効化と即時起動を同時実行 します。

  • 何のコマンドか: daemon-reload (設定再読込) + enable --now (起動 + ブート時自動起動) + status + curl で動作確認
  • いつ実行するか: 新規 unit のデプロイ直後、unit ファイル編集直後、本番リリース時の動作確認
  • 解決する具体的な問題: 「unit 配置したけど動かない」の8割は daemon-reload 忘れ、を一連の手順書化することで予防。HTTP エンドポイントへの curl まで含めることで「プロセスは動いてるけどリッスンしていない」状態も検知できます
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
 
# 確認
systemctl status myapp
curl http://localhost:8080/
curl http://localhost:8080/health
 
# ログ確認(JSON 形式で出ているはず)
journalctl -u myapp -n 10 -o json | jq -c .

想定される journalctl 出力

{"MESSAGE":"{\"time\":\"2026-05-14T03:00:00Z\",\"level\":\"INFO\",\"msg\":\"server starting\",\"port\":\"8080\",\"data_dir\":\"/var/lib/myapp\"}","_SYSTEMD_UNIT":"myapp.service","_PID":"12345"}

アプリの構造化ログがそのまま MESSAGE フィールドに入り、journald のメタデータと組み合わさる。jq で更にパース:

journalctl -u myapp -o json | jq -r '.MESSAGE' | jq .

6. 障害シナリオ1:プロセス kill

設定が 本当に効いているか を実機テストで確認します。Restart=on-failure を書いたなら、実際に kill -9 して数秒後に PID が変わるかを目で見るのがプロの作法。

  • 何をするか: 現在の MainPID を取得 → kill -9 で強制停止 → RestartSec 秒待機 → 新しい PID で復活しているか確認
  • いつやるか: unit デプロイ直後の動作確認、本番投入の総合テスト、再起動ポリシー変更後の検証
  • 解決する具体的な問題: 「Restart 設定したつもりが効いていない」「OOM Killer での復活条件を読み違えていた」を実機テストで即発覚させ、本番事故の前に潰します
# 現在の PID
PID=$(systemctl show myapp -p MainPID --value)
echo "current PID: $PID"
 
# 強制 kill
sudo kill -9 $PID
 
# 6秒待ってから状態確認(RestartSec=5s なので)
sleep 6
systemctl status myapp
 
# ログで再起動の経緯を見る
journalctl -u myapp -n 15
 
# 新しい PID
NEW_PID=$(systemctl show myapp -p MainPID --value)
echo "old=$PID new=$NEW_PID"
test "$PID" != "$NEW_PID" && echo "PASSED: 自動再起動が機能"

7. 障害シナリオ2:意図的 crash エンドポイント

StartLimitBurst=5 という再起動回数制限が 本当に動作するか を実機で確認するシナリオです。アプリに仕込んだ /crash エンドポイントを連打して限界を観察します。

  • 何をするか: reset-failed でカウンタをクリアした後、curl /crash を連続実行して StartLimitBurst 超過後に failed 状態になることを確認
  • いつやるか: 新規 unit のリリース前テスト、再起動ポリシー変更後の検証、社内の障害訓練
  • 解決する具体的な問題: 「Restart=always で無限再起動して CPU 100%占有」「起動失敗を検知できずに数時間サービス停止」を、5回失敗で停止する閾値の動作確認で予防します
# 再起動カウンタリセット
sudo systemctl reset-failed myapp
 
# 5回連続クラッシュさせて StartLimitBurst を発動させる
for i in $(seq 1 6); do
  echo "--- attempt $i ---"
  curl -s http://localhost:8080/crash || true
  sleep 1
  systemctl is-active myapp
done
 
# 期待: 5回目以降は activating ではなく failed になる
systemctl status myapp
journalctl -u myapp -n 20

障害テストは「無限再起動」を絶対防げる設定で

StartLimitBurst=5 を入れずに無限再起動を許すと、CPU を100%占有して他のサービスが死ぬ。テスト前に必ずこのフィールドが入っていることを確認。

8. 復旧

StartLimit に引っかかって failed 状態になったサービスを 正常稼働に戻す 手順です。本番でも障害復旧時に同じコマンドを叩きます。

  • 何のコマンドか: reset-failed で failed カウンタをクリア、その後 start で再起動
  • いつ使うか: 障害でサービスが failed 状態になった後の復旧、再起動ループ防止が発動した後の手動復旧
  • 解決する具体的な問題: 「systemctl start だけ叩いても start request repeated too quickly で起動を拒否される」を、まず reset-failed でカウンタをクリアする手順で確実に復旧させます
# 失敗状態をクリア
sudo systemctl reset-failed myapp
sudo systemctl start myapp
systemctl status myapp

セッション③: バックアップとログローテーション(20-25分)

9. バックアップスクリプト(bash の集大成)

Level 2 で学んだ bash の 「事故らない書き方」を全部入り で実装するバックアップスクリプトです。set -euo pipefail から構造化ログまで、教科書的なお手本にします。

  • 何を作るか: データディレクトリを tar.gz でアーカイブし、世代管理(古いファイルの自動削除)、結果を JSON 形式で標準出力に記録するシェルスクリプト
  • いつ使うか: 日次/週次の定期バックアップ、デプロイ前のデータバックアップ、cron や systemd timer から起動するバッチの雛形
  • 解決する具体的な問題: 「途中エラーで中途半端なファイルが残る」「失敗してもサイレントに成功扱い」「ログが grep しづらいプレーンテキスト」を、set -euo pipefail + 明示 exit + JSON ログで一気に解決します
sudo tee /opt/myapp/bin/backup.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
readonly DATA_DIR="${DATA_DIR:-/var/lib/myapp}"
readonly BACKUP_DIR="${BACKUP_DIR:-/var/backups/myapp}"
readonly RETENTION_DAYS="${RETENTION_DAYS:-7}"
 
readonly TS=$(date +%Y%m%d_%H%M%S)
readonly ARCHIVE="$BACKUP_DIR/backup_${TS}.tar.gz"
 
mkdir -p "$BACKUP_DIR"
 
# tar 化(エラー時は exit)
if ! tar -czf "$ARCHIVE" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")" 2>&1; then
  echo "backup tar failed" >&2
  exit 1
fi
 
# サイズ確認
size=$(stat -c%s "$ARCHIVE" 2>/dev/null || stat -f%z "$ARCHIVE")
printf '{"ts":"%s","event":"backup_complete","archive":"%s","size":%d}\n' \
  "$(date -Iseconds)" "$ARCHIVE" "$size"
 
# 古いバックアップを削除
find "$BACKUP_DIR" -name "backup_*.tar.gz" -mtime "+${RETENTION_DAYS}" -delete
 
# 削除数を JSON で記録
remaining=$(find "$BACKUP_DIR" -name "backup_*.tar.gz" | wc -l)
printf '{"ts":"%s","event":"backup_retention","remaining":%d,"retention_days":%d}\n' \
  "$(date -Iseconds)" "$remaining" "$RETENTION_DAYS"
EOF
 
sudo mkdir -p /opt/myapp/bin
sudo chmod +x /opt/myapp/bin/backup.sh
sudo chown -R myapp:myapp /opt/myapp /var/backups/myapp 2>/dev/null || sudo mkdir -p /var/backups/myapp && sudo chown myapp:myapp /var/backups/myapp

このスクリプトで意識したポイント

  • set -euo pipefail:失敗を握りつぶさない
  • readonly:再代入を防ぐ
  • ${VAR:-default}:環境変数が無くてもデフォルトで動く
  • 構造化ログ(JSON):標準出力に書く → cron / systemd / journald で取り込み可能
  • 失敗時の exit code 伝播:tar が失敗したら明示的に exit 1
  • 冪等性:複数回実行しても問題ない(タイムスタンプでファイル名が変わる)

10. cron で日次実行

バックアップスクリプトを 「自動で毎日走らせる」 手段。cron と systemd timer の2つの選択肢を並べて、用途で使い分けます。

  • 何のコマンドか: 選択肢A: crontab -e で伝統的な cron 登録、選択肢B: .service + .timer ペアで systemd timer 化
  • いつ選ぶか: 既存システムが cron だらけなら cron、新規プロジェクト・リソース制限・ログ統合が欲しいなら timer
  • 解決する具体的な問題: 「手動で ./backup.sh を毎日実行」という現実的でない運用を、自動定期実行 + ログ集約 + ジッタ + 取り逃し補完で本番運用品質にします

選択肢A:crontab で(伝統)

sudo -u myapp crontab -e
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * /opt/myapp/bin/backup.sh 2>&1 | logger -t myapp-backup

logger -t myapp-backup で出力を syslog 経由で journald に流す。あとで journalctl -t myapp-backup で集計可能。

選択肢B:systemd timer で(モダン、推奨)

sudo tee /etc/systemd/system/myapp-backup.service > /dev/null <<'EOF'
[Unit]
Description=Daily backup for myapp
After=myapp.service
 
[Service]
Type=oneshot
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/env
Environment=BACKUP_DIR=/var/backups/myapp
Environment=RETENTION_DAYS=7
ExecStart=/opt/myapp/bin/backup.sh
 
# セキュリティ
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/myapp /var/backups/myapp
EOF
 
sudo tee /etc/systemd/system/myapp-backup.timer > /dev/null <<'EOF'
[Unit]
Description=Daily backup at 03:00
 
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=15min
Persistent=true
 
[Install]
WantedBy=timers.target
EOF
 
sudo systemctl daemon-reload
sudo systemctl enable --now myapp-backup.timer
 
# 次回実行時刻を確認
systemctl list-timers myapp-backup.timer
 
# 動作確認のため即時実行
sudo systemctl start myapp-backup.service
journalctl -u myapp-backup.service -n 20
ls -lh /var/backups/myapp/

11. ヘルスチェック付きスクリプト+通知

サービスが「動いている つもり」を信用せず、外形監視 + 通知 で異常を即検知する仕組みを作ります。Slack 通知付きの簡易ヘルスチェッカーが題材です。

  • 何を作るか: curl /health を叩いて成功すれば JSON ログを stdout、失敗すれば Slack Webhook に通知する bash スクリプト
  • いつ使うか: 監視 SaaS を導入する前の暫定対策、社内検証環境の簡易監視、systemd timer で5分おきに実行する外形監視
  • 解決する具体的な問題: 「アプリは active のままだがリクエストに応答していない(ハング状態)」を内部監視では検知できない問題を、外部からの HTTP 疎通確認で発見し、Slack で即通知する仕組みで解決します
sudo tee /opt/myapp/bin/healthcheck.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
readonly URL="${HEALTH_URL:-http://localhost:8080/health}"
readonly WEBHOOK="${SLACK_WEBHOOK_URL:-}"
 
if curl -sf --max-time 5 "$URL" > /dev/null; then
  printf '{"ts":"%s","event":"healthcheck","status":"ok"}\n' "$(date -Iseconds)"
  exit 0
fi
 
# 失敗時
printf '{"ts":"%s","event":"healthcheck","status":"fail","url":"%s"}\n' "$(date -Iseconds)" "$URL" >&2
 
if [ -n "$WEBHOOK" ]; then
  curl -s -X POST "$WEBHOOK" \
    -H 'Content-Type: application/json' \
    -d "{\"text\":\"myapp healthcheck failed on $(hostname) at $(date -Iseconds)\"}" \
    > /dev/null || true
fi
 
exit 1
EOF
 
sudo chmod +x /opt/myapp/bin/healthcheck.sh

systemd timer で5分おきに実行:

sudo tee /etc/systemd/system/myapp-healthcheck.service > /dev/null <<'EOF'
[Unit]
Description=Healthcheck for myapp
 
[Service]
Type=oneshot
User=myapp
ExecStart=/opt/myapp/bin/healthcheck.sh
EnvironmentFile=-/etc/myapp/env.secret
EOF
 
sudo tee /etc/systemd/system/myapp-healthcheck.timer > /dev/null <<'EOF'
[Unit]
Description=Run myapp healthcheck every 5 min
 
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
 
[Install]
WantedBy=timers.target
EOF
 
sudo systemctl daemon-reload
sudo systemctl enable --now myapp-healthcheck.timer

Webhook URL は秘密情報

SLACK_WEBHOOK_URL を含む /etc/myapp/env.secretchmod 600、所有者は root にする:

sudo tee /etc/myapp/env.secret > /dev/null <<'EOF'
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
EOF
sudo chmod 600 /etc/myapp/env.secret
sudo chown root:root /etc/myapp/env.secret

EnvironmentFile=-/etc/myapp/env.secret の先頭 - は「ファイルが無くてもエラー無視」の意味。開発環境では未配置でもOKにできる。

12. journald の容量上限を設定

journald のデフォルトは無制限に近い設定。本番では 必ず明示的に上限を設ける のがディスク満杯予防の基本です。

  • 何の設定か: /etc/systemd/journald.conf.d/myapp.conf に drop-in 設定で SystemMaxUse=1G MaxRetentionSec=30day などを宣言
  • いつ設定するか: 新規サーバー構築の初期設定、ログ量が多いアプリのデプロイ前、ディスク警告が出た時
  • 解決する具体的な問題: 「/var/log/journal/ が肥大化してルートFS満杯」「再起動でログが揮発」を、永続化 + 上限 + 保管期間の3点セットで予防します
sudo tee /etc/systemd/journald.conf.d/myapp.conf > /dev/null <<'EOF'
[Journal]
Storage=persistent
SystemMaxUse=1G
SystemMaxFileSize=100M
MaxRetentionSec=30day
EOF
 
sudo systemctl restart systemd-journald
 
# 確認
journalctl --disk-usage

13. アプリログを別ファイルに出している場合(参考)

journald で完結する設計が理想ですが、既存アプリがファイルログ前提 の場合は logrotate を併用します。参考として書いておきます。

  • 何の設定か: /etc/logrotate.d/myapp に「日次ローテ + 14世代保持 + 圧縮 + postrotate でアプリリロード」を宣言
  • いつ使うか: 自作アプリが標準出力ではなく直接ファイルに書く設計の時、ミドルウェア(nginx等)の追加ログを管理する時
  • 解決する具体的な問題: 「ファイルログを放置して数GBに肥大化」「ローテ時にアプリが古いFDを掴み続けて新ファイルに書けない」を、世代管理 + postrotate での reload で予防します

このプロジェクトは標準出力 → journald なので不要だが、もしファイルログを使うなら:

sudo tee /etc/logrotate.d/myapp > /dev/null <<'EOF'
/var/log/myapp/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 640 myapp myapp
    sharedscripts
    postrotate
        systemctl reload myapp 2>/dev/null || true
    endscript
}
EOF
 
# 文法チェック
sudo logrotate -d /etc/logrotate.d/myapp

セッション④: 障害シナリオの総合テスト(15-20分)

14. シナリオA:プロセスクラッシュからの自動復活

ここからは 学んだもの全部を「障害シナリオ」で総合テスト します。最初は kill -9 からの自動復活、つまり Restart=on-failure が機能しているか確認するシナリオ。

  • 何をするか: 平常時の health 確認 → kill -9 で強制停止 → 数秒後の自動再起動を curl で再確認 → ログで経緯を確認
  • いつやるか: リリース前の総合テスト、本番投入前の最終確認、四半期ごとの障害訓練
  • 解決する具体的な問題: 「Restart 設定したのに実際は動かない」「再起動後に DB接続が復旧しない」を、本番環境投入前に手元の検証環境で発覚させます
# 1. 平常時の確認
curl http://localhost:8080/health
 
# 2. kill -9
PID=$(systemctl show myapp -p MainPID --value)
sudo kill -9 $PID
 
# 3. 数秒後の自動再起動を確認
sleep 6
curl http://localhost:8080/health
# 期待: 再び 200 OK
 
# 4. ログで再起動の経緯
journalctl -u myapp -n 10

15. シナリオB:メモリ上限超過

MemoryMax=128M本当に効いているか を cgroup 経由で確認します。実際の OOMKill 動作はアプリ側で意図的にメモリを食う実装が必要なため、まずは設定値の検証から。

  • 何をするか: systemctl show -p MemoryMax で cgroup 制限を確認、OOMKill が起きた場合のログ調査手順を確認
  • いつやるか: リソース制限を新規追加した時、負荷試験前の事前確認、メモリリーク調査
  • 解決する具体的な問題: 「想定外のメモリリークでサーバー全体がスワップで地獄」を、cgroup でアプリ単体に制限してホスト全体への波及を防ぎます
# 軽い確認:cgroup の制限値
systemctl show myapp -p MemoryMax
 
# 実際に超過させる方法はアプリの作り込みが必要なため、設定値の確認のみ
# 実本番では負荷試験で OOMKill が起きないかも確認する
 
# OOMKill が起きた場合の調査
journalctl -k | grep -i "killed process"

16. シナリオC:バックアップが失敗した時の検出

バックアップは 「動いていない時に気付ける」 ことが何より大事。データディレクトリをわざと消して失敗を再現し、systemctl statusjournalctl で検知できることを確認します。

  • 何をするか: データディレクトリを退避してバックアップを失敗させる → systemctl status で失敗ステータスを確認 → 復旧
  • いつやるか: バックアップ unit のリリース前テスト、監視アラート設定の動作確認
  • 解決する具体的な問題: 「3ヶ月バックアップが失敗していたのに誰も気付かず、復旧時に絶望」というよくある事故を、failure 状態の即時可視化で予防します
# 故意にバックアップを失敗させる
sudo mv /var/lib/myapp /var/lib/myapp.tmp
sudo systemctl start myapp-backup.service
 
# 失敗を確認
systemctl status myapp-backup.service
journalctl -u myapp-backup.service -n 20
 
# 元に戻す
sudo mv /var/lib/myapp.tmp /var/lib/myapp
sudo systemctl start myapp-backup.service
ls -lh /var/backups/myapp/

17. シナリオD:journal で過去30分の全イベントを横断検索

本番障害時に最強の武器となる 「複数 unit を横断した時系列調査」 を体験します。journalctl-u 複数指定と JSON 出力 + jq の組み合わせは実務頻出パターン。

  • 何のコマンドか: journalctl --since で時間絞り込み、-u unit1 -u unit2 ... で複数 unit 横断、-o json | jq で構造化集計
  • いつ使うか: 障害発生時の時系列調査、「アプリ・バックアップ・ヘルスチェック」など関連 unit の連動確認、毎週の異常検知レポート
  • 解決する具体的な問題: 「unit ごとに journalctl を叩き分けて時系列が把握できない」「ログ量が多すぎて目視で追えない」を、横断検索 + jq 集計で5分以内に異常を特定できるようになります
# myapp 関連を全部
journalctl --since "30 min ago" \
  -u myapp -u myapp-backup -u myapp-healthcheck.timer \
  -o json | jq -c '{ts: .__REALTIME_TIMESTAMP, unit: ._SYSTEMD_UNIT, msg: .MESSAGE}' | head -30
 
# エラーだけ抽出
journalctl --since "30 min ago" \
  -u myapp -u myapp-backup \
  -p err

練習課題(提出物的なもの)

課題1: 提供チェックリストをすべて通過

下のチェックリストを上から全部 ☑ にする。詰まったポイントを ~/learn/linux/day207/STUMBLED.md にメモする。

課題2: 「systemctl 再起動シナリオ」を自動テストするスクリプト

cat > ~/learn/linux/day207/integration_test.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
echo "=== integration test start: $(date -Iseconds) ==="
 
# テスト1: 起動状態
test "$(systemctl is-active myapp)" = "active" || { echo "FAIL: myapp not active"; exit 1; }
 
# テスト2: ヘルスエンドポイント
curl -sf http://localhost:8080/health > /dev/null || { echo "FAIL: health endpoint"; exit 1; }
 
# テスト3: kill -9 からの自動復活
OLD_PID=$(systemctl show myapp -p MainPID --value)
sudo kill -9 "$OLD_PID"
sleep 7
NEW_PID=$(systemctl show myapp -p MainPID --value)
test "$OLD_PID" != "$NEW_PID" || { echo "FAIL: no restart"; exit 1; }
curl -sf http://localhost:8080/health > /dev/null || { echo "FAIL: not recovered"; exit 1; }
 
# テスト4: バックアップ unit が走る
sudo systemctl start myapp-backup.service
sleep 2
test "$(ls /var/backups/myapp/ | wc -l)" -gt 0 || { echo "FAIL: no backup file"; exit 1; }
 
# テスト5: メモリ制限が設定されている
test "$(systemctl show myapp -p MemoryMax --value)" != "infinity" || { echo "FAIL: no memory limit"; exit 1; }
 
echo "=== all tests passed ==="
EOF
 
chmod +x ~/learn/linux/day207/integration_test.sh
~/learn/linux/day207/integration_test.sh

課題3: トラブルシューティング演習

以下の状況を再現して原因を突き止め、修正手順を記録する:

  1. ExecStart のパスが間違っている/opt/myapp/myap (typo) → systemctl status で何と出るか、journalctl で原因をどう特定するか
  2. User= で存在しないユーザーUser=nonexistent に変更 → エラーメッセージは何か
  3. EnvironmentFile= が chmod 600 で myapp から読めない:起動に失敗する → 権限を 640 / chown root:myapp に修正
  4. PORT 環境変数を上書きできるかsudo systemctl edit myapp の drop-in で Environment=PORT=9999 を追加 → 9999番ポートで動くか

締め: 総合振り返り

# script を終了
exit
 
# 設定ファイル一式を学習リポに残す
cd ~/learn/linux/day207
mkdir -p configs/systemd configs/etc
sudo cp /etc/systemd/system/myapp.service configs/systemd/
sudo cp /etc/systemd/system/myapp-backup.* configs/systemd/
sudo cp /etc/systemd/system/myapp-healthcheck.* configs/systemd/
sudo cp /etc/myapp/env configs/etc/
sudo chown -R "$USER:$USER" configs/
 
git init 2>/dev/null
git add .
git commit -m "feat(linux): Level 2 総復習 - Go サーバーの本番品質常駐化を達成"

チェックリスト

構築

  • Go アプリが構造化ログ(JSON)を標準出力に書く
  • 専用ユーザー myapp で動いている(ps -ef | grep myapp で確認)
  • /etc/myapp/env から環境変数を読んでいる
  • systemctl status myappactive (running) 表示
  • OS 再起動後も自動で立ち上がる(systemctl enable 済み)
  • curl http://localhost:8080/health が 200 を返す
  • journalctl -u myapp で構造化ログが見える
  • MemoryMax=128M などのリソース制限が効いている

障害対応

  • kill -9 で5秒以内に自動復活する
  • 短時間で5回失敗すると failed 状態で止まる(無限再起動しない)
  • systemctl reset-failed で復旧手順を実行できた
  • OOMKill された場合 journalctl -k で確認できる

バックアップとログ

  • 日次バックアップが systemd timer で動く(systemctl list-timers
  • バックアップが7日以上経過すると自動削除される
  • ヘルスチェック失敗時に通知が飛ぶ(or stub で動作確認)
  • journalctl --disk-usage で上限内に収まっている
  • ログにパスワード等の秘密情報が 一切 含まれていない

セキュリティ

  • User=root で動かしていない
  • NoNewPrivileges=true / ProtectSystem=full などのハードニングが入っている
  • Environment= に秘密情報を直書きしていない
  • /etc/myapp/env.secretchmod 600 で root 所有
  • crontab -l に秘密情報が無い

観測性

  • journalctl で myapp 関連を unit 横断で検索できる
  • jq で JSON ログから「直近30分の error 数」を集計できる
  • エラーの根本原因を journalctl から5分以内に特定できる

Level 3 へのブリッジ

Level 2 を完走したあなたは、いま Linux サーバー単体 で本番品質のアプリ運用ができるようになりました。

次の Level 3 では、その範囲をネットワーク・複数台・コンテナへと広げていきます:

  • ネットワーク基礎:TCP/IP、ポート、ファイアウォール、nftables/iptables、SSH の深掘り
  • nginx:リバースプロキシ、ロードバランサ、TLS 終端、レート制限
  • Docker:単一サーバーから「アプリ+OSをパッケージ化」へ
  • Docker Compose:マルチコンテナの構成管理
  • DB 運用基礎:PostgreSQL のインストール、バックアップ、レプリケーション
  • TLS / Let’s Encrypt:本番に必須の HTTPS
  • 基本的な侵入検知とハードニング:fail2ban、unattended-upgrades、CIS Benchmark

Level 2 で「単一サーバーで完結」だった世界が、Level 3 で「ネットワーク越しに連携する複数サーバー」へとスケールします。


詰まった時のチートシート(Level 2 統合版)

やりたいことコマンド
サービス状態systemctl status myapp
起動 / 停止 / 再起動sudo systemctl start | stop | restart myapp
自動起動有効化sudo systemctl enable --now myapp
unit 再読込sudo systemctl daemon-reload
失敗状態クリアsudo systemctl reset-failed myapp
ログ追跡journalctl -u myapp -f
エラーのみjournalctl -u myapp -p err
期間で絞るjournalctl --since "1 hour ago"
JSON 出力journalctl -u myapp -o json | jq .
タイマー一覧systemctl list-timers
journal 容量journalctl --disk-usage
バックアップ即時実行sudo systemctl start myapp-backup.service
設定確認systemctl cat myapp
解析後の状態systemctl show myapp
環境変数だけ表示systemctl show myapp -p Environment
メモリ上限確認systemctl show myapp -p MemoryMax

「Level 2 完了」基準

  • 新しい Linux サーバーを渡された時、ゼロからこの構成を1時間以内に組める
  • 「ログ見て」と言われて、journalctl で5分以内に異常を特定できる
  • インフラのコードレビューで、「ここ root 実行になってる」「ここ logrotate ない」「ここログレベル debug のままだ」を即座に指摘できる
  • 自分が書いたシェルスクリプトに set -euo pipefail が無いと違和感を覚える

ここまで来たら、「Linux サーバーで Go / Node / Python のアプリを単独で運用できる人」 として現場で通用するレベル。

Level 3 ではこれを 「ネットワーク越しに複数台」「コンテナ化」「TLS 化」 へと拡張していく。


次のレッスン: Day 15 - SSH

Level 3 の最初は SSH ─ リモートサーバーとの通信の入口。

Level 2 までは「手元で動くサーバー」だったが、Level 3 からは「ネットワーク越しに操る」世界。SSH の鍵認証・config・トンネリングを覚えて、Day 16 以降のユーザー管理・Nginx・ファイアウォール・デプロイへ繋ぐ。

Day 15: SSH