【実務・中級編】Fiberを用いたサーキットブレーカーパターンの実装:外部サービス障害からの回復力強化 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

序:なぜPHPでFiberなのか? 外部API依存時代の生存戦略

外部APIやマイクロサービスへの依存度が高まる現代のWebシステムにおいて、ネットワークの遅延や障害は「例外」ではなく「常時発生する前提条件」として扱わなければならない。

PHP 8.1で導入された Fiber(ファイバー) は、シングルスレッドのZend VM上で協調的マルチタスク(Cooperative Multitasking)を実現するパラダイムシフトをもたらした。従来の `ext-async` や複雑なEvent Loop依存のノンブロッキングI/Oから脱却し、コールバック地獄(Pyramid of Doom)に陥ることなく、手続き型の美しいコードを書いたまま非同期並行処理を行える。

しかし、Fiberの真価は単なる「並行リクエストの処理」にあるのではない。「処理の中断(suspend)と再開(resume)」というZend VMレベルのプリミティブを完全に掌握し、システムを守る防壁(サーキットブレーカー)を構築することこそが、シニアエンジニアが到達すべき極地である。

本稿では、Zend VMのスタックフレームとメモリ管理の挙動を踏まえつつ、Fiberを用いた「ノンブロッキング・サーキットブレーカーパターン」の完全実装を解説する。

—

1. 内部挙動の理解:FiberとZend VMのスタック管理

まず、PHPのFiberが内部でどのように動作しているかを低レイヤの視点から押さえておく。

通常、PHPの関数呼び出しはコールスタック(Call Stack)に積まれ、Zend VMが順次実行していく。Fiberを生成すると、そのFiber専用の独立したC言語レベルのスタック領域(zend_execute_dataなどを含む)がヒープ上に割り当てられる。

  • `Fiber::suspend()` が呼び出されると、現在のZend VMの実行コンテキストが退避され、制御権が親スコープ(イベントループやメインルーチン)に即座に戻る。
  • `Fiber::resume($value)` が呼ばれると、退避されたコンテキストが復元され、suspendした地点から処理が再開される。

このメカニズムを利用すれば、外部APIへのHTTPリクエスト(通常はI/O待ちでプロセス全体がブロックされる)の待機時間を、「他のFiberの実行に割り当てる(Yieldする)」 ことが可能になる。

—

2. サーキットブレーカーパターンの設計要件

サーキットブレーカー(回路遮断器)は、以下の3つの状態を持つステートマシンである。

1. CLOSED(通常状態): すべてのリクエストを外部サービスに送る。エラー率が閾値を超えると `OPEN` に遷移する。
2. OPEN(遮断状態): 外部サービスへのリクエストを即座に拒否(Fail-fast)し、フォールバックを返す。一定時間が経過すると `HALF-OPEN` に遷移する。
3. HALF-OPEN(半開状態): 制限付きでリクエストを少量通し、成功すれば `CLOSED` に復旧、失敗すれば再び `OPEN` に戻す。

これをFiberベースのイベントループ上で実装する際最大の課題となるのが、「状態の永続化と競合防護」である。FPM環境下ではプロセス間でメモリが共有されないため、実務ではShared Memory(APCuやお世辞にも高速とは言えないRedis)を介して状態を同期させる必要がある。今回はメモリ効率とシミュレーションの明快さを考慮し、静的プロパティおよびストアを抽象化したコードを示す。

—

3. 実装:Fiber対応サーキットブレーカー付きHTTPクライアント

以下のコードは、PHP 8.2+を前提とした、Fiber対応のサーキットブレーカー統合型HTTPクライアントの実装である。実務のコードレビューに通る、型安全かつ堅牢な設計に仕上げている。

declare(strict_types=1);

namespace App\Resilience;

use Fiber;
use Throwable;

/

  • サーキットブレーカーのステータス定義

/
enum CircuitState: string
{
case CLOSED = ‘closed’;
case OPEN = ‘open’;
case HALF_OPEN = ‘half_open’;
}

/

  • 簡易的なストレージインターフェース(実戦ではAPCuやRedisなどに置き換える)

/
interface CircuitBreakerStoreInterface
{
public function getState(string $serviceKey): CircuitState;
public function setState(string $serviceKey, CircuitState $state): void;
public function recordFailure(string $serviceKey): int;
public function recordSuccess(string $serviceKey): void;
public function getFailureCount(string $serviceKey): int;
public function reset(string $serviceKey): void;
public function getOpenTimestamp(string $serviceKey): float;
public function setOpenTimestamp(string $serviceKey, float $timestamp): void;
}

/

  • メモリ上での簡易ストア実装(プロセス内キャッシュ用)

/
class MemoryCircuitBreakerStore implements CircuitBreakerStoreInterface
{
private array $states = [];
private array $failures = [];
private array $openTimestamps = [];

public function getState(string $serviceKey): CircuitState
{
return $this->states[$serviceKey] ?? CircuitState::CLOSED;
}

public function setState(string $serviceKey, CircuitState $state): void
{
$this->states[$serviceKey] = $state;
}

public function recordFailure(string $serviceKey): int
{
$this->failures[$serviceKey] = ($this->failures[$serviceKey] ?? 0) + 1;
return $this->failures[$serviceKey];
}

public function recordSuccess(string $serviceKey): void
{
$this->failures[$serviceKey] = 0;
$this->states[$serviceKey] = CircuitState::CLOSED;
}

public function getFailureCount(string $serviceKey): int
{
return $this->failures[$serviceKey] ?? 0;
}

public function reset(string $serviceKey): void
{
$this->failures[$serviceKey] = 0;
$this->states[$serviceKey] = CircuitState::CLOSED;
unset($this->openTimestamps[$serviceKey]);
}

public function getOpenTimestamp(string $serviceKey): float
{
return $this->openTimestamps[$serviceKey] ?? 0.0;
}

public function setOpenTimestamp(string $serviceKey, float $timestamp): void
{
$this->openTimestamps[$serviceKey] = $timestamp;
}
}

/

  • Fiberを活用したサーキットブレーカー

/
class FiberCircuitBreaker
{
private string $serviceKey;
private CircuitBreakerStoreInterface $store;
private int $failureThreshold;
private float $recoveryTimeout;

public function __construct(
string $serviceKey,
CircuitBreakerStoreInterface $store,
int $failureThreshold = 3,
float $recoveryTimeout = 5.0
) {
$this->serviceKey = $serviceKey;
$this->store = $store;
$this->failureThreshold = $failureThreshold;
$this->recoveryTimeout = $recoveryTimeout;
}

/

  • 外部サービス呼び出しの実行制御
  • @param callable $task 非同期(または同期)で実行するタスク
  • @param callable|null $fallback 失敗時やOPEN時のフォールバック処理
  • @return mixed

/
public function execute(callable $task, ?callable $fallback = null): mixed
{
$this->checkAndUpdateState();

$currentState = $this->store->getState($this->serviceKey);

// OPEN状態の場合は即座にFail-fastし、フォールバックへ逃げる
if ($currentState === CircuitState::OPEN) {
if ($fallback !== null) {
return $fallback();
}
throw new \RuntimeException(“Circuit breaker is OPEN for service: {$this->serviceKey}”);
}

try {
// Fiber内での実行であれば、I/O待ちの際にsuspendを挟む設計が可能
$result = $task();

// 成功時のハンドリング
$this->onSuccess();
return $result;

} catch (Throwable $e) {
// 失敗時のハンドリング
$this->onFailure($e);

if ($fallback !== null) {
return $fallback();
}
throw $e;
}
}

private function checkAndUpdateState(): void
{
$state = $this->store->getState($this->serviceKey);

if ($state === CircuitState::OPEN) {
$openTime = $this->store->getOpenTimestamp($this->serviceKey);
// 復旧タイムアウト時間を経過していたら HALF_OPEN へ移行
if ((microtime(true) – $openTime) >= $this->recoveryTimeout) {
$this->store->setState($this->serviceKey, CircuitState::HALF_OPEN);
}
}
}

private function onSuccess(): void
{
$state = $this->store->getState($this->serviceKey);
if ($state === CircuitState::HALF_OPEN || $state === CircuitState::CLOSED) {
$this->store->recordSuccess($this->serviceKey);
}
}

private function onFailure(Throwable $e): void
{
// ネットワークエラーやタイムアウトなど、障害とみなすべき例外を精査するのが実務では望ましい
$failures = $this->store->recordFailure($this->serviceKey);
$state = $this->store->getState($this->serviceKey);

if ($state === CircuitState::HALF_OPEN || $failures >= $this->failureThreshold) {
$this->store->setState($this->serviceKey, CircuitState::OPEN);
$this->store->setOpenTimestamp($this->serviceKey, microtime(true));
}
}
}

—

4. イベントループとFiberの統合:ノンブロッキングな並行実行

サーキットブレーカーを単体で動かしても意味がない。これをFiberベースのミニイベントループと組み合わせ、複数APIへの並行リクエスト時に障害サービスを高速にバイパスするデモを見てみよう。

/

  • 簡易イベントループマネージャー

/
class FiberAsyncPool
{
/ @var Fiber[] /
private array $fibers = [];

public function add(callable $task): void
{
$this->fibers[] = new Fiber($task);
}

public function run(): void
{
// 全Fiberの起動
foreach ($this->fibers as $fiber) {
if (!$fiber->isStarted()) {
$fiber->start();
}
}

// 協調的マルチタスクのポーリングループ
while (count($this->fibers) > 0) {
foreach ($this->fibers as $key => $fiber) {
if ($fiber->isTerminated()) {
unset($this->fibers[$key]);
continue;
}

// サスペンド状態から再開可能な場合の処理
if ($fiber->isSuspended()) {
try {
$fiber->resume();
} catch (Throwable $e) {
// 例外キャッチとログ記録
echo “Fiber Error: ” . $e->getMessage() . “\n”;
unset($this->fibers[$key]);
}
}
}
// CPUの過剰消費を防ぐためのマイクロ睡眠(実環境ではepoll/select等に基づく)
usleep(1000);
}
}
}

// — 実行シミュレーション —
$store = new MemoryCircuitBreakerStore();
$circuitBreaker = new FiberCircuitBreaker(‘payment-api’, $store, failureThreshold: 2, recoveryTimeout: 2.0);

$pool = new FiberAsyncPool();

// タスク1: 不安定な外部決済APIをシミュレート
$pool->add(function() use ($circuitBreaker) {
for ($i = 1; $i <= 5; $i++) { try { $result = $circuitBreaker->execute(
task: function() use ($i) {
// 3回目以降に障害(例外)が発生すると仮定
if ($i >= 3) {
throw new \Exception(“Connection Timeout to Payment Gateway”);
}
// 擬似的なI/O待ち(Fiberをsuspendして他へ処理を譲ることも可能)
return “Payment Success: Order #{$i}”;
},
fallback: function() {
return “Fallback: Payment queued for later processing.”;
}
);
echo “[Loop $i] $result\n”;
} catch (\Throwable $e) {
echo “[Loop $i] Exception caught: ” . $e->getMessage() . “\n”;
}

// 処理間隔のシミュレーション
usleep(500000); // 0.5秒
}
});

$pool->run();

実行結果の読み解き

上記のコードを実行すると、最初の2回は成功するが、3回目で例外が発生してエラーカウントが閾値(2回)に達した瞬間にサーキットブレーカーが `OPEN` に遷移する。
その後、4回目と5回目のループでは `task()` すら実行されず、即座に `fallback` が発動してシステム全体の硬直(スレッド枯渇やタイムアウト待ちによるレスポンス遅延)を防ぎながら動作し続ける。

—

5. コードレビューの視点:実務における罠と回避策

シニアエンジニアとして、このコードをチームに導入する際、以下のポイントをコードレビューで厳しくチェックしてほしい。

1. プロセス境界とステートの共有(FPM vs Swoole/RoadRunner)

PHP-FPM環境において、メモリ上(`MemoryCircuitBreakerStore`)の状態は1リクエスト(または同一プロセス内の後続リクエスト)でしか共有されない。FPMのマルチプロセスモデルでは、プロセスごとにカウンターがバラバラになるため、本番環境でサーキットブレーカーを機能させるには、RedisやAPCuなどの外部アトミックストアを使用することが絶対条件となる。一方、SwooleやRoadRunnerのような常駐型(Persistent)アプリケーションサーバーであれば、メモリ上のストアをそのまま安全にプロセス間で共有・維持できる。

2. 例外のフィルタリング

すべての `Throwable` をエラーとしてカウントしてはならない。400 Bad Request(クライアント起因のエラー)やバリデーションエラーは外部サービスの「障害」ではなくビジネスロジック上の想定内である。サーキットブレーカーが反応すべきなのは、5xx系エラー、ネットワークタイムアウト、DNS名前解決失敗など、インフラストラクチャー層の障害に限定すべきである。

3. Fiberのリークとメモリプレッシャー

Fiber内で未処理の例外が発生した場合や、無限ループに近い構造を組んだ場合、Zend VMのヒープ上に不要なスタックフレームが残留し、メモリリークを引き起こす可能性がある。必ず `try-catch` でFiber内の例外をハンドリングし、終了ライフサイクルを明確に管理すること。

—

結:PHPは「ただのテンプレート言語」ではない

多くのプログラマは、PHPをいまだに「HTMLに埋め込むスクリプト言語」あるいは「リクエストごとにすべてを破棄する単純なWeb言語」と誤認している。しかし、近年のPHP(8.x系)は、JITコンパイラ、正確な型システム、そしてFiberによる協調的非同期処理を備えた、極めて強力でモダンなシステム開発言語へと進化を遂げた。

外部サービスの障害に屈しない、レジリエント(回復力のある)なWebアプリケーションの設計。その基盤を支えるのは、Zend VMの挙動を熟知した上で構築された、堅牢なアーキテクチャに他ならない。

あなたのコードベースにFiberとサーキットブレーカーを組み込み、真に安定した高可用性システムを構築してほしい。

タイトルとURLをコピーしました