guzzleで並列処理とリトライをやろうとして折れた話

f:id:tatsuta2:20191209094129p:plain
この記事はBASE Advent Calendar 2019の9日目の記事です。

devblog.thebase.in

はじめまして。BASE株式会社のtatsuと申します。 最近、業務にて guzzle を使う機会がありました。結論から述べますと guzzle のみで実現することは出来ず Amazon sqs を併用するという形で落ち着いたのですが、いくつか知見を得ることも出来たのでその事について書きたいと思います。 主に guzzle/Pool と guzzle/RetryMiddleware の話になります。

最初の壁:ResponseがどのRequestの結果なのか分からん

まず並列処理を実装しました。実際のものとは違いますが流れは一緒。

$urls = [
    'https://example_base.in/1',
    'https://example_base.in/2',
];
$guzzle_client = new Client();
$requests = function ($urls) use ($guzzle_client) {
    foreach ($urls as $url) {
        yield function () use ($guzzle_client, $url) {
            return $guzzle_client->postAsync($url);
        };
    }
};
(new Pool($guzzle_client, $requests($urls), [
    'concurrency' => 10,
    'fulfilled' => function ($response, $index) {
        // dbに保存とか
    },
    'rejected' => function ($reason, $index) {
        // dbに保存,ログ出力とか
    }
]))->promise()->wait();

レスポンスを受け取って保存という段階で 「どのリクエストの結果を受け取っているか分からん。」となりました。 送信は request の生成順に処理されますが、結果はもちろん順不同で返ってきます。 そりゃ並列処理ですからね…

この問題の解決策は簡単で、request 生成部分を少し変えるだけ。

$urls = [
    'index1' => 'https://example_base.in/1',
    'index2' => 'https://example_base.in/2',
];
$requests = function ($urls) use ($guzzle_client) {
    foreach ($urls as $index => $url) {
    yield $index => function () use ($guzzle_client, $url) {
        return $guzzle_client->postAsync($url);
    };
    }
};

これで pool の成否処理の第2引数が'index1','index2'といった値になります。何もしないとrequest の生成順に整数が振られますが、上記の様に任意の値を渡すことも出来ます。

ちなみに失敗時の第1引数には基本的に exception が入ってきます。 下記を参考に処理を分けてあげると良いかもしれません。 http://docs.guzzlephp.org/en/latest/quickstart.html#exceptions

二つ目の壁:Retryの度に結果を保持したい

一応の並列処理は出来た。ということで retry についても考えてみます。 guzzle では RetryMiddleware というリトライの方法を標準で用意してくれています。

// clientに設定
$handler_stack = HandlerStack::create(new CurlHandler());
$handler_stack->push(Middleware::retry(retryDecider(), retryDelay()));
$guzzle_client = new Client(['handler' => $handler_stack]);

function retryDecider()
{
    return function (
    $retries,
    Request $request,
    Response $response = null,
    RequestException $exception = null
    ) {
    // 最大5回
    if ($retries >= 5) {
        return false;
    }
    // 4xx or 5xx はリトライ
    if ($response && $response->getStatusCode() >= 400) {
        return true;
    }
    return false;
    };
}

function retryDelay()
{
    return function ($retries) {
    return (int) pow(2, $retries - 1) * 1000;
    };
}

上記の例はかなりシンプルですが、リトライの判断部分と待機時間を実装してMiddleware::retry()に渡してあげれば良しなにリトライしてくれます。 注意点としては、delay は単位がミリ秒です。timeout は単位が秒なのに http://docs.guzzlephp.org/en/stable/request-options.html#delay

ここでまた問題が生じました。ある理由から最終的な成否だけでなく、retry 時の結果も逐一保持したいとなりました。さて困った。 pool の成否へは request がすべき動作が終わってから到達します。つまり retry 時には実行されません。そうなると retry の中で保持を行う必要があります。 retryDecider()で行いたいところですが、どのリクエストの retry なのか判別する必要があります。上記の様に request が入ってきているので、request-header 等に識別子を設定して判別する事も出来ます。ただ、こちら側の都合を request に持たせるのはどうなのかという考えが浮かび別の方法を取ることにしました。

RetryMiddleware を自作してみました。 …こう言うと凄そうに聞こえるかもしれませんが、実際は標準のものをちょっと変えただけです。 全文は長いので変更箇所の辺りだけです。

private function onFulfilled(RequestInterface $req, array $options)
{
    return function ($value) use ($req, $options) {
        if (!call_user_func(
            $this->decider,
            //$options['retries'],
            $options
            $req,
            $value,
            null
        )) {
            return $value;
        }
        return $this->doRetry($req, $options, $value);
    };
}

上記と同じ事をonRejected()にも deciderに$options['retries'](リトライ回数)では無く$optionsごと渡す様にしました。 これで request-options に識別子を持たせることでリクエストを判別する事ができる様になりました。request-ooptions は key を既存のものと被らない様にすれば影響はないですし、リクエスト先にも渡ることはありません。 $options['retries']自体は$optionsに同keyが無ければ__invoke()で追加されます。つまり RetryMiddleware の外から操作する事も出来るということでもあります。

少し長くなってしまいましたが、これで pool × retry の実装が出来た! …と思っていました。この時は

三つ目の壁:PoolとRetryMiddlewareって併用出来ないの&リトライ中にプロセス死んじゃう

開発の世界で三段オチを味わうことになろうとは…。 それぞれをさっくり解説しますと

PoolとRetryMiddlewareって併用出来ないの

リトライするテストを書いていたら何かおかしいということで検索したところ、「asyncRequest() と RetryMiddleware を併用すると同期してしまいます」といった情報を見つけました。いやいやいや、そんなはずは…と思いつつ試してみました。

方法は簡単でリクエスト毎にリトライの待機時間を変えてみます。 request1 は[2つ目の壁]にある様に回数に応じてべき秒で増えていき、request2 は2秒固定でリトライを繰り返してみます。並列処理が非同期で行われているのであれば、4回目で順番が入れ替わりrequest2が連続するはずです。結果は…?

綺麗にrequest1とrequest2が交互に並び続けました。ワーイ

さらに追い打ちをかける様にもう一つ問題が発覚します。

リトライ中にプロセス死んじゃう

これは一般的ではない環境での事象ですので詳細は省きますが、あるタイミングで実行中のプロセスが強制的に終了させられてしまうという事がわかりました。終了までに多少の猶予があるのですがリトライの待機時間によっては、待機中に強制終了されてしまい終了に備える事が出来ないのです。プロセスの管理側からの操作なので内部で対策をするにも限界がりそうでした。ちなみに強制終了後すぐに再起動されます。

上記二点の問題が浮上し悩んでいた所、同僚からこんな言葉が(実際にはgitのコメント)…。 「リトライをAmazon sqsに任せちゃうのは?」 …なるほど?

結果的にこれが自分がとった解決方法となりました。 sqs に関しての説明は長くなってしまうので「便利なメッセージキューイングサービス」とだけ言わせて頂きます。その sqs の機能の一つで遅延機能があります。 https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-delay-queues.html これと実装してきた guzzle を組み合わせる事で上手くいきそうです。 リトライの判断と待機時間の算出はそのまま使い、失敗してリトライが必要な時に待機時間を持たせて sqs に投げる様にします。 待機自体は sqs でするので強制終了の猶予時間以上の処理は無いので、終了に備える事が出来ます。 イレギュラー中のイレギュラーとはいえ起こり得る事に対処出来て良かった。

まとめ

この方法を採った大きな要因として、そもそも作っていたものが sqs からキューを受け取ってリクエストを送るというものだったという事があります。なので sqs の導入コストはほぼ0でした。 そもそも使っていたなら気づけよとも思いますが、こういう時って離れて視野を広くするのがなかなか難しかったりします。

そんな時に第三者の視点から意見を貰えるのは本当に有り難いですよね。同僚の言葉が無ければ時間を掛けて guzzle で頑張っていたと思います。それも不正解では無いと思いますが、今回は sqs との連携を選んで良かったと感じています。また guzzle で色々やったからこそ、それほど苦労せずに連携させられたという事もあるはずです。

長くなってしまいましたが、業務上の課題は積極的にオープンにすると良さそうというお話でした(guzzleどこいった)。

明日は id:beerbierbearid:yk4o4 の記事です。お楽しみに!