출처: https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker
- 개요
Circuit Breaker는 원격 서비스나 자원에 접근할 때 오류가 발생하는 경우 복구에 어느 정도 시간이 걸리는지 알 수 없는데, 이런 경우를 다루는데 유용한 패턴이다. Application의 안정성과 탄력성을 향상 시킨다.
- Context and Problem
분산환경에서 원격 자원이나 서비스를 호출할 때 느린 network 속도, timeout, 요청이 몰려서 일시적으로 이용 불가능한 경우 등과 같이 일시적인 오류가 발생할 수 있다. 이런 오류들은 일반적으로 조금만 시간이 지나도 복구되지만 견고한 application 이라면 Retry pattern 과 같은 것을 사용해서 이를 대비해야한다.
하지만 예상치 못한 이벤트가 발생하면 복구에 시간이 길어지는 경우도 존재한다. 이런 경우 잠깐동안 불편함만 제공하고 지나갈 수도 있지만, 서비스가 완전히 불능상태에 빠질 수도 있다. 이런 상황에서는 성공하지 못할것 같은 요청을 계속해서 재시도하는것은 의미가 없다. 대신 application이 작업의 실패를 빠르게 인지하고 이를 적절하게 다루어야 한다.
추가적으로 만약 서비스에 부하가 가해지면 시스템중 한 부분에서 발생하는 실패가 전체 시스템으로 전파될 수 있다. 예를 들어 서비스를 실행하는 연산이 timeout을 기반으로 설정되었을 때 해당 기간내에 서비스가 응답에 실패하면 실패 메시지가 반환된다. 하지만 이 전략은 같은 연산에 대해 많은 병렬 요청을 발생시키기 때문에 timeout 기간이 만료될때까지 block 된다. 이렇게 block 된 요청들은 메모리나 쓰레드, DB 커넥션과 같은 시스템의 중요한 자원을 점유하고 있을것이다. 따라서 이런 리소스들이 고갈되어 같은 자원을 사용하는 시스템의 다른 부분들의 실패를 발생시킬 수 있다. 이런 상황에서는 연산이 즉시 실패하는것이 적절하며 성공할것 같은 서비스만 실행되어야 한다. timeout을 짧게 가져가면 이 문제를 해결하는데는 도움이 되지만, 반대로 서비스에 대한 요청이 조금 오래 걸려서 결국 성공하는 경우도 실패라고 간주는 부작용이 생길 수 있다.
- Solution
Circuit Breaker 패턴은 저자 "Michael Nygard"의 Release It 로 인해 유명해졌는데, application이 실패할것 같은 연산을 반복적으로 시도하는것을 방지하는데 그 목적이 있다. 오류가 수정될때까지 기다리지 않고 계속 진행하도록 하거나 오류가 지속된다고 판단하는 동안 CPU cycle을 낭비하지 않게 된다. Circuit Braker 패턴은 또한 오류가 해결되었는지 여부를 application이 탐지할 수 있도록 한다. 만약 문제가 해결되면 application은 연산 수행을 시도한다.
Circuit Breaker 패턴은 Retry 패턴과 목적이 다르다. Retry 패턴은 application이 연산을 성공할것을 가정하고 연산을 재시도 하지만, Circuit Breaker 패턴은 application이 실패할것 같은 연산을 수행하는것을 방지하는데 목적이 있다. Application은 circuit breaker를 통해 연산을 수행할 때 Retry pattern을 사용하는 방식으로 두 pattern을 결합할수도 있다. 하지만 재시도 로직은 circuit breaker 에 의해 반환된 예외에 잘 반응해야하고 만약 circuit breaker가 오류가 일시적이 아니라고 판단하면 재시도를 막아야 한다.
Circuit breaker는 실패할수도 있는 연산에 대한 proxy 와 같은 역할을 한다. Proxy는 최근에 발생한 실패 횟수를 모니터링하여 연산을 계속 수행할것인지 예외를 곧바로 반환할것인지 판단한다.
Proxy는 전자회로의 차단기 기능을 모방한 state machine 으로 구현된다.
- Closed: application 으로 부터의 요청은 연산으로 전달된다. Proxy는 최근 실패횟수를 관리하는데, 만약 연산에 대한 호출이 성공적이지 않다면 proxy는 최근 실패횟수를 증가시킨다. 만약 최근 실패횟수가 일정 기간동안에 임계값을 넘기면 proxy는 "Open" 상태로 변환된다. 이때 proxy는 timeout timer를 가동하여 timer가 만료되면 proxy는 다시 "Half-Open" 상태에 들어간다.
(Timeout timer의 목적은 시스템에 application이 연산을 다시 시도하기 전에 실패를 일으킨 문제를 고칠 시간을 주기위한것이다.) - Open: application 으로 부터의 요청은 즉시 실패하며 application으로 예외를 곧바로 반환한다.
- Half-Open: application 으로 부터 제한된 수만큼의 요청만 연산을 수행한다. 만약 이 요청들이 성공하면 이전에 실패를 일으킨 문제가 복구되었다고 가정하고 circuit breaker는 "Closed" 상태로 변환된다. (실패 counter 가 reset 된다.) 만약 계속 실패하면 circuit breaker는 문제가 여전히 지속되고 있으며 "Open" 상태로 전환하고, 실패를 복구하기 위한 기간을 시스템에 더 주기 위해 timeout timer를 다시 시작한다.
(Half-Open 상태는 서비스가 복구되었을 때 갑자기 요청이 쏟아져 들어오는것과 같은 상황을 방지하는데 유용하다. 서비스가 복구되면 완전이 복구가 될때까지 제한된 규모만큼의 요청만 지원하지만, 복구가 진행중이면 많은 요청이 또 다시 서비스를 timeout이나 실패를 일으킬 수 있다.
위의 그림에서 "Closed" 상태에서 사용되는 failure counter는 시간 기반으로 동작한다. 해당 시간은 일정 주기마다 자동으로 reset 된다. 이렇게 하는 이유는 가끔식 오류가 발생하는 경우 Circuit Breaker가 "Open" 상태로 진입하는것을 방지하기 위함이다. Circuit Breaker가 "Open" 상태로 변경되는 조건은 특정 기간동안 실패한 횟수가 failure threshold(실패 임계값)에 도달할때이다.
"Half-Open" 상태에서 사용되는 counter는 연산을 실행했을 때 성공한 횟수를 관리한다. 지정된 횟수만큼 연속적으로 연산 수행을 성공하면 Circuit Breaker가 "Closed" 상태로 복구한다. 만약 연산 수행이 실패하면 Circuit Breaker는 즉시 "Open" 상태로 변경하며 성공 counter는 "Open"상태에서 다시 "Half-Open" 상태에 진입할 때 reset 된다.
Circuit Breaker 패턴은 시스템이 실패를 복구하는 동안 안정성을 제공해주고 성능에 미치는 영향을 최소화시켜준다. 실패할것같은 연산에 대한 요청을 timeout 동안 기다리지않고 빠르게 탐지하여 거절하므로 시스템의 응답시간을 줄여준다. 만약 curcuit breaker가 상태를 변경할때마다 event를 기록하면 health 정보를 모니터하거나 "Open" 상태로 변경될 때 관리자에게 alter를 주는 등의 응용도 가능하다.
이 패턴은 커스터마이징이 가능하며 실패 유형에 따라 적용이 가능하다. 예를 들어 curcuit breaker timer의 timeout을 점진적으로 늘릴 수 있다. "Open" 상태에서는 초기에 몇초 동안으로 설정해놓고, 만약 실패 상태가 복구되지 않으면 timeout을 몇분으로 늘려가는식이다. 또 일부 경우에는 "Open" 상태에서 실패를 응답하거나 예외를 발생시키지 않고 의미있는 default 값을 반환하는 경우도 있다.
- Issue와 고려사항
이 패턴을 구현할때에는 아래 사항을 고려해야 한다.
Exception Handling: Application이 curcuit breaker를 통해 연산을 실행할때는 연산을 이용할 수 없을 때 발생하는 예외를 처리할 준비가 되어있어야 한다. 예외가 처리되는 방식은 application 마다 다르다. 예를 들면 application이 일시적으로 기능을 저하시키거나, 같은 데이터를 얻거나 같은 작업을 수행하기위해 대체 연산을 실행하거나, User 에게 예외를 보고하여 나중에 시도하도록 할 수 있다.
Type of Exceptions: 요청이 수많은 이유로 실패하지만 일부는 심각한 경우가 있다. 예를 들어 원격 서비스가 고장 혹은 복구에 몇분이 걸려서 요청이 실패하거나 서비스에 일시적으로 부하가 걸려서 timeout 때문에 실패할 수 있다. Circuit Breaker는 발생하는 예외 특성에 따라 다른 처리 전략을 적용할 수 있다. 예를 들어 service가 완전히 불능상태에 빠지는 경우에는 curcuit breaker를 "Open" 상태로 전환하기위해 실패한 수가 아니라 더 많은 수의 timeout 예외가 필요할 수 있다.
Logging: Circuit Breaker는 관리자가 연산의 health 상태를 모니터링하기 위해 모든 실패한 요청을 기록해야 한다.
Recoverability: Circuit Breaker가 연산의 recovery 패턴과 일치하도록 구성해야 한다. 예를 들어 만약 curcuit breaker가 오랫동안 "Open" 상태로 있게되면 실패가 해결되었더라도 예외가 발생할 수 있다. 이와 유사하게 curcuit breaker는 "Open" 상태에서 "Half-Open" 상태로 너무 급격하게 변경하면 application의 응답 시간을 줄이거나 변동시킬 수 있다.
Testing Failed Operations: "Open" 상태에서 언제 "Half-Open" 상태로 전환하는지 결정하기 위해 timer를 사용하기 보다는 curcuit breaker 가 원격 서비스나 리소스에 ping을 날릴수도 있다. 이런 ping은 이전에 실패한 연산을 수행하기 위해 시도한 형식을 취하거나 원격 서비스에서 제공하는 health endpoint를 사용할수도 있다.
Manual Override: 시스템에서 실패하는 연산에 대한 복구시간 변동이 심한지점은 관리자가 curcuit breaker를 닫고 수동 reset option을 제공할 수 있도록 하는것을 고려해봄직하다. 이와 유사하게 관리자는 curcuit breaker에 의해 보호되는 연산이 잠시동안 이용 불가능한 경우 curcuit breaker를 강제로 "Open" 상태로 전환할수도 있다.
Concurrency: 같은 curcuit breaker는 application의 많은 instance에 의해 접근될 수 있다. 구현시에는 동시 요청들을 block 하거나 연산의 각 호출마다 추가적인 overhead를 발생시켜서는 안된다.
Resource Differentiation: 만약 여러개의 독립적인 provider 들이 있는 경우 리소스의 하나의 유형을 위한 단일 curcuit breaker를 사용할 때 조심해야 한다. 예를 들어 여러 개의 shard를 포함하는 data store의 경우 하나의 shard는 완전히 접근 가능한 반면 다른 shard는 잠시 동안 issue가 있을 수 있다. 만약 이런 시나리오에서 error 응답이 하나로 통합되면, application은 실패할것이 명백한 경우에도 일부 shard에 접근하려고 하거나 성공할것 같은 경우에는 다른 shard에 대한 접근을 막을것이다.
Accelerated Circuit Breaking: 때로는 실패 응답에 curcuit breaker가 즉시 작동하고 최소한의 시간동안 상태를 유지하는데 충분한 정보를 포함하는 경우가 있다. 예를 들어 공유 자원의 오류 응답 메시지가 즉시 재시도를 추천하지 않고 몇분 동안 기다리라는 내용이 포함될 수 있다.
Relaying Failed Requests: "Open" 상태에서 단순히 빠르게 실패하기보다 curcuit breaker는 각 요청의 세부사항을 저널에 기록하고원격 리소스나 서비스가 이용가능해지면 해당 요청이 재시도되도록 준비할수도 있다.
Inappropriate Timeouts on External Services: Circuit Breaker가 timeout 기간이 설정된 외부 서비스에서 실패한 연산으로 부터 application을 완벽하게 보호하지 못할 수도 있다. timeout이 너무 길면 curcuit breaker 가 연산이 실패했다고 알려주기 전에 curcuit breaker가 실행중인 thread가 block 될수도 있다. 이런 경우에는 다른 application instance 들도 curcuit breaker를 통해 서비스 실행을 하려고 시도할것이고 이들이 모두 실패하기 전에 상당수의 thread가 엮일 수도 있다.
- 이 패턴을 사용하는 경우
Recommended:
- Application이 원격 서비스나 공유 자원에 접근하는 연산 수행이 실패할 가능성이 큰데도 시도하려는 경우를 방지한다.
Not recommneded:
- Application에서 private한 리소스(예를 들면 memory 데이터 구조) 접근하는것을 다루는 경우가 있다. 이런 환경에서는 curcuit breaker를 사용하면 시스템에 overhead만 가중된다.
- Application 비즈니스 로직에서 발생하는 예외를 대체하려는 경우
- Example with golang library
golang의 Circuit Breaker 라이브러리 중에서 https://github.com/sony/gobreaker 를 사용하여 Test code를 짜면서 익혀보자. 테스트 시나리오는 아래와 같다.
- 초기 상태가 "Closed" 인지 검증한다.
- threshold failure 만큼 실패했을 때 "Closed" -> "Open" 상태가 되는지 검증한다.
- threshold failure 를 초과하여 실패했지만 중간에 count가 clear 되는 interval이 존재하여 "Closed" 상태로 유지되는지 검증한다.
- 해당 라이브러리에서는 error가 반환되더라도 이를 성공처리하는 IsSuccessful 이라는 커스텀 메소드를 제공하고 있다. "Closed" 상태에서 threshold failure 만큼 실패했더라도 무시할 error type이 반환되면 "Closed"로 유지되는지 검증한다.
- "Open" 상태로 전환되고 나서 설정한 timeout이 지나면 "Half-Open" 상태로 전환되는지 검증한다.
- "Half-Open" 상태에서 요청이 설정한 횟수만큼 연속적으로 성공하면 "Closed"로 전환되는지 검증한다.
- "Half-Open" 상태에서 설정한 횟수만큼 성공하지 못하면 다시 "Open" 상태로 전환되는지 검증한다.
Test Case의 구조는 아래와 같이 잡았다.
type testCase struct {
name string
exec func(cb *gobreaker.CircuitBreaker) gobreaker.State
expectedState gobreaker.State
}
- name은 test를 구분하기 위한 이름이다.
- exec는 circuit breaker를 받아 특정 로직을 수행한 후 최종 State를 반환한다.
- expectedState를 최종적으로 기대하는 State를 기술한다.
Full 소스코드는 아래와 같다. 위에서 작성한 시나리오와 1:1 로 대응되니 천천히 살펴보면 test 하고자 하는 바를 이해할 수 있을것이다.
func TestCircuitBreaker(t *testing.T) {
const thresholdFailure = 3
const allowedMaxRequestsInHalfOpen = 4
const clearCountIntervalInClosed = 2 * time.Second
const timeoutOpenToHalfOpen = 3 * time.Second
errToIgnore := errors.New("please ignore this error")
type testCase struct {
name string
exec func(cb *gobreaker.CircuitBreaker) gobreaker.State
expectedState gobreaker.State
}
testCases := []testCase{
{
name: "init status",
exec: func(cb *gobreaker.CircuitBreaker) gobreaker.State {
return cb.State()
},
expectedState: gobreaker.StateClosed,
},
{
name: "closed to open",
exec: func(cb *gobreaker.CircuitBreaker) gobreaker.State {
for i := 0; i < thresholdFailure; i += 1 {
cb.Execute(func() (interface{}, error) {
return nil, fmt.Errorf("dummy error")
})
}
return cb.State()
},
expectedState: gobreaker.StateOpen,
},
{
name: "clear failure count in closed state",
exec: func(cb *gobreaker.CircuitBreaker) gobreaker.State {
for i := 0; i < thresholdFailure-1; i += 1 {
cb.Execute(func() (interface{}, error) {
return nil, fmt.Errorf("dummy error")
})
}
time.Sleep(clearCountIntervalInClosed)
for i := 0; i < thresholdFailure-1; i += 1 {
cb.Execute(func() (interface{}, error) {
return nil, fmt.Errorf("dummy error")
})
}
return cb.State()
},
expectedState: gobreaker.StateClosed,
},
{
name: "ignore error",
exec: func(cb *gobreaker.CircuitBreaker) gobreaker.State {
for i := 0; i < thresholdFailure; i += 1 {
cb.Execute(func() (interface{}, error) {
return nil, errToIgnore
})
}
return cb.State()
},
expectedState: gobreaker.StateClosed,
},
{
name: "open to half-open",
exec: func(cb *gobreaker.CircuitBreaker) gobreaker.State {
for i := 0; i < thresholdFailure; i += 1 {
cb.Execute(func() (interface{}, error) {
return nil, fmt.Errorf("dummy error")
})
}
time.Sleep(timeoutOpenToHalfOpen)
time.Sleep(1 * time.Millisecond)
return cb.State()
},
expectedState: gobreaker.StateHalfOpen,
},
{
name: "half-open to closed",
exec: func(cb *gobreaker.CircuitBreaker) gobreaker.State {
for i := 0; i < thresholdFailure; i += 1 {
cb.Execute(func() (interface{}, error) {
return nil, fmt.Errorf("dummy error")
})
}
time.Sleep(timeoutOpenToHalfOpen)
time.Sleep(1 * time.Millisecond)
for i := 0; i < allowedMaxRequestsInHalfOpen; i += 1 {
cb.Execute(func() (interface{}, error) {
return nil, nil
})
}
return cb.State()
},
expectedState: gobreaker.StateClosed,
},
{
name: "half-open to open",
exec: func(cb *gobreaker.CircuitBreaker) gobreaker.State {
for i := 0; i < thresholdFailure; i += 1 {
cb.Execute(func() (interface{}, error) {
return nil, fmt.Errorf("dummy error")
})
}
time.Sleep(timeoutOpenToHalfOpen)
time.Sleep(1 * time.Millisecond)
for i := 0; i < allowedMaxRequestsInHalfOpen-1; i += 1 {
cb.Execute(func() (interface{}, error) {
return nil, nil
})
}
cb.Execute(func() (interface{}, error) {
return nil, fmt.Errorf("dummy error")
})
return cb.State()
},
expectedState: gobreaker.StateOpen,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: tc.name,
MaxRequests: allowedMaxRequestsInHalfOpen,
Interval: clearCountIntervalInClosed,
Timeout: timeoutOpenToHalfOpen,
ReadyToTrip: func(counts gobreaker.Counts) bool {
if counts.TotalFailures >= thresholdFailure {
return true
}
return false
},
OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
fmt.Printf("time: %v, name: %v, from: %v, to: %v\n", time.Now(), name, from, to)
},
IsSuccessful: func(err error) bool {
return err == nil || err == errToIgnore
},
})
state := tc.exec(cb)
require.Equal(t, tc.expectedState, state)
})
}
}
'Concepts > Cloud Native' 카테고리의 다른 글
Timeout (1) | 2023.11.11 |
---|---|
Throttling (0) | 2023.11.04 |
Retry (0) | 2023.10.31 |
Debounce (1) | 2023.10.30 |
Cloud Native의 구성요소 (0) | 2023.10.10 |
댓글