UbuntuでSystemd ServiceとSystemd Timerを作成する実践ガイド:Crontabに代わる堅牢なスケジューリング手法

Ubuntu tutorial - IT technology blog
Ubuntu tutorial - IT technology blog

火曜日の深夜2時、アラート通知のバイブレーションでスマートフォンが激しく鳴り響きました。注文同期サービスが突如停止していたのです。さらに悪いことに、80GBにおよぶPostgreSQLデータベースのバックアップCronジョブは、過去6時間にわたってログを1行も記録していませんでした。

急いでサーバーへSSH接続し、crontab -lを実行すると、スケジュール設定自体は残っていました。しかし環境変数PATHの不足により、スクリプトは深夜のうちに誰にも気づかれることなくサイレントクラッシュしていたのです。このオンコール対応をきっかけに、私たちのチームは老朽化したCrontabを完全に廃止し、Ubuntu 22.04クラスタの定期実行タスクをすべてSystemd ServiceとSystemd Timerへ移行することを決断しました。

クイックスタート:5分で作成するServiceとTimer

ログを明確に残し、サイレントエラーの心配がない定期タスク基盤を整えたい場合は、以下の4つのステップに沿って進めてください。

ステップ1:実行スクリプトの作成

/usr/local/bin/backup-task.shにBashスクリプトを作成します:

sudo nano /usr/local/bin/backup-task.sh

ディスク容量の確認と一時ファイルのクリーンアップを行う以下のコードを記述します:

#!/usr/bin/env bash
set -euo pipefail

NOW=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$NOW] キャッシュのクリーンアップとログ圧縮を開始します..."

# 7日以上前のアプリケーションキャッシュを削除
find /tmp/app-cache/ -type f -mtime +7 -delete 2>/dev/null || true

echo "[$NOW] タスクが正常に完了しました!"

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

sudo chmod +x /usr/local/bin/backup-task.sh

ステップ2:Systemd Serviceの作成

Serviceは「どのコマンドを実行するか」、そして「どの権限で実行するか」を定義する役割を担います。/etc/systemd/system/backup-task.serviceにUnitファイルを作成します:

sudo nano /etc/systemd/system/backup-task.service

以下の設定内容を入力します:

[Unit]
Description=定期的なキャッシュクリーンアップおよびバックアップタスク
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-task.sh
User=root
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

ステップ3:実行スケジュールを設定するSystemd Timerの作成

TimerはServiceをトリガー(起動)する役割を果たします。Serviceと同名で/etc/systemd/system/backup-task.timerを作成します:

sudo nano /etc/systemd/system/backup-task.timer

実行スケジュールを設定します(例:毎日午前2時):

[Unit]
Description=毎日午前02:00にbackup-taskを実行

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true

[Install]
WantedBy=timers.target

ステップ4:設定の反映と有効化

ターミナルで以下のコマンドを実行します:

# 1. systemdに新しいUnitファイルを再読み込みさせる
sudo systemctl daemon-reload

# 2. 構文エラーを検証するため直ちにServiceをテスト実行する
sudo systemctl start backup-task.service

# 3. ログを確認してスクリプトの実行結果を検証する
journalctl -u backup-task.service -n 20 --no-pager

# 4. Timerを有効化し、システム起動時に自動実行させる
sudo systemctl enable --now backup-task.timer

わずか5分の作業で設定は完了です。これにより、スクリプトの実行状態は常に監視され、すべての標準出力・標準エラー出力ログが漏れなく記録されるようになります。

なぜSystemd TimerはCrontabより信頼性が高いのか?

Crontabが誕生したのは1975年のことです。当時のサーバー環境は現在ほど複雑ではありませんでした。Cronコマンドでエラーが発生した際、Cronはデフォルトでその出力をsendmail経由で送信しようとします。しかし、現代のサーバーの多くはローカルにメールサーバーを構築していません。その結果、エラーは痕跡すら残さず闇に葬られてしまいます。

対照的に、Systemdのモジュール化されたアーキテクチャには以下の3つの決定的な強みがあります:

  • 明示的な依存関係管理:After=network-online.targetやWants=docker.serviceなどを簡単に宣言できます。ネットワークやデータベースなどのインフラが確実に準備完了してからスクリプトを実行することが可能です。これはCrontabでは実現できません。
  • 追いつき実行機能(Persistent=true):例えば実行予定が午前2時だったものの、メンテナンスのためにサーバーが1:30から3:00まで停止していたとします。サーバーが再起動した際、Systemd Timerは未実行のジョブを自動検知して即座に補填実行します。Crontabの場合、そのタスクは単にスキップされ、翌日まで実行されません。
  • リソース制限(cgroups):Serviceファイル内に直接MemoryMax=500MやCPUQuota=30%などを指定できます。負荷の高いスクリプトが暴走して、同居するWebサーバーのCPUリソースを枯渇させるリスクを未然に防げます。

本番向け構成:クラッシュ時に自動復旧するデーモン設定

Systemdは、単発実行スクリプト(oneshot)だけでなく、Node.js、Python FastAPI、Golangなどで構築されたバックグラウンド常駐プロセス(デーモン)の管理にも最適です。

以下は、私たちのチームが本番環境で実際に運用している/etc/systemd/system/api-gateway.serviceのサンプル設定です:

[Unit]
Description=Production API Gateway
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/opt/api-gateway
ExecStart=/usr/bin/node /opt/api-gateway/server.js

# アプリケーションクラッシュ時の自動再起動
Restart=always
RestartSec=5s

# 環境変数とファイルディスクリプタの上限設定
LimitNOFILE=65535
Environment=NODE_ENV=production
EnvironmentFile=/opt/api-gateway/.env

# ファイルシステムのセキュリティ強化
ProtectSystem=full
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

特に注目すべき2つのポイントがあります:

  1. RestartSec=5s:再起動までに5秒間のインターバルを設けます。致命的なロジックエラーでプロセスが即座に終了を繰り返す場合でも、このディレイによって再起動ループによるCPU使用率100%の張り付きを防止できます。
  2. EnvironmentFile:.envファイルから環境変数を一括読み込みします。「手動でログインして動かしたときは動くのに、デーモンとして動かすと設定不足で落ちる」という典型的なトラブルを完全に解消できます。

OnCalendarのスケジュール構文を事前に検証する

Crontabの5つのアスタリスク指定は、日と曜日の組み合わせを間違えがちです。Systemdであれば、systemd-analyze calendarコマンドを使用して事前にスケジュールを検証できます:

systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"

実行すると即座に構文がパースされ、次回実行日時と残り時間のカウントダウンが表示されます:

Normalized form: Mon..Fri *-*-* 09:00:00
   Next elapse: Fri 2026-10-23 09:00:00 UTC
      (in 22h left)

障害発生時に迅速に原因を特定するデバッグの勘所

サービスのアラートを受信した際は、以下の3つのコマンドを使うことで1分以内に原因を絞り込むことができます:

1. 稼働中の全Timerの一覧を確認する

systemctl list-timers --all

このコマンドで全体の見通しが良いステータステーブルが出力されます。NEXT列で次回の実行予定、LEFT列で残り時間、LAST RESULT列で前回実行時の終了ステータス(exit status)を一目で把握できます。

2. Journalctlでリアルタイムにログを追跡する

/var/log/配下に散らばる個別のテキストファイルを手動で開く代わりに、Unit名を指定してログを直接フィルタリングできます:

# リアルタイムで最新ログを追跡出力(tail -f と同様)
journalctl -u backup-task.service -f

# 過去30分間に生成された全ログを抽出
journalctl -u backup-task.service --since "30 minutes ago"

# エラーレベル(Error以上)のログのみを表示
journalctl -u backup-task.service -p err

3. PATHの不一致による「Command not found」エラーの解消

BashスクリプトをService化する際、最も頻発するトラブルがPATHの不整合です。Systemdはユーザーの~/.bashrcや~/.profileを読み込まないため、デフォルトのPATHは/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/binといった最低限のシステムディレクトリのみに限定されます。

もしスクリプト内でNVM、Pyenv、Poetry経由でインストールしたバイナリを使用している場合は、[Service]セクション内でPATHを明示的に指定してください:

[Service]
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/home/deploy/.nvm/versions/node/v20.10.0/bin"

あるいは最も安全な方法として、スクリプト内では常に実行バイナリの絶対パスを記述するようにしてください(例:単にpython3とするのではなく/usr/bin/python3と記述)。

CrontabからSystemd Timerへの移行にかかる時間は15分程度ですが、得られるシステムの透明性と堅牢性は極めて大きなものです。正確な実行タイミングの把握、消費リソースの制御、そしてトラブル時の迅速な調査を可能にする統合ログ管理基盤をぜひ手に入れてください。

Share: