シェルスクリプトのsystemd深層連携設計|EnvironmentFileとWatchdogSecとsd_notifyでサービス品質を高める方法

宮崎智広 この記事の監修:宮崎智広(Linux実務・教育歴20年以上・受講者3,100名超)
HOME > Linux技術 リナックスマスター.JP(Linuxマスター.JP) > シェルスクリプト > シェルスクリプトのsystemd深層連携設計|EnvironmentFileとWatchdogSecとsd_notifyでサービス品質を高める方法
「systemdサービスにしたのに、なぜか環境変数が取れない」「ハングしたスクリプトがいつまでも生き続けている」——Unitファイルを作った直後によくぶつかるこれらの問題は、systemdが提供する連携機能を使いこなせていないサインです。
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に任せるだけでログが構造化・永続保存される


「このままじゃマズい」と感じていませんか?
参考書を開く気力もない、同年代に取り残される不安——
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

なぜ「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

この構成には、実運用で問題になる4つの落とし穴があります。

・機密情報の管理: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

ExecStartのプロセスはsystemdが起動するため、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=notify
WatchdogSec=
systemd-notify WATCHDOG=1を定期送信
起動完了の通知 Type=notify systemd-notify --readyを初期化後に呼ぶ
/tmp競合・権限昇格 PrivateTmp=true
NoNewPrivileges=true
特別な対応不要(自動でサンドボックス化)
ログの収集・管理 StandardOutput=journal stdoutに出力するだけ。タイムスタンプ重複は不要

Type=simpleのまま「動いているからOK」で済ましていたサービスを一つ取り上げ、EnvironmentFileとWatchdogSecを追加するだけでも、運用品質は大きく変わります。まず手元にある小さなサービスで試してみてください。
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、シェルスクリプト実践講座ではsystemd連携を含むシェルスクリプト設計パターンを体系的に解説しています。実際のサーバー環境で動くコードを使いながら、「なぜそう書くか」まで理解できる構成です。

無料メルマガで学習を続ける

Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。

登録無料・いつでも解除できます

暗記不要・1時間後にはサーバーが動く

3,100名以上が実践した「型」を無料で公開中

プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。

姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

Linux無料マニュアル(図解60P) 名前とメールで30秒登録
宮崎 智広

この記事を書いた人

宮崎 智広(みやざき ともひろ)

株式会社イーネットマーキュリー代表。現役のLinuxサーバー管理者として20年以上の実務経験を持ち、これまでに累計3,100名以上のエンジニアを指導してきたLinux教育のプロフェッショナル。「現場で本当に使える技術」を体系的に伝えることをモットーに、実践型のLinuxセミナーの開催や無料マニュアルの配布を通じてLinux人材の育成に取り組んでいる。

趣味は、キャンプにカメラ、トラウト釣り。好きな食べ物は、ラーメンにお酒。休肝日が作れない、酒量を減らせないのが悩み。最近、ドラマ「フライトエンジェル」を観て涙腺が崩壊しました。