Spring Batchをジョブの完了を待機して安全に終了する方法

こんにちは、情報システム部エンジニアの飯田です。昨年のAdvent Calendarでは技術イベントの話を書きましたが、普段はお客さまの契約や課金、請求などを管理するシステム(以降、「課金システム」と呼びます)の設計から開発、保守・運用まで幅広く担当しています。

今回はその課金システムで使用しているSpring Batchについて書いていきたいと思います。Spring Batchを知らない人でもできるだけシンプルなサンプルコードで概要が分かるようにしていきますのでぜひ読んでみて下さい!

Spring Batchとは?

Javaを使っている多くの人が知っているSpring Frameworkのモジュールの1つで、Batchという名の通り大量のデータを処理するための機能が提供されています。Spring MVCやSpring Securityなどと比べるとマイナーな部類だと思うので、実際に触ったことのある人は意外と少ないのではないでしょうか。(私も今のチームに来るまで使ったことがありませんでした。)

バッチ処理と書くと馴染みがないかも知れませんが、「予め用意された大量のジョブを適切に管理・実行して最後まで処理してくれる」ものだと思ってもらえば大丈夫です。

課金システムでのSpring Batchの使い方

課金システムでもバッチ処理のためにSpring Batchを使っていますが、大量ジョブの実行の他に、お客さまの操作によって随時発生するジョブの処理にも利用しています。Spring Batchは通常、全てのジョブが完了したら自動的に終了しますが、後に説明する方法を用いることで終了せずに他のジョブを待ち構えるようにできます。

ただそうすると、デプロイなどでシステムを終了する際に実行中のジョブを中断してしまう可能性が出てきます。課金システムは内部・外部の様々なサービスやシステムと連携しているので、処理が中途半端だとお客さまに影響が出ますし、復旧作業も結構大変です。そこで導入したのがタイトルにある「ジョブの完了を待機して安全に終了する方法」となります。

Spring Batchに入門してみよう!

それでは実際にSpring Batchの簡単なアプリを書いてみましょう。

プロジェクトのひな型はSTS(Spring Tool Suite。Spring向けにカスタマイズされたEclipseベースのIDE)の「New Spring Starter Project」またはウェブ上の「Spring Initializr」から簡単に作成できます。今回はsample-batchという名前のGradleプロジェクトにして、依存ライブラリにSpring BatchとLombok、H2 Databaseを選択します。

SampleBatchApplication.javaというクラスが生成されていますがこれには触らず、Spring Batchの設定を行うJava Configのクラスを作成します。

Spring Batchのステップとジョブの定義

@Configuration
@EnableBatchProcessing  // 1. Spring Batchの機能を有効にする
@RequiredArgsConstructor
public class SampleBatchConfig {

  private final JobBuilderFactory jobs;
  private final StepBuilderFactory steps;
  
  @Bean
  protected Step step1(Tasklet tasklet1) {
    return steps.get("step1").tasklet(tasklet1).build();  // 2. ステップを定義する
  }
  
  @Bean
  protected Step step2(Tasklet tasklet2) {
    return steps.get("step2").tasklet(tasklet2).build();
  }
  
  @Bean
  protected Job sampleJob(Step step1, Step step2) {
    return jobs.get("sampleJob").incrementer(new RunIdIncrementer())
        .start(step1).next(step2).build();  // 3. ジョブを定義する
  }
}
  1. @EnableBatchProcessingをどこかのクラスに書けばSpring Batchの機能が有効になり、内部で色んなコンポーネントを生成してくれます。
  2. 生成されたコンポーネントの中のStepBuilderFactoryを使って「step1」と「step2」の2つのステップを定義します。
  3. 同じく生成されたJobBuilderFactoryを使って「step1」と「step2」を順番に実行する「sampleJob」というジョブを定義します。

ジョブ定義のincrementer(new RunIdIncrementer())は実行するジョブのIDを設定するもので、詳しくは後のサンプルで説明します。

@RequiredArgsConstructorはLombokのアノテーションで、finalフィールドをセットするデフォルトコンストラクタを自動生成してくれます。さらにSpringの機能でシステムの起動時にフレームワークで生成されたコンポーネントを自動で設定(DI)してくれます。実装量が少なくなって嬉しいですね!

ステップの処理の実装

@Component
@Slf4j
public class Tasklet1 implements Tasklet {  // 1. Taskletを実装する

  @Override
  public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext)
      throws Exception {
    log.info("tasklet1が実行されました。");  // 2. ステップの処理を記述する
    Thread.sleep(3 * 1000);
    return RepeatStatus.FINISHED;
  }
}
  1. 簡単な処理であればTaskletというインタフェースを使います(これをタスクレットモデルと呼び、より複雑な処理にはチャンクモデルを使いますが、今回は説明を省きます)。
  2. execute()にステップの処理を記述し、最後にRepeatStatusを返します。今回はログ出力とスリープ処理だけを行い、完了を表すRepeatStatus.FINISHEDを返しています。

Tasklet2も同様に用意して、これでもう必要最小限の実装は完了です!早速実行してみましょう。

Job: [SimpleJob: [name=sampleJob]] launched with the following parameters: [{run.id=1}]
Executing step: [step1]
tasklet1が実行されました。
Step: [step1] executed in 3s7ms
Executing step: [step2]
tasklet2が実行されました。
Step: [step2] executed in 3s3ms
Job: [SimpleJob: [name=sampleJob]] completed with the following parameters: [{run.id=1}] and the following status: [COMPLETED] in 6s69ms

この通り、Spring Batchが起動してsampleJobを実行し、step1とstep2が順番に処理されました。ようこそSpring Batchの世界へ!

ジョブをスケジュール実行してみよう!

上の例ではジョブが完了するとSpring Batchが自動で終了しますが、Springのスケジュール機能を有効にするとアプリを終了せずに定期的にジョブを実行できるようになります。課金システムではこれを利用して毎日決まった時間に特定の処理を行ったり他のシステムからリクエストされたジョブを随時実行したりしています。

それではスケジュール実行を試してみましょう。まず次のクラスを追加します。

スケジュール処理の実装

@Component
@EnableScheduling  // 1. スケジュール機能を有効にする
@RequiredArgsConstructor
public class SampleBatchLauncher {

  private final JobLauncher jobLauncher;
  private final Job sampleJob;
  
  private JobParameters jobParameters;
  
  @Scheduled(cron = "*/5 * * * * *")  // 2. スケジュール処理を実装する
  public void launchSampleJob() throws JobExecutionException {
    this.jobParameters = sampleJob.getJobParametersIncrementer()
        .getNext(this.jobParameters);  // 3. ジョブのパラメータを更新する
    this.jobLauncher.run(this.sampleJob, this.jobParameters);  // 4. ジョブを実行する
  }
}
  1. @EnableSchedulingを書けばスケジュール機能が有効になります。
  2. スケジュール機能を有効にすると@Scheduledの付いたメソッドが指定したスケジュールで実行されるようになります。サンプルでは5秒おきに実行するようにしています。
  3. ジョブの実行に必要なJobParametersを作成します。ここでジョブ定義の時に設定したincrementerが登場し、以下の図のように前回のJobParameter(初回はnull)を渡すとrun.idを1増やしたパラメータを生成してくれます。Spring Batchではパラメータが全く同じジョブは同一と見なされて実行をスキップするので、このようにIDを設定します。
  4. ジョブとパラメータを指定してジョブを実行します。

ジョブ実行の設定変更

spring:
  batch:
    job:
      enabled: false

最初のサンプルではJobLauncherを使わなくてもジョブが実行されていましたが、これはデフォルトだと@Beanで生成されたジョブが実行されるようになっているためです。application.yml(またはapplication.properties)で実行しないようにできるので設定しておきましょう。

これでスケジュール実行の準備が整いました!実行するとrun.idがインクリメントしながらジョブが定期的に処理されている様子が分かります。

新しいジョブを実行します。{run.id=1}
Job: [SimpleJob: [name=sampleJob]] launched with the following parameters: [{run.id=1}]
Executing step: [step1]
tasklet1が実行されました。
Step: [step1] executed in 3s25ms
Executing step: [step2]
tasklet2が実行されました。
Step: [step2] executed in 3s7ms
Job: [SimpleJob: [name=sampleJob]] completed with the following parameters: [{run.id=1}] and the following status: [COMPLETED] in 6s55ms
新しいジョブを実行します。{run.id=2}
Job: [SimpleJob: [name=sampleJob]] launched with the following parameters: [{run.id=2}]
Executing step: [step1]
tasklet1が実行されました。
Step: [step1] executed in 3s26ms
Executing step: [step2]
tasklet2が実行されました。
Step: [step2] executed in 3s5ms
Job: [SimpleJob: [name=sampleJob]] completed with the following parameters: [{run.id=2}] and the following status: [COMPLETED] in 6s51ms

デフォルトだとジョブの実行は同期処理なので、このサンプルだと前のジョブが完了してから5秒後に次のジョブが実行されます。非同期処理にすることもできますが、設定ではなく実装を修正する必要があります。

ジョブの完了を待機したい!

前置きが長くなりましたが、ここからが今回の記事の本題です。スケジュール実行によりSpring Batchが稼働し続けるようになったので終了は手動ですることになりますが、もしこの時にジョブが実行中だった場合、以下のように実行途中で異常終了してしまいます!

Thread interrupted while locking for repository update
HikariPool-1 - Shutdown initiated...
HikariPool-1 - Shutdown completed.
Encountered an error executing step step2 in job sampleJob

java.lang.InterruptedException: sleep interrupted

これでは困るのでジョブが完了するまで待機するようにできないか調べたのですが、どうやらSpring Batchではそのような機能はないようです。通常は処理完了後に自動で終了するものなので仕方ありませんが、どうにかして待機を実現したかったので独自に実装することにしました。

終了処理の実装

@Component
@RequiredArgsConstructor
@Slf4j
public class SampleBatchShutdownHandler {
  
  private final JobExplorer jobExplorer;

  @EventListener  // 1. イベントを受け取るメソッドを定義する
  public void shutdown(ContextClosedEvent event) throws InterruptedException {
    log.info("実行中のジョブを確認します。");
    for (int count = 0; count <= 30; count++) {
      Set<JobExecution> runningJobs = this.jobExplorer
          .findRunningJobExecutions("sampleJob");  // 2. 実行中のジョブを取得する
      log.info(runningJobs.size() + "個のジョブが実行中です。");
      if (runningJobs.size() > 0) {
        if (count == 30) {
          log.warn("実行完了待ちがタイムアウトしました。");
          break;
        }
        Thread.sleep(1 * 1000);  // 3. 実行中のジョブがなくなるまで待機する
      } else {
        break;
      }
    }
    log.info("アプリケーションを終了します。");
  }
}
  1. @EventListenerを付与したメソッドでContextClosedEventを受け取るようにすると、アプリケーションが終了する直前(正確にはApplicationContextが閉じられた時)に呼び出してくれるようになります。
  2. Spring Batchが生成したJobExplorerがジョブの検索機能を持っており、実行中のジョブを取得することができます。
  3. 実行中のジョブがあれば待機(最大30秒)し、なければそのままメソッドを抜けてアプリケーションの終了に移ります。

このように、Springのイベントの仕組みを利用してジョブの完了を待機する処理を書いてみました。これでジョブの実行中に終了してみると…。

新しいジョブを実行します。{run.id=1}
Job: [SimpleJob: [name=sampleJob]] launched with the following parameters: [{run.id=1}]
Executing step: [step1]
tasklet1が実行されました。
Step: [step1] executed in 3s23ms
Executing step: [step2]
tasklet2が実行されました。
Application shutdown requested.
実行中のジョブを確認します。
1個のジョブが実行中です。
1個のジョブが実行中です。
Step: [step2] executed in 3s6ms
Job: [SimpleJob: [name=sampleJob]] completed with the following parameters: [{run.id=1}] and the following status: [COMPLETED] in 6s50ms
0個のジョブが実行中です。
アプリケーションを終了します。
HikariPool-1 - Shutdown initiated...
HikariPool-1 - Shutdown completed.

この通り、実行中のジョブがなくなるまで待機し、安全に終了できるようになりました!

フレームワークを使うと実装量を減らせたり意識することが少なくなったりして便利ですが、基本的な使い方から少し外れると独自で対応の必要な部分が出てくるかも知れません。その場合はフレームワークに対する理解が問われますので、仕組みを把握して適切な実装をしたいところですね。それでは今回はこの辺で!

あとがき

実は今回の実装は、最初は@EventListenerではなく@PreDestroyを使っていました。@PreDestroyはそのbeanが破棄される時に呼び出されるので本来は他のジョブの待機には間に合わないのですが、古いバージョンのSpring Batchを使っていた時には何故かそれで問題なく動作していたのです。

それがバージョンアップしたら動作しなくなったので、「どこかで仕様が変わったのだろうか?これは技術ブログのネタにできるかも!」と思い、自宅で古いバージョンの環境を用意して再現を試みました。STSやGradleのバージョンも落とす必要があるので、何気に古い環境を作るのって手間が掛かるんですよね。

で、再現させた結果、古いバージョンでも@PreDestroyでは動作しませんでした(笑)つまり原因不明。おそらく他のコンポーネントとの参照関係などによりたまたま最初の方に呼び出されてジョブ監視できていたのだと考えています。

今考えれば@PreDestroyは不適切だと思いますが、調査している時には案外気付けないもの。なので皆さん、フレームワークへの理解は深めておきましょう!

お知らせ

弥生では一緒に働く仲間を募集しています! herp.careers