systemdユニットファイルの作成と管理実践

Tech

Systemd", "secondary_categories": ["DevOps", "Bash Scripting"], "tags": ["systemd", "systemctl", "unit file", "timer", "curl", "jq", "idempotent", "shell script", "security"], "summary": "systemdユニットファイルの作成、安全な管理スクリプト、セキュリティ、そしてcurl/jq連携による実用例をDevOpsエンジニア向けに解説。", "mermaid": true, "verify_level": "L0", "tweet_hint": {"text":"systemdユニットファイルの作成と管理について深掘り。安全なBashスクリプトでサービスとタイマーを実装し、curl/jqを使ったデータ取得の自動化、セキュリティ考慮点を解説します。#systemd ","hashtags":["#systemd","#DevOps"]}, "link_hints": [ "https://github.com/systemd/systemd/releases/tag/v256", "https://curl.se/changes.html", "https://github.com/jqlang/jq/releases/tag/jq-1.7.1", "https://www.freedesktop.org/software/systemd/man/systemd.service.html" ] } --> 本記事はGeminiの出力をプロンプト工学で整理した業務ドラフト(未検証)です。

systemdユニットファイルの作成と管理実践

DevOpsエンジニアにとって、Linuxシステムのサービス管理は日常業務の核となる要素です。systemdは現代のLinuxディストリビューションにおける標準的なinitシステムであり、その理解と効果的な活用はシステムの信頼性、効率性、セキュリティを大きく左右します。本記事では、systemdユニットファイルの作成から、安全かつ冪等性(idempotent)のある管理スクリプト、さらにはcurljqを用いた具体的な自動化例、そして運用上の考慮点までを解説します。

1. 要件と前提

systemdの役割と利点

systemdは、システム起動時のプロセス管理、サービスの起動・停止・再起動、ログ管理、タイマーによる定期実行など、多岐にわたる機能を提供します。その宣言的なユニットファイル形式は、設定の一貫性と管理の容易さを実現します。

本記事のスコープ

  • 対象OS: 主にsystemdを採用しているDebian系(Ubuntuなど)およびRHEL系(CentOS, Fedoraなど)Linuxディストリビューションを想定します。

  • root権限の必要性: systemdユニットファイルの配置やsystemctlコマンドの実行には通常root権限が必要です。本記事では、これらの操作を安全に行うためのプラクティスも紹介します。

  • 安全なスクリプト: サービスの実行スクリプトや管理スクリプトは、意図しない挙動やセキュリティリスクを避けるため、安全なBashスクリプトのベストプラクティスに従います。

前提ツール

  • systemd (バージョン256以降を推奨, 2024年5月27日リリース)

  • curl (バージョン8.8.0以降を推奨, 2024年5月15日リリース)

  • jq (バージョン1.7.1以降を推奨, 2024年1月20日リリース)

2. 実装: systemdユニットファイルの作成と安全な管理スクリプト

フロー図

以下の図は、systemdタイマーがサービスを起動し、そのサービスがcurljqを使ってデータを取得・処理し、ログに記録する一連のプロセスを示しています。

graph TD
    A[my-data-fetcher.timer] -- 定期的に起動 --> B(my-data-fetcher.service);
    B -- ExecStartで実行 --> C{"Bashスクリプト"};
    C -- curlでHTTPS API呼び出し --> D["外部APIサーバー"];
    D -- JSONレスポンス受信 --> C;
    C -- jqでJSONデータ処理 --> E["処理結果を標準出力"];
    E -- 標準出力/エラー --> F["journaldログ"];
    F -- systemctl status/journalctl -fで確認 --> G["DevOpsエンジニア"];

シェルスクリプトの概要と安全な書き方

systemdユニットのデプロイや管理には、堅牢なシェルスクリプトを使用することが不可欠です。

安全なBashスクリプトの原則

  • set -euo pipefail:

    • -e: コマンドが失敗した場合、即座にスクリプトを終了します。

    • -u: 未定義の変数を使用した場合、エラーを発生させます。

    • -o pipefail: パイプライン中の任意のコマンドが失敗した場合、そのパイプライン全体が失敗したものとして扱われます。

  • trapによるクリーンアップ: スクリプトの実行中にエラーが発生したり、予期せず終了したりした場合でも、一時ファイルを確実に削除するための仕組みです。

  • mktemp -d: 一時ファイルを安全に作成するために使用します。これにより、予測不可能なファイル名が生成され、競合状態やセキュリティ脆弱性を防ぎます。

  • sudo -v: root権限が必要な操作の前に、パスワードプロンプトを事前に表示させ、スクリプトの途中で中断するのを防ぎます。

root権限の扱いと権限分離の基本

systemdのユニットファイルを/etc/systemd/system/に配置するにはroot権限が必要です。しかし、実際にサービスを実行する際には、可能な限り低い権限で実行することがセキュリティ上のベストプラクティスです。 systemdUser=ディレクティブやDynamicUser=yesを使用することで、サービス実行ユーザーを分離できます。

  • User=: 指定した既存ユーザーでサービスを実行します。

  • DynamicUser=yes: サービス実行時に一時的なシステムユーザーを動的に作成し、終了時に削除します。これにより、事前にユーザーを作成する手間が省け、セキュリティが向上します。

サービスユニット (.service) の作成

外部APIからデータを定期的に取得し、処理するサービスを想定します。

# /etc/systemd/system/my-data-fetcher.service

[Unit]
Description=My Data Fetcher Service
Documentation=https://example.com/docs/my-data-fetcher
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
ExecStart=/usr/local/bin/my-data-fetcher-script.sh
User=my-data-fetcher-user  # 既存ユーザーで実行する場合

# DynamicUser=yes          # サービス実行時に一時的なユーザーを作成する場合 (User=と排他)

Group=my-data-fetcher-group # 既存グループで実行する場合
LimitNOFILE=524288
StandardOutput=journal
StandardError=journal
Restart=on-failure
RestartSec=5s

# セキュリティ強化のオプション (systemd v256, 2024年5月27日リリース以降)


# systemd.exec man page (https://www.freedesktop.org/software/systemd/man/systemd.exec.html) 参照

ProtectSystem=full       # /usr, /boot, /etc を読み取り専用にする
ProtectHome=true         # /home, /root をアクセス不可にする
PrivateTmp=true          # サービス専用の一時ディレクトリを作成し、他のプロセスから隔離
NoNewPrivileges=true     # サービスが新たな特権を取得することを禁止
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 # 許可するネットワークプロトコルファミリーを制限
RestrictSUIDSGID=true    # SUID/SGIDバイナリの実行を制限
  • [Unit]: サービスのメタ情報や依存関係を定義します。After=network-online.targetは、ネットワークが利用可能になった後にサービスを開始することを示します。

  • [Service]: サービスプロセスの実行方法を定義します。

    • ExecStart: 実行するコマンドを指定します。ここでは/usr/local/bin/my-data-fetcher-script.shを指定。

    • User/Group: サービスを実行するユーザーとグループを指定します。権限分離のために重要です。

    • ProtectSystem, ProtectHome, PrivateTmp, NoNewPrivilegesなどのディレクティブは、サービスのサンドボックス化とセキュリティ強化に寄与します。

/usr/local/bin/my-data-fetcher-script.sh の内容 (curlとjqの利用例)

#!/bin/bash

set -euo pipefail

# エラーハンドリングとクリーンアップ

cleanup() {
    echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Script finished. Exit code: $?"
}
trap cleanup EXIT

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Starting data fetch."

API_URL="https://jsonplaceholder.typicode.com/todos/1"
OUTPUT_FILE="/tmp/fetched_data_$(date +%s).json" # PrivateTmp=true の場合、サービス専用の /tmp になる

# curlを用いたAPI呼び出し


# --fail: HTTPステータスコードが200番台以外の場合、エラー終了


# --silent: 進捗表示を抑制


# --show-error: エラー時にはエラーメッセージを表示


# --retry 5: 最大5回リトライ


# --retry-delay 5: リトライ間隔5秒


# --retry-max-time 60: 最大リトライ時間60秒


# --tlsv1.2: TLSv1.2の使用を強制 (セキュリティ強化)


# --cacert /etc/ssl/certs/ca-certificates.crt: システムのCA証明書を使用 (検証必須)

if ! curl \
    --fail \
    --silent \
    --show-error \
    --retry 5 \
    --retry-delay 5 \
    --retry-max-time 60 \
    --tlsv1.2 \
    --output "$OUTPUT_FILE" \
    "$API_URL"; then
    echo "$(date '+%Y-%m-%d %H:%M:%S JST'): ERROR: Failed to fetch data from $API_URL" >&2
    exit 1
fi

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Data fetched successfully to $OUTPUT_FILE"

# jqを用いたJSON処理


# .title と .completed の値を抽出し、フォーマットして表示

PROCESSED_DATA=$(jq -r '. | "Title: \(.title), Completed: \(.completed)"' "$OUTPUT_FILE")

if [ $? -ne 0 ]; then
    echo "$(date '+%Y-%m-%d %H:%M:%S JST'): ERROR: Failed to process JSON data with jq." >&2
    exit 1
fi

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Processed Data: $PROCESSED_DATA"

# 一時ファイルの削除 (PrivateTmp=true の場合はサービス終了時に自動で消えるが、明示的に削除も可)


# rm -f "$OUTPUT_FILE"

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Data fetch and processing completed."

タイマーユニット (.timer) の作成

上記サービスを定期的に実行するためのタイマーユニットを作成します。

# /etc/systemd/system/my-data-fetcher.timer

[Unit]
Description=Run My Data Fetcher Service every 15 minutes
Requires=my-data-fetcher.service # サービスが存在しないとタイマーを有効化できない

[Timer]
OnCalendar=*:0/15              # 15分ごとに実行 (例: 00:00, 00:15, 00:30, ...)
Persistent=true                # システム再起動後、前回実行できなかった分があればすぐに実行
AccuracySec=1min               # 実行時刻の精度を1分に設定 (デフォルト1sより緩やか)

[Install]
WantedBy=timers.target
  • [Unit]: このタイマーがどのサービスを起動するかを示すためにRequires=my-data-fetcher.serviceを記述します。

  • [Timer]: タイマーの実行スケジュールを定義します。

    • OnCalendar: systemd独自のカレンダー式で実行時刻を指定します。*:0/15は毎時0分、15分、30分、45分に実行することを示します。

    • Persistent=true: システムが停止していた期間に実行されるべきだったサービスを、システム起動後に一度だけ実行するようにします。

    • AccuracySec: 実行時刻の厳密さを設定します。デフォルトの1秒では負荷が高すぎる場合、値を大きくすることでシステムへの影響を軽減できます。

  • [Install]: タイマーがsystemctl enableされたときに、どのターゲットにリンクされるかを定義します。WantedBy=timers.targetが一般的です。

インストールスクリプト (冪等性のあるデプロイ)

以下のBashスクリプトは、サービスとタイマーユニットを配置し、有効化・起動する一連のプロセスを自動化します。このスクリプトは冪等性を持ち、何度実行しても同じ結果が得られるように設計されています。

#!/bin/bash

set -euo pipefail

SERVICE_NAME="my-data-fetcher.service"
TIMER_NAME="my-data-fetcher.timer"
SCRIPT_NAME="my-data-fetcher-script.sh"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME"
TIMER_FILE="/etc/systemd/system/$TIMER_NAME"
SCRIPT_PATH="/usr/local/bin/$SCRIPT_NAME"

# 一時ディレクトリの作成とクリーンアップ

TMP_DIR=$(mktemp -d)
cleanup() {
    local exit_code=$?
    echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Cleaning up temporary directory: $TMP_DIR"
    rm -rf "$TMP_DIR"
    echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Script finished with exit code $exit_code."
    exit "$exit_code"
}
trap cleanup EXIT

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Starting systemd unit deployment."

# root権限の確認と事前認証

if [ "$(id -u)" -ne 0 ]; then
    echo "$(date '+%Y-%m-%d %H:%M:%S JST'): This script requires root privileges. Attempting to use sudo."

    # sudoのパスワードを事前に要求し、スクリプト実行中にプロンプトが出ないようにする

    sudo -v
    if [ $? -ne 0 ]; then
        echo "$(date '+%Y-%m-%d %H:%M:%S JST'): ERROR: sudo authentication failed or not available." >&2
        exit 1
    fi
fi

# ユニットファイルとスクリプトの内容を一時ファイルに書き出す


# serviceファイル

cat <<EOF > "$TMP_DIR/$SERVICE_NAME"
[Unit]
Description=My Data Fetcher Service
Documentation=https://example.com/docs/my-data-fetcher
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
ExecStart=$SCRIPT_PATH
DynamicUser=yes

# User=my-data-fetcher-user # DynamicUser=yes と排他。既存ユーザーで実行する場合


# Group=my-data-fetcher-group

LimitNOFILE=524288
StandardOutput=journal
StandardError=journal
Restart=on-failure
RestartSec=5s

ProtectSystem=full
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true

[Install]
WantedBy=multi-user.target
EOF

# timerファイル

cat <<EOF > "$TMP_DIR/$TIMER_NAME"
[Unit]
Description=Run My Data Fetcher Service every 15 minutes
Requires=$SERVICE_NAME

[Timer]
OnCalendar=*:0/15
Persistent=true
AccuracySec=1min

[Install]
WantedBy=timers.target
EOF

# scriptファイル

cat <<'EOF' > "$TMP_DIR/$SCRIPT_NAME"
#!/bin/bash

set -euo pipefail

cleanup() {
    echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Script finished. Exit code: $?"
}
trap cleanup EXIT

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Starting data fetch."

API_URL="https://jsonplaceholder.typicode.com/todos/1"
OUTPUT_FILE="/tmp/fetched_data_$(date +%s).json"

if ! curl \
    --fail \
    --silent \
    --show-error \
    --retry 5 \
    --retry-delay 5 \
    --retry-max-time 60 \
    --tlsv1.2 \
    --output "$OUTPUT_FILE" \
    "$API_URL"; then
    echo "$(date '+%Y-%m-%d %H:%M:%S JST'): ERROR: Failed to fetch data from $API_URL" >&2
    exit 1
fi

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Data fetched successfully to $OUTPUT_FILE"

PROCESSED_DATA=$(jq -r '. | "Title: \(.title), Completed: \(.completed)"' "$OUTPUT_FILE")

if [ $? -ne 0 ]; then
    echo "$(date '+%Y-%m-%d %H:%M:%S JST'): ERROR: Failed to process JSON data with jq." >&2
    exit 1
fi

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Processed Data: $PROCESSED_DATA"

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Data fetch and processing completed."
EOF

# ファイルの配置とパーミッション設定

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Deploying unit files and script."
sudo cp "$TMP_DIR/$SERVICE_NAME" "$SERVICE_FILE"
sudo cp "$TMP_DIR/$TIMER_NAME" "$TIMER_FILE"
sudo cp "$TMP_DIR/$SCRIPT_NAME" "$SCRIPT_PATH"
sudo chmod 755 "$SCRIPT_PATH"

# systemdへの変更をリロード

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Reloading systemd daemon."
sudo systemctl daemon-reload

# サービスとタイマーの有効化と起動 (冪等性あり)

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Enabling and starting $TIMER_NAME and $SERVICE_NAME."
sudo systemctl enable "$TIMER_NAME"
sudo systemctl start "$TIMER_NAME"

# Note: サービスはタイマーによって起動されるため、ここでは直接startしない

echo "$(date '+%Y-%m-%d %H:%M:%S JST'): Deployment completed."
echo "$(date '+%Y-%m-%d %H:%M:%S JST'): To check status: sudo systemctl status $TIMER_NAME"
echo "$(date '+%Y-%m-%d %H:%M:%S JST'): To check logs: sudo journalctl -f -u $SERVICE_NAME"
  • sudo -v: root権限が必要な操作の前に実行することで、パスワード入力を一度に済ませ、スクリプトの実行中にsudoがブロックされることを防ぎます。

  • cat <<EOF > ...: ヒアドキュメントを用いてユニットファイルとスクリプトの内容を定義し、一時ファイルに書き出します。'EOF'とシングルクォートで囲むことで、シェル変数展開を防ぎ、内容をそのまま保持します。

  • cp: ユニットファイルを適切なディレクトリ(/etc/systemd/system/)にコピーします。

  • chmod: スクリプトに実行権限を付与します。

  • systemctl daemon-reload: 新しいユニットファイルが配置されたことをsystemdに知らせ、設定をリロードします。これは非常に重要です。

  • systemctl enable: タイマーユニットを自動起動設定に追加します。

  • systemctl start: タイマーユニットを直ちに起動します。サービスユニットはタイマーによって起動されるため、通常は直接起動しません。

3. 検証: ユニットの動作確認

デプロイが完了したら、正しく動作しているか確認します。

  1. タイマーの起動状況確認

    sudo systemctl status my-data-fetcher.timer
    

    Active: active (waiting)と表示され、Next:に次の実行時刻(JST)が表示されていれば、タイマーは正しく有効化されています。

  2. サービスの手動実行(初回デバッグ用) タイマーを待たずにサービスをテストしたい場合、手動で実行できます。

    sudo systemctl start my-data-fetcher.service
    
  3. サービスのステータス確認

    sudo systemctl status my-data-fetcher.service
    

    実行中、または直前の実行結果を確認できます。エラーがないか、意図したユーザーで実行されているかなどを確認します。

  4. ログの確認 journalctlを使用して、サービスが出力したログを確認します。

    sudo journalctl -u my-data-fetcher.service -f
    

    -fオプションでリアルタイムにログを追跡できます。curljqの出力、エラーメッセージなどがここに表示されます。

  5. タイマーによる自動起動の確認 journalctlでタイマーとサービスのログを両方追跡し、タイマーがサービスを起動していることを確認します。

    sudo journalctl -f -u my-data-fetcher.timer -u my-data-fetcher.service
    

4. 運用: 管理と監視

ログローテーション

systemdはデフォルトでjournaldにログを収集します。journaldは自身のログローテーション機能を持っていますが、ディスク使用量や保存期間をカスタマイズするには/etc/systemd/journald.confを編集します。

ユニットファイルの更新手順

  1. 新しいユニットファイルまたはスクリプトを配置します。

  2. sudo systemctl daemon-reload: systemdに設定変更を通知します。

  3. sudo systemctl restart my-data-fetcher.service: サービスを再起動して新しい設定を適用します。タイマーも設定変更があれば再起動します。sudo systemctl restart my-data-fetcher.timer

監視ツールの連携

PrometheusやGrafanaなどの監視ツールと連携し、systemdサービスの稼働状況、CPU/メモリ使用量、スクリプトの実行時間、API応答時間などを監視します。ExecStartPreExecStopPostでメトリクスを収集するスクリプトを挟むことも有効です。

5. トラブルシュート

問題が発生した場合、以下の手順でトラブルシューティングを行います。

  1. ステータスの確認: sudo systemctl status my-data-fetcher.serviceでサービスの状態(ActiveSubState)や最新のエラーメッセージを確認します。

  2. 詳細ログの確認: sudo journalctl -xe -u my-data-fetcher.serviceで詳細なログとエラーコンテキストを確認します。--since "2024-07-30 10:00:00 JST"のように時間範囲を指定すると便利です。

  3. 手動実行によるデバッグ: ExecStartで指定されたスクリプトを直接実行し、エラーを特定します。sudo -u my-data-fetcher-user /usr/local/bin/my-data-fetcher-script.shのように、サービスが実行されるユーザーで実行すると、権限の問題を特定しやすくなります。

  4. 依存関係の確認: my-data-fetcher.serviceWants=network-online.targetなどの依存関係がある場合、それらが正しく機能しているか確認します。

  5. ユニットファイルの構文チェック: systemd-analyze verify my-data-fetcher.serviceでユニットファイルの構文エラーがないかチェックします。

6. まとめ

systemdのユニットファイル(.service.timer)の作成方法、curljqを用いた具体的な自動化例、そして安全なBashスクリプトによるデプロイと管理の実践方法を解説しました。DynamicUserProtectSystemなどのセキュリティ強化オプションの活用は、本番環境でのサービス運用の信頼性と安全性を高める上で不可欠です。これらの知識と実践を通じて、DevOpsエンジニアはより堅牢で効率的なシステム管理を実現できるでしょう。

ライセンス:本記事のテキスト/コードは特記なき限り CC BY 4.0 です。引用の際は出典URL(本ページ)を明記してください。
利用ポリシー もご参照ください。

コメント

タイトルとURLをコピーしました