Abstruct

Circuit Breaker( opossum ) を使ってみたので、サーキットブレーカーの概要を説明した後、簡易実装サンプルとその実行結果について説明します。

1. About Circuit Breaker

サーキットブレーカーとは、あるサービスの障害を検知した場合には通信を遮断、その後サービスの復旧を検知すると通信を復旧させる仕組みのことです。

複数のマイクロサービスが連動するサービスの場合、一部のマイクロサービスの障害が連鎖的な障害に繋がるカスケード障害を発生させる可能性があります。

要するに、以下のような事態に陥ってしまうのを防ぐためのものです。

1
2
3
4
5
Service A -> Service B -> Service C(障害発生)
↓(Service Bでタイムアウトが多発)
Service A -> Service B(障害発生) -> Service C(障害発生)

サーキットブレーカーの元ネタ
電気回路のブレーカーのことで、事故や故障で大きな電流が流れたときに自動的に遮断して配線を保護する機構のことを指すらしいです。

今回は、Node.js のCircuit Breakerライブラリの中でも一番よく使われている opossum を使います。

参考:brakes vs circuit-breaker-js vs circuitbreaker vs levee vs opossum | npm trends

1.1. State Transition Diagram

サーキットブレーカーはステートマシンとして表現されます。

というのも、サーキットブレーカーには以下の状態(ステート)があり、その状態によって要求された処理を実行をする/しないが決定されるからです。

  • closed
  • open
  • half open

状態遷移図

  • closed

    • サーキットブレーカーの初期状態
    • この状態の場合、要求された処理が実行される
    • 実行した結果、エラー回数が閾値を超えると open 状態に遷移する
  • open

    • この状態の場合、要求された処理を実行せず fallback 処理が実行される
    • 指定した時間(reset time)が経過すると half open 状態に遷移する
  • half open

    • この状態の場合、要求された処理が実行される。
    • 実行した結果、成功回数が閾値を超えると 正常な状態に回復したとみなして closed に遷移する
    • 実行した結果、エラー回数が閾値を超えると open 状態に遷移する

2. Sample Application

ここまでで説明した Circuit Breakerライブラリ opossum の実装サンプルを以下に載せます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import CircuitBreaker from 'opossum'
import { log } from '@/utils/log'
import { wait } from '@/utils/wait'
import { OpenBreakerError, SemaphoreError, ShutdownError, TimeoutError } from '@/error/Error'

// サーキットブレーカーから返却されるエラーコード
const ErrorCode = {
  OPEN: 'EOPENBREAKER',
  SHUTDOWN: 'ESHUTDOWN',
  TIMEOUT: 'ETIMEDOUT',
  SEMAPHORE: 'ESEMLOCKED',
} as const

// サーキットブレーカーの対象となる非同期関数を実装
async function slowEcho(message: string, waitSecond = 0): Promise<string> {
  await wait(waitSecond)
  return message
}

const options = {
  timeout: 3000, // If our function takes longer than 3 seconds, trigger a failure
  errorThresholdPercentage: 50, // When 50% of requests fail, trip the circuit
  resetTimeout: 10000, // After 30 seconds, try again.
  capacity: 10 // The number of concurrent executions is 10
}

// CircuitBreakerに引数で実装した非同期関数を渡す
const breaker = new CircuitBreaker(slowEcho, options)

breaker.on('timeout', () => {
  log('timeout')
})
breaker.on('open', () => {
  log('open')
})
breaker.on('halfOpen', () => {
  log('halfOpen')
})
breaker.on('close', () => {
  log('close')
})
breaker.on('semaphoreLocked', () => {
  logWithTime('semaphoreLocked')
  //'Breaker is at capacity and cannot execute the request'
})

// fallbackで非同期関数でエラーが発生した場合の処理を実装
// 引数の最後に、`error` が渡されることは、公式ドキュメントに明記されていないため、
// opossum のソースコードを読んで、仕様を理解した。
breaker.fallback((message: string, waitSecond: number, error: { code: string }) => {
  const msg = `Sorry, out of service right now. But your parameters are: ${message}, ${waitSecond}`
  switch (error.code) {
    case ErrorCode.OPEN:
      throw new OpenBreakerError(msg)
    case ErrorCode.SHUTDOWN:
      throw new ShutdownError(msg)
    case ErrorCode.TIMEOUT:
      throw new TimeoutError(msg)
    case ErrorCode.SEMAPHORE:
      throw new SemaphoreError(msg)
    default:
      throw error
  }
})

// [0, 1, 2, ...]
const waitSeconds = [...Array(10).keys()]

;(async () => {
  // 失敗率が 50% を超えると、openになり、全てのリクエストを拒否するようになる。
  for (let i = 0; i < waitSeconds.length; i++) {
    const waitSecond = waitSeconds[i]
    log(`waitSecond = ${waitSecond}`)
    await breaker.fire('hello.', waitSecond).then(log).catch(console.error)
  }
  // しばらく時間が経つと、half open になる。
  log(`waitSecond = 10`)
  await wait(10)

  // half openの間に成功するリクエストがあると、close になる。
  log(`sucess request`)
  await breaker.fire('hello.', 1).then(log).catch(console.error)

  // capacityで設定した 10 を超える並列数で実行すると、
  // semaphoreLocked 状態となる。
  // この状態になると、11個目のリクエストが来た際、fallbackの処理が実行されるようになる。
  const parallelNum = [...Array(11).keys()]
  const ret = await Promise.all(
    parallelNum.map(() => breaker.fire('hello.', 2))
  );
  log(ret)
})()

2.1. Execution Result

実行結果に簡易なメモを付けておきます。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
takuto-n@MacBook-Air opossum % yarn start                                                                   master
yarn run v1.22.18
$ node .dist/index.js
# timeout で設定している3秒未満の実行時間であるため、
# 処理が正常に完了している。
Mon Mar 28 2022 15:29:22 GMT+0900 (日本標準時) waitSecond = 0
Mon Mar 28 2022 15:29:22 GMT+0900 (日本標準時) hello.
Mon Mar 28 2022 15:29:22 GMT+0900 (日本標準時) waitSecond = 1
Mon Mar 28 2022 15:29:23 GMT+0900 (日本標準時) hello.
Mon Mar 28 2022 15:29:23 GMT+0900 (日本標準時) waitSecond = 2
Mon Mar 28 2022 15:29:25 GMT+0900 (日本標準時) hello.
# timeout を 3秒に設定しているので、3秒以上の場合はタイムアウトとなる。
Mon Mar 28 2022 15:29:25 GMT+0900 (日本標準時) waitSecond = 3
Mon Mar 28 2022 15:29:28 GMT+0900 (日本標準時) timeout
# timeout や semaphoreLocked など circuit breaker によって、
# 処理が中断される場合、fallback 処理が実行される。
# 今回は、fallback処理の中で、TimeoutError をスローしており、
# circuit-breaker.fire() から例外がスローされるので、catch()やtry-catch文によってキャッチ可能。
TimeoutError: Sorry, out of service right now. But your parameters are: hello., 3
    at Function.<anonymous> (/takuto-n/javascript/opossum/.dist/index.js:50:19)
    at fallback (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:756:10)
    at handleError (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:744:16)
    at Timeout.<anonymous> (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:600:15)
    at listOnTimeout (node:internal/timers:559:17)
    at processTimers (node:internal/timers:502:7)
Mon Mar 28 2022 15:29:28 GMT+0900 (日本標準時) waitSecond = 4
Mon Mar 28 2022 15:29:31 GMT+0900 (日本標準時) timeout
TimeoutError: Sorry, out of service right now. But your parameters are: hello., 4
    at Function.<anonymous> (/takuto-n/javascript/opossum/.dist/index.js:50:19)
    at fallback (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:756:10)
    at handleError (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:744:16)
    at Timeout.<anonymous> (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:600:15)
    at listOnTimeout (node:internal/timers:559:17)
    at processTimers (node:internal/timers:502:7)
Mon Mar 28 2022 15:29:31 GMT+0900 (日本標準時) waitSecond = 5
Mon Mar 28 2022 15:29:34 GMT+0900 (日本標準時) timeout
# 0, 1, 2秒で成功し、3, 4, 5秒で失敗したため、失敗率が50%を超えた。
# そのため、Circuit Breaker が open した。
# これ以降は、resetTimeout として設定した10秒が経過するまで Circuit Breakerは open のままで、処理を受け付けない。
# (処理を受け付けないので、以降waitSecond 6-9では、実行時間の秒数が変わっていない。)
Mon Mar 28 2022 15:29:34 GMT+0900 (日本標準時) open
TimeoutError: Sorry, out of service right now. But your parameters are: hello., 5
    at Function.<anonymous> (/takuto-n/javascript/opossum/.dist/index.js:50:19)
    at fallback (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:756:10)
    at handleError (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:744:16)
    at Timeout.<anonymous> (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:600:15)
    at listOnTimeout (node:internal/timers:559:17)
    at processTimers (node:internal/timers:502:7)
Mon Mar 28 2022 15:29:34 GMT+0900 (日本標準時) waitSecond = 6
OpenBreakerError: Sorry, out of service right now. But your parameters are: hello., 6
    at Function.<anonymous> (/takuto-n/javascript/opossum/.dist/index.js:46:19)
    at fallback (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:756:10)
    at CircuitBreaker.call (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:574:14)
    at CircuitBreaker.fire (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:499:22)
    at /takuto-n/javascript/opossum/.dist/index.js:62:23
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
Mon Mar 28 2022 15:29:34 GMT+0900 (日本標準時) waitSecond = 7
OpenBreakerError: Sorry, out of service right now. But your parameters are: hello., 7
    at Function.<anonymous> (/takuto-n/javascript/opossum/.dist/index.js:46:19)
    at fallback (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:756:10)
    at CircuitBreaker.call (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:574:14)
    at CircuitBreaker.fire (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:499:22)
    at /takuto-n/javascript/opossum/.dist/index.js:62:23
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
Mon Mar 28 2022 15:29:34 GMT+0900 (日本標準時) waitSecond = 8
OpenBreakerError: Sorry, out of service right now. But your parameters are: hello., 8
    at Function.<anonymous> (/takuto-n/javascript/opossum/.dist/index.js:46:19)
    at fallback (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:756:10)
    at CircuitBreaker.call (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:574:14)
    at CircuitBreaker.fire (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:499:22)
    at /takuto-n/javascript/opossum/.dist/index.js:62:23
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
Mon Mar 28 2022 15:29:34 GMT+0900 (日本標準時) waitSecond = 9
OpenBreakerError: Sorry, out of service right now. But your parameters are: hello., 9
    at Function.<anonymous> (/takuto-n/javascript/opossum/.dist/index.js:46:19)
    at fallback (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:756:10)
    at CircuitBreaker.call (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:574:14)
    at CircuitBreaker.fire (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:499:22)
    at /takuto-n/javascript/opossum/.dist/index.js:62:23
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
# ここで単に10秒待つ処理を実行する。
# resetTimeout が 10秒なので、これでCircuitBreakerの状態が halfopen に遷移する。
Mon Mar 28 2022 15:29:34 GMT+0900 (日本標準時) waitSecond = 10
Mon Mar 28 2022 15:29:44 GMT+0900 (日本標準時) halfOpen
# ここで成功する処理を実行する。
# halfopen 時に処理が成功したので、CircuitBreakerの状態が close に遷移する。
Mon Mar 28 2022 15:29:44 GMT+0900 (日本標準時) sucess request
# CircuitBreakerの状態が close に遷移したので、無事処理が実行された。
Mon Mar 28 2022 15:29:45 GMT+0900 (日本標準時) close
Mon Mar 28 2022 15:29:45 GMT+0900 (日本標準時) hello.
# capacity で設定した10を超えた並列度(11)で実行したので、
# 最後の1リクエスト分が失敗している。
Mon Mar 28 2022 15:29:45 GMT+0900 (日本標準時) semaphoreLocked
SemaphoreError: Sorry, out of service right now. But your parameters are: hello., 2
    at Function.<anonymous> (/takuto-n/javascript/opossum/.dist/index.js:52:19)
    at fallback (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:756:10)
    at handleError (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:744:16)
    at /takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:649:9
    at new Promise (<anonymous>)
    at CircuitBreaker.call (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:581:12)
    at CircuitBreaker.fire (/takuto-n/javascript/opossum/node_modules/opossum/lib/circuit.js:499:22)
    at /takuto-n/javascript/opossum/.dist/index.js:69:65
    at Array.map (<anonymous>)
    at /takuto-n/javascript/opossum/.dist/index.js:69:47
[
  'hello.',  'hello.',
  'hello.',  'hello.',
  'hello.',  'hello.',
  'hello.',  'hello.',
  'hello.',  'hello.',
  undefined # semaphoreLocked の状態になっているときに、最後に実行された1リクエスト分は実行されていないため、undefined になっている。
]

3. Statistics

opossum ライブラリでは、Circuit Breakerで実行された処理の統計情報を出力することができる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
  failures: 1,
  fallbacks: 0,
  successes: 11,
  rejects: 0,
  fires: 12,
  timeouts: 0,
  cacheHits: 0,
  cacheMisses: 0,
  semaphoreRejections: 1,
  percentiles: {
    '0': 0,
    '1': 2010,
    '0.25': 2000,
    '0.5': 2000,
    '0.75': 2001,
    '0.9': 2001,
    '0.95': 2010,
    '0.99': 2010,
    '0.995': 2010
  },
  latencyTimes: [
       0, 1005, 2000,
    2000, 2000, 2000,
    2000, 2000, 2001,
    2001, 2001, 2010
  ],
  latencyMean: 1751.5
}

4. Coding precautions

4.1. 1st Point

以下のようなイベントハンドラ内で例外をスローせず、エラーハンドングは fallback() 内で実装すること。

  • timeout
  • semaphoreLocked
  • open
  • halfOpen
  • close
1
2
3
4
breaker.on('timeout', () => {
  logWithTime('timeout')
  throw new Error('timeout') // ← ここで例外はスローしないこと
})
  • 理由
    • 各イベント(timeoutsemaphoreLocked など)の発火は try-catch で囲われていないため、自前の処理ロジック内で適切なハンドリングができないから。
1
2
3
4
5
6
OutOfServiceError: timeout
    at CircuitBreaker.<anonymous> (/takuto-n/javascript/.dist/index.js:23:11)
    at CircuitBreaker.emit (node:events:402:35)
    at Timeout._onTimeout (/takuto-n/javascript/node_modules/opossum/lib/circuit.js:599:20)
    at listOnTimeout (node:internal/timers:557:17)
    at processTimers (node:internal/timers:500:7)

4.2. 2nd Point

接続先単位で CircuitBreaker を作成すること

4.2.1. Bad Example

以下のように、どの接続先にでも対応可能なメソッドを 1つの CircuitBreaker で作成すること。

  • 理由
    • ある接続先が落ちてしまった際に、ほかの接続先にもつながらなくなってしまうため、このような実装は避けるべき。
    • たとえば、
      • ServiceA が落ちてしまったことによって、Circuit Breaker が open になってしまい、ServiceB にも接続できなくなってしまうからです。
1
2
3
4
export const axiosBackendCall = new CircuitBreaker(axios.post, circuitBreakerOptions)

axiosBackendCall.fire('serviceA:8080/check', args);
axiosBackendCall.fire('serviceB:8081/register', args);

4.2.2. Good Example

以下のように接続先ごとに、CircuitBreaker を作るようにすると良いです。

  • 理由
    • ある接続先が落ちてしまっても、ほかの接続先には影響なく接続できるため。
    • たとえば、
      • ServiceA が落ちてしまった場合、ServiceA 用の Circuit Breakerが open になってしまうが、
      • ServiceB 用の Circuit Breaker は別モノであるため、継続して ServiceB に接続できるからです。
1
2
3
4
5
6
7
8
async function axiosServiceAWrapper(contextPath: string, args: object): Promise<{ data: AuthResult }> {
  return axios.post(SERVICE_A_URL + contextPath, args)
}
async function axiosServiceBWrapper(contextPath: string, args: object): Promise<{ data: AuthResult }> {
  return axios.post(SERVICE_B_URL + contextPath, args)
}
axiosServiceACall.fire('check', args);
axiosServiceBCall.fire('register', args);

5. Reference