Article

タスクスケジューラーの活用テクニック

高田晃太郎
タスクスケジューラーの活用テクニック

うるう秒は1972年以降で27回挿入され、サマータイムは世界70以上の国・地域で実施されるという時間の現実は、タスクスケジューリングの難易度を一段引き上げます¹²。Linuxのcronは分単位の解像度で扱いやすい一方、時計の飛びや歪み、プロセス再起動、ノード障害などの運用事象が重なると、想定外の重複実行や取りこぼしが発生します。CTOやエンジニアリーダーにとっての本質は、単なるジョブの起動ではなく、業務改善とシステムの効率化に直結する信頼可能な実行保証をどう設計するかにあります。

プロダクション運用では、cron、systemd timer、Kubernetes CronJob、Airflow、APSchedulerを併用し、SLO(Service Level Objective:サービス目標)とコストの視点で最適化する考え方が有効です。結論から言えば、壁時計時間(人間のカレンダー時間)に束縛されるジョブは冪等性とロックで守り、周期タスクは単調時間(OSが持つ単調増加カウンタ)で安定化し、観測性で遅延と取りこぼしを検出して補償するという三点を押さえるだけで、失敗率と運用負荷は目に見えて下がります。本稿では、設計原則から具体実装、可観測性、ROIまでを、一貫した戦略として解説します。運用設計にそのまま転用できるコードと設定も掲載します。

失敗しないスケジューリング設計の原則

まず時間の取り扱いを正します。人間が見るカレンダー時間はDST(サマータイム)やうるう秒の影響を受けます。日次バッチのように特定時刻に走らせたいジョブは、壁時計時間の都合に従わざるを得ませんが、処理そのものは冪等(同じ入力で複数回実行しても結果が変わらない)に設計し、二重実行に耐える必要があります。一方でキャッシュの温めやメトリクス集計のような周期タスクは、単調時間(再起動を跨いでも巻き戻らない内部時間)に基づくトリガーのほうが時刻の歪みから独立して安定します。systemd timerのMonotonicはこの利点を活かせます³。

二重実行の制御は、スケジューラの設定とアプリの両方で行うと堅牢です。Kubernetes CronJobにはconcurrencyPolicy(同時実行の禁止や置換)がありますが⁵、アプリケーションレベルでも分散ロックをかけて冪等性を補強すると、ワーカーノードの故障やコントローラ再起動時の再実行にも耐えられます。PostgreSQLのadvisory lock(任意キーで取る軽量ロック)やRedisベースのロックは現場で実用的です⁸。さらにリトライとバックオフ(試行間隔を徐々に延ばす)は外部依存の瞬間的な失敗を吸収しますが、無制限の再試行は逆効果なので、最大試行回数や締切(deadline)を必ず設けます。

取りこぼしの補償は、スケジューラとアプリの責務分担が鍵です。systemd timerのPersistent=trueは電源オフ期間の実行を補償します⁴。Kubernetes CronJobではstartingDeadlineSeconds(遅延起動をどこまで許容するか)を定め、Airflowではcatchupとバックフィル(過去期間の再処理)で穴を埋められます⁶⁷。アプリ側は最後に処理したオフセットや日付を記録し、起動時に未処理範囲を自動で追いかけると、コントロールプレーンのダウン時でも整合が崩れません。

資源の隔離と優先度も業務改善の観点で重要です。CPUクォータやメモリ上限を設定し、重いバッチで本番APIのレイテンシを劣化させないようにします。コンテナ環境ではrequests/limits、VMではcgroups(Linuxの資源制御機構)、ベアメタルやWindowsでは優先度とI/Oスロットリングを使います。これにより夜間バッチによるスパイクを平準化し、全体の処理効率化に直接効いてきます。

小さく始めて観測で詰める

最初から完璧な設計を目指すより、最小限の制御(冪等性・ロック・リトライ)を入れて可観測性を整え、運転しながら改善するほうが現実的です。遅延、実行時間、成功率、二重実行の発生頻度をダッシュボード化し、しきい値をSLOとして明文化します。SLO違反が続くジョブは原因を分類し、スケジューラ側の設定かアプリ側の制御か、あるいは外部依存の能力不足かを切り分けます。

現場で効く具体テクニックと実装例

ここからは各スケジューラの特性を踏まえ、実装の雛形を示します。プロダクション前提のオプションを含め、エラーハンドリングやログ出力を備えています。

systemd timer:単調時間と永続実行の補償

Linuxサーバでの定時ジョブと周期ジョブの双方に有効です。日次処理は壁時計、周期処理はMonotonicで切り分けるのがコツです。RandomizedDelaySecで一斉起動を避け、Persistentで停止中の実行を補償します⁴。

# /etc/systemd/system/report.service
[Unit]
Description=Daily report job
After=network-online.target

[Service]
Type=oneshot
Environment=ENV=prod
ExecStart=/usr/local/bin/report.sh
# 失敗時に非ゼロ終了すること。

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/report.timer
[Unit]
Description=Run report at 00:05 local time

[Timer]
OnCalendar=*-*-* 00:05:00
RandomizedDelaySec=180
Persistent=true
Unit=report.service

[Install]
WantedBy=timers.target
#!/usr/bin/env bash
set -euo pipefail
trap 'echo "[report] failed at $(date -Is)" >&2' ERR

LOG_PREFIX="[report]"
echo "$LOG_PREFIX start $(date -Is)"
# 冪等な処理:同じ日付の再実行でも安全に終わるようにする
TARGET_DATE=$(date -d 'yesterday' +%F)
/usr/local/bin/generate_report --date "$TARGET_DATE" --idempotent true

echo "$LOG_PREFIX done $(date -Is)"

journalctlで構造化ログを拾い、Prometheusのnode_exporterやcustom exporterで終了コードと実行時間をメトリクス化すると、障害時の復旧が速くなります。

Kubernetes CronJob:クラウドネイティブな実行保証

コントローラがJobを生成する方式のため、ノード障害にも比較的強く、履歴や並行実行の制御も標準機能で賄えます。スケジュールのタイムゾーンは環境によって解釈が異なることがあるため、UTC基準の表現とアプリ側のタイムゾーン処理を組み合わせると安全です。

apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-report
spec:
  schedule: "5 0 * * *"   # 00:05 UTC。必要ならアプリでTZを明示
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 2
  startingDeadlineSeconds: 900
  suspend: false
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 1200
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: report
              image: ghcr.io/acme/report:1.8.0
              env:
                - name: TZ
                  value: Asia/Tokyo
              resources:
                requests: { cpu: "100m", memory: "256Mi" }
                limits:   { cpu: "500m", memory: "512Mi" }

取りこぼしが致命的なジョブはstartingDeadlineSecondsを十分に大きく取り、アプリ側で未処理期間を自動補償します。

Airflow:依存関係とバックフィルが強いDAG型

依存関係を持つETLやMLパイプラインでは、DAG(有向非巡回グラフ)オーケストレータの表現力が強みになります。SLAやcatchupでデータ欠損に向き合い、再実行の履歴を監査可能にします⁷。

from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.bash import BashOperator

default_args = {
    'owner': 'data-eng',
    'retries': 2,
    'retry_delay': timedelta(minutes=5),
    'depends_on_past': False,
}

dag = DAG(
    dag_id='daily_report',
    default_args=default_args,
    schedule_interval='5 0 * * *',
    start_date=datetime(2024, 1, 1),
    catchup=True,
    max_active_runs=1,
)

generate = BashOperator(
    task_id='generate',
    bash_command='python -m jobs.report --date {{ ds }}',
    dag=dag,
)

upload = BashOperator(
    task_id='upload',
    bash_command='python -m jobs.upload --date {{ ds }}',
    dag=dag,
)

generate >> upload

AirflowはUIとメタデータベースにより、失敗の原因と再実行状況が視覚化されます。ジョブをコード化し、レビューとCIを通すと、業務フローの変更管理がシステムの効率化に直結します。

APScheduler:アプリ内スケジューリングの正しい使い方

単一サービス内の軽量ジョブはAPSchedulerが手軽です。永続化ジョブストア(ジョブ定義をDBに保存)とタイムゾーン明示、構造化ログ、例外処理を組み合わせます。

import logging
from datetime import datetime
from pytz import timezone
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore

log = logging.getLogger('sched')
logging.basicConfig(level=logging.INFO)

jobstores = {'default': SQLAlchemyJobStore(url='sqlite:///jobs.sqlite')}
executors = {'default': ThreadPoolExecutor(4)}
scheduler = BackgroundScheduler(jobstores=jobstores, executors=executors, timezone=timezone('Asia/Tokyo'))


def run_report():
    try:
        start = datetime.now()
        log.info('report start ts=%s', start.isoformat())
        # 冪等な処理
        # ...
        log.info('report done dur_ms=%d', int((datetime.now() - start).total_seconds() * 1000))
    except Exception as e:
        log.exception('report failed err=%s', e)
        raise

scheduler.add_job(run_report, trigger='cron', hour=0, minute=5, jitter=30)
scheduler.start()

プロセス再起動時の取りこぼしはジョブストアの永続化と、起動時に未処理期間を補償するロジックでカバーします。

分散ロック:PostgreSQL advisory lockで単一実行

二重実行を確実に避けたいジョブは、DBロックで明示的にガードすると堅くなります。pg_try_advisory_lockは待たずにロック可否を返すため、重複時に即座にスキップできます⁸。

import psycopg2
import sys

LOCK_KEY = 424242  # 任意の整数キー

conn = psycopg2.connect(dsn='postgresql://app@db/prod')
conn.autocommit = True
cur = conn.cursor()
cur.execute('SELECT pg_try_advisory_lock(%s)', (LOCK_KEY,))
locked, = cur.fetchone()
if not locked:
    print('skip: another instance running', file=sys.stderr)
    sys.exit(0)

try:
    # ここに冪等な本処理
    pass
finally:
    cur.execute('SELECT pg_advisory_unlock(%s)', (LOCK_KEY,))
    cur.close(); conn.close()

Redisを使う場合は、有効期限(TTL)と更新を伴うロックでネットワーク分断に備えます。いずれの場合も、重複時の振る舞い(スキップか置換か)はビジネス仕様に合わせて決めます。

可観測性とパフォーマンス:遅延・取りこぼし・重複を可視化する

スケジューリングの品質は、予定時刻との差、実行時間、結果の三点で測ります。予定時刻との差はスケジューラの精度とシステム負荷の指標になり、実行時間は処理の健全性を表し、結果はSLOの直接の対象です。以下の最小メトリクスを収集すると、改善余地がすぐ浮かび上がります。

最小構成では、job_scheduled_time(予定)、job_start_time(実測)、job_end_time、job_success(0/1)をロギングし、差分をメトリクス化します。Prometheusではヒストグラムでschedule_delay_seconds、ジョブごとのduration_seconds、成功率のカウンタを公開するだけで十分役に立ちます。ダッシュボードは予定と実行の散布図が強力で、負荷集中やスローダウンが一目で分かります。

# Prometheus client例(簡略)
import os
from time import time
from prometheus_client import Histogram, Counter

schedule_delay = Histogram('job_schedule_delay_seconds', 'Delay between planned and actual start', ['job'])
duration = Histogram('job_duration_seconds', 'Job duration', ['job'])
success = Counter('job_success_total', 'Job success count', ['job'])

# 実行時
planned = float(os.environ.get('PLANNED_TS', time()))
start = time()
schedule_delay.labels('daily_report').observe(max(0.0, start - planned))

# 処理本体 ...
job_start = time()
# ... do work ...
job_end = time()
duration.labels('daily_report').observe(job_end - job_start)
success.labels('daily_report').inc()  # 失敗時は記録しない/別途failureを増やす

開始遅延の絶対値は環境により変わりますが、傾向として、cron(分解像度)は高負荷時に遅延が伸びやすく、systemd timerはジッタに強く、Kubernetes CronJobはコントローラのポーリング間隔とクラスタ負荷の影響を受けます。重要なのは相対比較と傾向の把握で、観測結果に基づきスケジューラ選択や設定を調整することです。

取りこぼし検知は、予定スロットに対して実行記録が欠けていないかを照合する方式が堅実です。日次ジョブなら日付キーで、時次ジョブなら時刻スロットで、データストアに完了フラグを記録しておくと、ダッシュボードで即座に穴が見えます。重複実行はロックの統計と突き合わせ、発生時には影響範囲を自動判定して後続処理を保護します。

組織スケールでの運用:権限、コスト、ROI

ジョブ定義はConfiguration as Codeでリポジトリに集約し、レビューと自動テストを通します。KubernetesならCronJob YAMLを、AirflowならDAG Pythonを、VMならsystemd unitファイルをそれぞれ管理します。ジョブのオーナー、SLO、エスカレーション先、停止手順までREADMEに同居させると、属人性が薄れ、業務改善になります。秘密情報はVaultやSecrets Managerで注入し、リポジトリには置かない方針を徹底します。

コストの観点では、実行時間×消費リソース×単価に加え、失敗時の人件費と機会損失が効きます。一般的なクラウド単価(vCPU時間やGB時間が数円〜数十円程度)を仮定すると、短時間の定時ジョブは1回あたり数円規模に収まるケースが多い一方、失敗やオンコール対応のコストが支配的になることがあります。重複実行の抑制と取りこぼし補償で失敗率を下げられれば、そのままROIに跳ね返ります。

導入・移行の目安は、小規模なcronからsystemd timerへの置き換えが数時間〜数日、Kubernetes CronJobの標準化とGitOps化が数日〜数週間、Airflow導入が小規模でも数週間程度と考えると計画が立てやすくなります。段階的に対象ジョブを移し、可観測性のダッシュボードが成長するにつれて、SLO違反の削減が意思決定の材料になります。

適材適所の指針

サーバ単体の周期タスクはsystemd timer、クラスタ上での水平スケールと履歴管理が必要ならKubernetes CronJob、依存関係やバックフィルが中心ならAirflow、アプリ内部の軽量処理ならAPSchedulerが第一候補になります。混在運用は珍しくありませんが、どのジョブがどの責務でどのSLOを持つかを台帳化しておくと、エスカレーションや棚卸しが格段に楽になります。

まとめ:時間に強いシステムは、組織を強くする

スケジューリングは設定作業に見えて、実はビジネスの律動を作る設計です。壁時計と単調時間を使い分け、冪等性・ロック・リトライでアプリを堅牢にし、PersistentやstartingDeadlineSeconds、catchupで取りこぼしを補償する。観測で遅延と成功率を常時可視化し、SLOに基づいて改善を回す。これだけで、失敗率とオンコールの負荷は目に見えて下がり、業務改善とシステムの効率化が同時に進みます。

明日からできる一歩として、最も重要な日次ジョブをひとつ選び、冪等化と分散ロック、実行メトリクスの導入から始めてみてください。最初のダッシュボードが立ち上がった瞬間に、次のボトルネックが自然と見えてきます。もし設計の方針に迷ったら、上記の実装雛形をそのまま叩き台に、あなたの環境に合わせて調整していきましょう。

参考文献

  1. APNIC Blog. Leaving the last second. https://blog.apnic.net/2016/12/19/leaving-last-second/
  2. Timeanddate.com. Daylight Saving Time (DST) — Statistics. https://www.timeanddate.com/time/dst/statistics.html
  3. systemd.timer — Timer unit configuration. https://www.freedesktop.org/software/systemd/man/devel/systemd.timer.html
  4. systemd.timer — RandomizedDelaySec, Persistent options. https://www.freedesktop.org/software/systemd/man/devel/systemd.timer.html
  5. Kubernetes documentation: CronJobs. https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/
  6. Kubernetes v1.32 docs: CronJobs — startingDeadlineSeconds, concurrencyPolicy. https://v1-32.docs.kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/
  7. Apache Airflow documentation: Backfill and catchup. https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/backfill.html
  8. PostgreSQL Documentation: Advisory Locks. https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS