ShoryukenをActiveJobのアダプターとして利用する際のリトライの挙動を調べた

# はじめに

Gakken LEAPで働いていますkoboriです。普段はRuby on Railsを使用したWeb APIの開発に携わっています。その中でShoryuken というgemに触れる機会があり、リトライ制御について調べた話について書いていきます。

# Shoryukenとは

ShoryukenはAmazon SQSに対応した、スレッドベースのメッセージプロセッサで、RubyGemとして提供されています。ShoryukenはプレーンなRubyプログラムに組み込むこともできますが、ActiveJobのアダプターとしても利用することもできます。
今回の記事では、ActiveJobのアダプターとして利用する場合のリトライの挙動について調べました。

# Shoryukenのリトライ制御について

ActiveJob経由でShoryukenを利用する場合、ActiveJobの`retry_on`メソッドを利用してリトライの設定を行うことができます。
しかし、単純に `retry_on` を定義すると、意図しない挙動になることが [wiki](https://github.com/ruby-shoryuken/shoryuken/wiki/Rails-Integration-Active-Job#how-to-use-retry_on)に記載されています。
ActiveJobでは、 `retry_on` に指定した例外が発生した場合、指定された回数リトライされ、上限に達した時点で例外が発生します。
Shoryukenのワーカーの設定には`auto_delete`という項目があり、メッセージを消費した後に自動的に削除するかを選択することができます。`auto_delete`は、ActiveJobのアダプターとしてShoryukenを利用する場合は `true` に設定されます。しかし、`auto_delete` は、例外の発生により処理が終了する場合は発火しません。そのため、処理に失敗したメッセージがキューに残り続けてしまう可能性があるというわけです。

これを回避するため、Shoryukenからは回避策が4つ提示されています。今回はそのうち2つを取り上げてみます。

1つ目は、 `retry_on` にブロック引数を渡す方法です。例外が発生した際に、ブロック内で処理を正常に完了させることで、 `auto_delete` を発火させます。ブロックでは、エラーログの出力や通知処理を行うことが推奨されています。この際、ブロック内で再度例外をあげてしまうと、 `auto_delete` が発火しないため注意が必要です。

```ruby
retry_on RetryError, attempts: N do |_job, _exception|
  # 必要な処理を行う
  # ただし、再度例外をあげてはいけない
end
```

2つ目は、デッドレターキューの `maxReceiveCount` を1に設定する方法です。リトライ回数が上限に達した時点で、メッセージはデッドレターキューに移動されます。デッドレターキュー及び、`maxReceiveCount`の詳細については、[AWSのドキュメント](https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html)に詳細が記載されています。

# Amazon SQSについて

次にAmazon SQSの仕様について確認していきます。[Amazon SQSのドキュメント](https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html)に、メッセージの受付から削除までのフローが記載されています。

> コンシューマーがキューからメッセージを受信して処理しても、そのメッセージはキューに保留されたままです。Amazon SQSでは、メッセージが自動的に削除されません。Amazon SQSは分散システムであり、接続の問題やコンシューマーアプリケーションの問題などが原因で、コンシューマーが実際にメッセージを受信するという保証がありません。そのため、コンシューマーはメッセージを受信して処理した後、キューからメッセージを削除する必要があります。

つまり、メッセージを受け付ける --> メッセージを処理する --> メッセージを削除する という流れでメッセージが処理されているようです。明示的なメッセージの削除、またはデッドレターキューへの移動が必要であることが、Amazon SQSのドキュメントからも読み取ることができました。

# 動かしてみる

Shoryukenのリトライ制御についてある程度分かってきたところで、実際に動かしてメッセージ処理の流れを確かめてみます。今回はShoryuken及びActiveJobとSQSの間のやり取りを大まかに把握することを目的としているため、手軽さ重視して、ローカル環境で検証を実施することにしました。

## 準備

今回検証に利用した環境は下記の通りです。

- ActiveJob: 7.0.8.1
- Ruby: 3.3.2
- shoryuken: 6.2.1
- localstack/localstack(Docker Image): 2.1.0

localstack上のSQSに対しては、CLI経由で下記の操作を行いました。

- メッセージを受け付けるキューを作成(`aws sqs create-queue`)
- デッドレターキューを作成(`aws sqs create-queue`)
- メッセージを受け付けるキューに、デッドレターキューの設定(`aws sqs set-queue-attributes`)
- キューの設定確認(`aws sqs get-queue-attributes`)
  - `maxReceiveCount`: 1

これらの実施にあたり、[localstackのSQSに関するドキュメント](https://docs.localstack.cloud/user-guide/aws/sqs)を参考にしました。

次に、Railsアプリケーションを用意し、メッセージを投げる環境(web環境とします)とメッセージを処理する環境(ワーカー環境とします)を準備します。
そして、それぞれのRailsアプリケーションでは、ActiveJobのアダプターにShoryukenを設定しました。

localstackを使用してローカル環境でShoryukenを動かす方法については、他に言及されている記事が多数ありますので、詳細は割愛しています。

最終的に、web環境からメッセージを投げ、SQSを経由して、ワーカー環境でメッセージを受け取り処理することができれば準備完了です。

## 検証

作成した環境で、メッセージ処理の流れを確認していきます。
キューの設定確認コマンド(`aws sqs get-queue-attributes`)を利用して、設定を確認していきますが、不要な情報は省略して記載します。

今回は下記のサンプルコードを利用して検証を行います。検証するパターンに応じて、コメントアウト及び解除、`retry_on` に渡すオプションの設定値(ここではNとしています)を変更をしながら検証を進めていきます。

SQSへメッセージを投げる際は、rails console を利用しています。

```ruby
class HelloJob < ActiveJob::Base
  class NebouError < StandardError; end

  queue_as ENV['AWS_SQS_QUEUE_NAME']

  # retry_onにブロック引数を渡す場合
  # wait: N.secondの値は適宜設定する
  # retry_on NebouError, wait: N.second, attempts: 3 do |_job, _error|
  #   p '完全に寝坊'
  # end

  # retry_onにブロック引数を渡さない場合
  # wait: N.secondの値は適宜設定する
  retry_on NebouError, wait: N.second, attempts: 3

  def perform(name)
    puts 'sleeping...'
    sleep 5

    # 処理中にエラーを発生させる場合
    # raise NebouError, '二度寝'

    puts "Hello! #{name}"
  end
end
```

### メッセージが正常に処理される場合

まずは、メッセージが正常に処理されるケースの流れを見ていきます。メッセージを投げると、即座にSQSでメッセージが受け付けられ、`ApproximateNumberOfMessages` が1になります。これは、キューにメッセージが1件入っていることを示しています。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "1",
        "ApproximateNumberOfMessagesNotVisible": "0",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-queue",
        "VisibilityTimeout": "30",
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue\",\"maxReceiveCount\":\"1\"}"
    }
}
```

ワーカーからのポーリングによりメッセージの処理が開始されると、`ApproximateNumberOfMessages` が0になり、`ApproximateNumberOfMessagesNotVisible` が1になります。これは、リクエストしたメッセージが処理中になったことを表し、可視性タイムアウトに設定した時間内であれば、他のプロセス/スレッドによって処理されない状態にあります。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "0",
        "ApproximateNumberOfMessagesNotVisible": "1",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-queue",
        "VisibilityTimeout": "30",
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue\",\"maxReceiveCount\":\"1\"}"
    }
}
```

ワーカーの処理が完了すると、`ApproximateNumberOfMessagesNotVisible` が0になります。Shoryukenの `auto_delete` が動作していることが確認できます。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "0",
        "ApproximateNumberOfMessagesNotVisible": "0",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-queue",
        "VisibilityTimeout": "30",
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue\",\"maxReceiveCount\":\"1\"}"
    }
}
```

### メッセージが正常に処理されない場合(デッドレターキューへの移動)

ワーカーの処理の途中で例外を発生させ、`retry_on` によって、5秒間隔で3回リトライさせてみます。`retry_on` にはブロック引数を渡しません。

メッセージを処理が開始されるまでの流れは先ほどと同様です。
しかし、今回は例外により処理が正常に終了しないため、おおよそ指定した間隔で指定した回数のリトライが行われました。

リトライ回数が上限に達した時点で、 `maxReceiveCount` に設定した回数を超えたため、メッセージはデッドレターキューへ移されることを期待します。

下記は、リトライ回数が上限に達した後のキューの状態です。キューにメッセージが残っていないことが確認できます。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "0",
        "ApproximateNumberOfMessagesNotVisible": "0",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-queue",
        "VisibilityTimeout": "30",
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue\",\"maxReceiveCount\":\"1\"}"
    }
}
```

こちらがデッドレターキューの状態です。デッドレターキューにメッセージが1つ追加されたことが確認できます。期待した挙動を得ることができました。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "1",
        "ApproximateNumberOfMessagesNotVisible": "0",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue",
        "VisibilityTimeout": "30",
    }
}
```

### メッセージが正常に処理されない場合(キューにメッセージが残る)

ワーカーの処理の途中で例外を発生させ、retry_on によって、5秒間隔で3回リトライさせてみます。retry_on にはブロック引数を渡しません。
ここまでは先ほどと同じですが、今回は `maxReceiveCount` を4に設定しました。

先ほどと同様に、指定した回数のリトライが行われ、リトライ回数が上限に達した時点で、例外が発生します。しかし、今回はリトライ回数の上限が、`maxReceiveCount` より小さいため、メッセージがキューに残ることを期待します。

下記はリトライ回数が上限に達した後のキューの状態です。キューにメッセージが残っていることを確認できました。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "0",
        "ApproximateNumberOfMessagesNotVisible": "1",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-queue",
        "VisibilityTimeout": "30",
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue\",\"maxReceiveCount\":\"4\"}"
    }
}
```

この時点で、デッドレターキューのメッセージ数も変化はありません。ここまでは期待通りです。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "1",
        "ApproximateNumberOfMessagesNotVisible": "0",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue",
        "VisibilityTimeout": "30",
    }
}
```

しかし、少し待ってから再度確認すると、デッドレターキューにメッセージが移っていました。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "2",
        "ApproximateNumberOfMessagesNotVisible": "0",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue",
        "VisibilityTimeout": "30",
    }
}
```

先ほど確認した通り、SQSが自動的にメッセージを削除することはありません。となると、可視性タイムアウトに設定した時間を超えた後、ワーカーが再度処理を開始し、`maxReceiveCount` に設定した回数を超えた時点で、メッセージがデッドレターキューに移動した可能性が考えられます。
しかし、ワーカーのログ上では、`retry_on` で指定した回数以上の実行は行われていないように見えるため、ActiveJob側で何かしらの制御がされているのかもしれません。(この記事を執筆する中では追い切れませんでした。)
処理に失敗したメッセージがキューに残り続けるという最悪の事態は避けることができそうですが、良いやり方とは言えなさそうです。

### メッセージが正常に処理されない場合(メッセージを削除)

今度は、`retry_on` にブロック引数を渡すパターンで検証を行います。他は先ほどと同様に、5秒間隔で3回リトライさせ、`maxReceiveCount` は4に設定します。
ここでは `retry_on` に設定したリトライ回数が上限に達した時点でメッセージが削除されることを期待します。

これまでと同様に、ワーカーでメッセージの処理が開始されることを確認します。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "0",
        "ApproximateNumberOfMessagesNotVisible": "1",
        "ApproximateNumberOfMessagesDelayed": "0",
        "CreatedTimestamp": "1724394803",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-queue",
        "VisibilityTimeout": "30",
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue\",\"maxReceiveCount\":\"4\"}"
    }
}
```

リトライ回数が上限に達した後、メッセージが削除されることが確認できました。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "0",
        "ApproximateNumberOfMessagesNotVisible": "0",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-queue",
        "VisibilityTimeout": "30",
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue\",\"maxReceiveCount\":\"4\"}"
    }
}
```

### メッセージが正常に処理されない場合(可視性タイムアウト < リトライに要する時間)

最後に、60秒間隔で3回リトライさせ、`maxReceiveCount` を1に設定します。`retry_on` には、ブロック引数を渡し、リトライ上限に達した時点でメッセージが削除されるよう設定します。

可視性タイムアウトは30秒に設定しており、リトライ中に可視性タイムアウトに設定した時間を超えた場合に、メッセージが削除されるかを確認します。

実行を開始し、可視性タイムアウトに設定した30秒を超え、リトライ待機中になると、`ApproximateNumberOfMessagesDelayed` が1になることが確認できました。メッセージが遅延パラメータとともに送信され、すぐにワーカーが処理することができない状態にあることを示しています。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "0",
        "ApproximateNumberOfMessagesNotVisible": "0",
        "ApproximateNumberOfMessagesDelayed": "1",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-queue",
        "VisibilityTimeout": "30",
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue\",\"maxReceiveCount\":\"1\"}"
    }
}
```

リトライ待機時間が終わり、次のリトライ処理に移ると、`ApproximateNumberOfMessagesNotVisible` にメッセージが移動します。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "0",
        "ApproximateNumberOfMessagesNotVisible": "1",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-queue",
        "VisibilityTimeout": "30",
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue\",\"maxReceiveCount\":\"1\"}"
    }
}
```

そして、リトライ回数が上限に達した後、メッセージが削除されることを確認しました。

`ApproximateNumberOfMessagesDelayed` により、可視性タイムアウトに設定した時間を超えた後でも、リトライが指定した回数実行することができることが分かりました。

```json
{
    "Attributes": {
        "ApproximateNumberOfMessages": "0",
        "ApproximateNumberOfMessagesNotVisible": "0",
        "ApproximateNumberOfMessagesDelayed": "0",
        "QueueArn": "arn:aws:sqs:ap-northeast-1:000000000000:local-queue",
        "VisibilityTimeout": "30",
        "RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:local-dead-letter-queue\",\"maxReceiveCount\":\"1\"}"
    }
}
```

# さいごに

メッセージの状態は確認しながら検証を進め、ShoryukenとSQSのやり取りを見てきました。
今回は検証用ということで、 `retry_on` のオプションは雑に設定しましたが、実際の運用では、リトライ間隔やリトライ回数を検討する必要があります。詳しくは、[リファレンス](https://api.rubyonrails.org/classes/ActiveJob/Exceptions/ClassMethods.html#method-i-retry_on)に記載がありますので、実装の際にはご参照ください。

# エンジニア募集

Gakken LEAP では教育をアップデートしていきたいエンジニアを募集しています。
ぜひカジュアル面談でお話できればと思います。
https://gakken-leap.co.jp/recruit/