systemdは単なる「スクリプトの起動ツール」ではありません。環境変数の安全な注入・スクリプトの生死監視・ファイルシステムのサンドボックス化・ログの統合収集という機能を持つサービス基盤です。これらをシェルスクリプト側から正しく使うことで、運用品質が大きく変わります。
この記事では、EnvironmentFile・WatchdogSec・sd_notifyの3つを軸に、シェルスクリプトをsystemdと深く連携させる設計パターンを実践例付きで解説します。既存の「サービス化」記事で止まっていた方が次のステップに進める内容です。
動作確認環境:RHEL 9.4 / Rocky Linux 9.4 / Ubuntu 22.04 LTS(systemd 249以上)
この記事のポイント
・EnvironmentFileで機密情報をスクリプト本体から完全に切り離せる
・WatchdogSec+sd_notifyで応答不能スクリプトを自動検知・再起動できる
・PrivateTmpとNoNewPrivilegesでスクリプトを最小権限で動かせる
・journaldに任せるだけでログが構造化・永続保存される
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜ「Unitファイルを作るだけ」では不十分なのか
最もシンプルなsystemdサービスは、こんなUnitファイルで作れます。# /etc/systemd/system/myservice.service(最低限の構成) [Unit] Description=My Script Service After=network.target [Service] Type=simple ExecStart=/usr/local/bin/myservice.sh [Install] WantedBy=multi-user.target
・機密情報の管理:DBパスワードやAPIキーをスクリプト内にハードコードするか、ExecStart行に直書きするしかなく、
systemctl showやプロセスリストで漏洩するリスクがあります・ハング検知の欠如:スクリプトが無限ループや待機状態に陥っても、systemdは「プロセスが生きている」と判断し続けます
・/tmp競合:複数サービスが共有する
/tmpに一時ファイルを作ると、シンボリックリンク攻撃や競合の原因になります・ログの散逸:
echoの出力先を自分で管理しなければ、どこに何が記録されているか分からなくなりますこれらをsystemdの機能で解決するのが、この記事の本題です。
EnvironmentFileで機密情報をスクリプトから分離する設計
1. EnvironmentFileの基本的な書き方
EnvironmentFileは、環境変数の定義ファイルをUnitファイルから参照する仕組みです。# /etc/systemd/system/myservice.service(抜粋) [Service] Type=notify EnvironmentFile=/etc/myservice/config.env ExecStart=/usr/local/bin/myservice.sh
KEY=VALUE形式で記述します。シェルのexportは不要です。# /etc/myservice/config.env DB_HOST=db01.internal.example.com DB_USER=app_reader DB_PASS=s3cret_p@ssword API_ENDPOINT=https://api.example.com/v2 BATCH_SIZE=500
-(ハイフン)を付けると、ファイルが存在しなくてもサービスが起動します。開発環境で設定ファイルが未整備の場合などに使います。# 省略可能なファイルは - を先頭に付ける EnvironmentFile=-/etc/myservice/optional.env
2. ファイルの権限設定
設定ファイルには機密情報を含むため、権限を適切に絞ります。# root のみ読み書き可能にする sudo install -m 600 -o root -g root /dev/null /etc/myservice/config.env sudo chmod 600 /etc/myservice/config.env # 確認 ls -la /etc/myservice/config.env -rw------- 1 root root 128 Oct 5 09:15 /etc/myservice/config.env
root実行であればEnvironmentFileを読めます。User=でサービスユーザーを指定している場合は、そのユーザーが読める権限が必要です。3. スクリプト内での環境変数の使い方
systemdが環境変数を注入した後は、スクリプト内で通常の環境変数として扱えます。#!/bin/bash # DB_HOST, DB_USER, DB_PASS は systemd が注入済み # 未定義時にエラーで止まるよう set -u を使う set -euo pipefail # 必須環境変数チェック : "${DB_HOST:?DB_HOST が未設定です}" : "${DB_PASS:?DB_PASS が未設定です}" mysql -h "${DB_HOST}" -u "${DB_USER}" -p"${DB_PASS}" mydb <<'SQL' SELECT COUNT(*) FROM orders WHERE status='pending'; SQL
: "${VAR:?message}"はbashのパラメータ展開です。変数が未定義または空の場合にスクリプトをエラー終了させるため、EnvironmentFileが正しく読まれていない場合の早期検知に役立ちます。WatchdogSecとsd_notifyでスクリプトの生死を自動検知する設計
1. Type=notifyとWatchdogSecの仕組み
Type=simpleのサービスは「ExecStartのプロセスが起動した瞬間」にsystemdが「起動完了」と判断します。スクリプトの内部処理が終わっているかどうかは無関係です。Type=notifyに変えると、systemdはスクリプトからREADY=1というシグナルを受け取るまで「起動中」状態のまま待機します。さらにWatchdogSec=を設定すると、一定時間ごとにキープアライブシグナルが届かない場合にサービスを自動的に再起動します。[Service] Type=notify WatchdogSec=30s Restart=on-failure RestartSec=5s
WatchdogSec=30sの場合、スクリプトは15秒以内に1回(WatchdogSecの半分以下)のペースでキープアライブを送る必要があります。2. sd_notifyの実装パターン
スクリプトからsystemdへの通知にはsystemd-notifyコマンドを使います。#!/bin/bash set -euo pipefail WATCHDOG_INTERVAL=10 # WatchdogSec=30s の半分以下 # 初期化完了を通知(Type=notify はこれを待っている) systemd-notify --ready --status="Initialization complete" # メインループ counter=0 while true; do counter=$((counter + 1)) # 実際の処理 process_pending_jobs # Watchdog キープアライブ+現在の状態を通知 systemd-notify WATCHDOG=1 STATUS="Processed: ${counter} cycles" sleep "${WATCHDOG_INTERVAL}" done
systemd-notifyコマンドが送信できる主なメッセージを整理します。・READY=1:初期化が完了したことを通知(Type=notifyで必須)
・WATCHDOG=1:スクリプトが正常動作中であることを通知
・STATUS=メッセージ:任意の状態メッセージを通知(journalctlとsystemctl statusに表示)
・STOPPING=1:シャットダウン処理を開始したことを通知
3. cron実行との互換性確保
$NOTIFY_SOCKETが設定されている場合にのみ通知するように書くと、同じスクリプトをcronやコマンドラインから手動実行するときにエラーが出ません。# systemd から起動された場合のみ通知する notify() { if [ -n "${NOTIFY_SOCKET:-}" ]; then systemd-notify "$@" fi } # 使い方 notify --ready --status="Ready" notify WATCHDOG=1 STATUS="Cycle: ${counter}"
PrivateTmpとNoNewPrivilegesでスクリプトをサンドボックス化する設計
1. PrivateTmpで/tmpの競合を防ぐ
PrivateTmp=trueを設定すると、サービスのプロセスには専用の/tmpと/var/tmpが割り当てられます。他のプロセスとの共有/tmpが見えなくなるため、一時ファイルの競合やシンボリックリンク攻撃を防げます。# サービス内でのパス $ systemctl start myservice $ nsenter -m -t $(systemctl show myservice -p MainPID --value) ls /tmp # → myservice 専用の空の /tmp が見える # サービス外から同じプロセスの /tmp は /tmp/systemd-private-.../tmp/ に隔離される $ ls /tmp/systemd-private-abcd1234.../tmp/ myservice_work.tmp
mktempで作成するだけでよく、プロセス終了時にsystemdが自動でクリーンアップします。2. NoNewPrivilegesで権限昇格を防ぐ
NoNewPrivileges=trueは、Linuxカーネルのprctl(PR_SET_NO_NEW_PRIVS)を使い、プロセスがsetuidバイナリの実行やケーパビリティの追加取得を行えないようにします。スクリプトが意図せずsetuidなコマンドを呼び出しても、権限昇格が発生しないため、脆弱性が侵入口になるリスクを下げられます。
3. ProtectSystemとReadWritePathsの組み合わせ
ProtectSystem=strictは/usr・/boot・/etcを読み取り専用にマウントします。スクリプトが書き込みを必要とするディレクトリはReadWritePaths=で明示的に許可します。[Service] PrivateTmp=true NoNewPrivileges=true ProtectSystem=strict ReadWritePaths=/var/log/myservice /var/lib/myservice
systemd-analyze security myserviceを実行すると、サンドボックス強度のスコアが確認できます。$ systemd-analyze security myservice NAME DESCRIPTION RESULT ✓ NoNewPrivileges Service processes cannot acquire new privileges OK ✓ PrivateTmp Service has access to private /tmp OK ✓ ProtectSystem Service cannot write to host OS directories OK ~ User= Service runs as root WARN → Overall exposure level for myservice.service: 4.1 MEDIUM
StandardOutputとjournaldによるログ統合設計
1. スクリプト側の設計
StandardOutput=journalとStandardError=journalを指定すると(systemd 249以降でデフォルト)、スクリプトがechoやprintfで出力するすべての内容がjournaldに収集されます。journaldはタイムスタンプ・PID・ユニット名・優先度を自動付与するため、スクリプト側でタイムスタンプを重ねる必要はありません。
# NG: タイムスタンプの二重付与(journaldが自動付与するので不要) echo "$(date '+%Y-%m-%d %H:%M:%S') [INFO] Processing started" # OK: メッセージだけ出力する echo "[INFO] Processing started" echo "[ERROR] Connection failed: ${DB_HOST}" >&2
>&2)に送ると、journaldがPRIORITY=ERRとして記録し、journalctl -p errでまとめて確認できます。2. journalctlでのログ確認
# リアルタイムでログを追う $ journalctl -u myservice -f # 直近100行 $ journalctl -u myservice -n 100 # 今日のログのみ $ journalctl -u myservice --since today # エラーと警告だけ $ journalctl -u myservice -p warning # 実際の出力例(/var/log への書き込みは不要) Oct 05 09:20:01 server01 myservice[2847]: [INFO] Processing started Oct 05 09:20:11 server01 myservice[2847]: [INFO] Processed: 3 cycles Oct 05 09:20:21 server01 myservice[2847]: [INFO] Processed: 4 cycles Oct 05 09:20:31 server01 myservice[2847]: systemd-notify[2901]: WATCHDOG=1
実装例:すべての設計を組み合わせた監視スクリプト
1. Unitファイルの完成形
# /etc/systemd/system/dbmonitor.service [Unit] Description=Database Connection Monitor After=network.target mysql.service Requires=mysql.service [Service] Type=notify EnvironmentFile=/etc/dbmonitor/config.env ExecStart=/usr/local/bin/dbmonitor.sh Restart=on-failure RestartSec=10s WatchdogSec=60s PrivateTmp=true NoNewPrivileges=true ProtectSystem=strict ReadWritePaths=/var/log/dbmonitor StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target
2. シェルスクリプトの完成形
#!/bin/bash # /usr/local/bin/dbmonitor.sh set -euo pipefail # 必須環境変数チェック(EnvironmentFile未読時に即エラー) : "${DB_HOST:?DB_HOST が未設定です}" : "${DB_USER:?DB_USER が未設定です}" : "${DB_PASS:?DB_PASS が未設定です}" : "${ALERT_THRESHOLD:=10}" WATCHDOG_INTERVAL=25 # WatchdogSec=60s の半分以下 # systemd 通知ヘルパー(cron 実行との互換維持) notify() { if [ -n "${NOTIFY_SOCKET:-}" ]; then systemd-notify "$@" fi } # ログ関数(journald がタイムスタンプを付けるため本文だけ出力) log_info() { echo "[INFO] $*"; } log_error() { echo "[ERROR] $*" >&2; } # --- 初期化フェーズ --- log_info "DB監視サービス起動中: host=${DB_HOST}" # DB 接続確認 if ! mysql -h "${DB_HOST}" -u "${DB_USER}" -p"${DB_PASS}" -e "SELECT 1" --connect-timeout=5 &>/dev/null; then log_error "DB への初期接続に失敗しました: ${DB_HOST}" exit 1 fi notify --ready --status="DB接続確認完了" log_info "起動完了" # --- メインループ --- cycle=0 while true; do cycle=$((cycle + 1)) # 未処理レコード数を取得 pending=$(mysql -h "${DB_HOST}" -u "${DB_USER}" -p"${DB_PASS}" -sNe "SELECT COUNT(*) FROM jobs WHERE status='pending'" mydb 2>/dev/null || echo -1) if [ "${pending}" -eq -1 ]; then log_error "DB クエリ失敗(cycle=${cycle})" elif [ "${pending}" -gt "${ALERT_THRESHOLD}" ]; then log_error "滞留ジョブ超過: ${pending} 件 > 閾値 ${ALERT_THRESHOLD}" else log_info "正常: 未処理ジョブ ${pending} 件 (cycle=${cycle})" fi # Watchdog キープアライブ notify WATCHDOG=1 STATUS="Monitoring: cycle=${cycle}, pending=${pending}" sleep "${WATCHDOG_INTERVAL}" done
3. デプロイ手順
# 設定ファイルとスクリプトを配置 sudo install -m 600 -o root -g root config.env /etc/dbmonitor/config.env sudo install -m 755 -o root -g root dbmonitor.sh /usr/local/bin/dbmonitor.sh sudo install -m 644 -o root -g root dbmonitor.service /etc/systemd/system/ # systemd にUnitファイルの変更を反映 sudo systemctl daemon-reload # 自動起動設定 & 起動 sudo systemctl enable --now dbmonitor # 起動確認(Type=notify なので READY=1 受信まで "activating" 表示) $ systemctl status dbmonitor * dbmonitor.service - Database Connection Monitor Loaded: loaded (/etc/systemd/system/dbmonitor.service; enabled) Active: active (running) since Mon 2026-10-05 09:22:15 JST; 8s ago Main PID: 3145 (dbmonitor.sh) Status: "Monitoring: cycle=1, pending=3" Tasks: 2 (limit: 4915) CGroup: /system.slice/dbmonitor.service ├─3145 /bin/bash /usr/local/bin/dbmonitor.sh └─3190 sleep 25
トラブルシュート:systemd連携でよくハマるポイント
1. sd_notifyが届かずタイムアウトする
症状:systemctl start myserviceが90秒後に「タイムアウト」エラーになる原因:
Type=notifyにもかかわらず、スクリプトがsystemd-notify --readyを送っていない、またはsystemd-notifyコマンドが存在しない・確認:
which systemd-notifyでコマンドの存在を確認する・確認:
echo $NOTIFY_SOCKETで環境変数が設定されているか確認する(手動起動時は空)・対処:確認用に一時的に
Type=simpleで起動してスクリプト自体の動作を切り分ける2. EnvironmentFileが読めない
症状:journalctl -u myserviceに「DB_HOST が未設定です」が出る原因:ファイルが存在しない、またはsystemdが読める権限がない
・確認:
systemctl show myservice | grep Environmentで変数が注入されているか確認する・確認:
stat /etc/myservice/config.envで所有者とパーミッションを確認する・対処:
User=を指定している場合は、そのユーザーがファイルを読める権限(640以上)が必要3. PrivateTmp下の一時ファイルが見えない
症状:スクリプトが/tmp/mywork.tmpに書いたはずのファイルが/tmpに見えない原因:
PrivateTmp=trueにより、サービスプロセスの/tmpはホストの/tmpとは別の名前空間になっている・確認:ホスト側の
ls /tmp/systemd-private-*/tmp/を探すと実際の一時ファイルが見える・対処:サービス間でファイルをやり取りする必要がある場合は
/var/lib/myservice/などReadWritePaths=に指定したディレクトリを使うまとめ
systemdとシェルスクリプトを深く連携させる設計のポイントをまとめます。| 課題 | systemdの設定 | スクリプト側の対応 |
|---|---|---|
| 機密情報の管理 | EnvironmentFile= |
${VAR:?message}で未設定を検知 |
| ハング検知・自動再起動 | Type=notifyWatchdogSec= |
systemd-notify WATCHDOG=1を定期送信 |
| 起動完了の通知 | Type=notify |
systemd-notify --readyを初期化後に呼ぶ |
| /tmp競合・権限昇格 | PrivateTmp=trueNoNewPrivileges=true |
特別な対応不要(自動でサンドボックス化) |
| ログの収集・管理 | StandardOutput=journal |
stdoutに出力するだけ。タイムスタンプ重複は不要 |
Type=simpleのまま「動いているからOK」で済ましていたサービスを一つ取り上げ、EnvironmentFileとWatchdogSecを追加するだけでも、運用品質は大きく変わります。まず手元にある小さなサービスで試してみてください。
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:cronで動かすシェルスクリプトの設計パターン|PATH・多重起動防止・ログ・エラー処理の4点セットを実装する方法
- この記事の属するカテゴリ:シェルスクリプトへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。
登録無料・いつでも解除できます