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、export | systemd unit の EnvironmentFile=, デプロイスクリプトの環境設定 |
| 2-2: bash スクリプティング | クォーティング、set -euo pipefail、変数展開 | デプロイスクリプト、バックアップスクリプト |
| 2-3: 制御構造 | if, for, while, 関数、テスト | 健全性チェック、引数バリデーション |
| 2-4: systemd | unit ファイル、Type、Restart、journald | サーバー常駐化の本体 |
| 2-5: cron | crontab、>/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.log1. 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 shutdown:
SIGTERMを受けたら接続を 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/myapp3. 環境変数ファイル
アプリの設定(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=trueProtectSystem=fullでハードニング、MemoryMax=128MCPUQuota=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
EOF5. 起動
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 -eSHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * /opt/myapp/bin/backup.sh 2>&1 | logger -t myapp-backuplogger -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.shsystemd 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.timerWebhook URL は秘密情報
SLACK_WEBHOOK_URLを含む/etc/myapp/env.secretはchmod 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=1GMaxRetentionSec=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-usage13. アプリログを別ファイルに出している場合(参考)
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 1015. シナリオ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 status と journalctl で検知できることを確認します。
- 何をするか: データディレクトリを退避してバックアップを失敗させる →
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: トラブルシューティング演習
以下の状況を再現して原因を突き止め、修正手順を記録する:
- ExecStart のパスが間違っている:
/opt/myapp/myap(typo) →systemctl statusで何と出るか、journalctl で原因をどう特定するか - User= で存在しないユーザー:
User=nonexistentに変更 → エラーメッセージは何か - EnvironmentFile= が
chmod 600で myapp から読めない:起動に失敗する → 権限を 640 /chown root:myappに修正 - 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 myappでactive (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.secretがchmod 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 へ