본문으로 건너뛰기

에러 처리와 Backoff

kube에서 에러는 여러 계층에서 발생합니다. 어디서 어떤 에러가 나오고, 각 계층에서 어떻게 처리해야 하는지 정리합니다.

에러 발생 지점 맵

계층에러 타입원인
ClientHyperError, HttpError네트워크, TLS, 타임아웃
ApiApi(Status)Kubernetes 4xx/5xx 응답
ApiSerdeErrorJSON deserialization 실패
watcherInitialListFailed초기 LIST 실패
watcherWatchStartFailedWATCH 연결 실패
watcherWatchFailedWATCH 스트림 중간 실패
watcherWatchErrorWATCH 중 서버 에러 (410 Gone 등)
Controllerreconciler Error사용자 코드에서 발생

Watcher 에러와 backoff

Watcher 에러는 soft error입니다 — watcher는 모든 실패(403, 네트워크 문제 포함)에 대해 재시도합니다. 외부 환경이 개선되면 복구될 수 있기 때문입니다. 이런 에러를 조용히(silently) 무시해서는 안 됩니다.

핵심 요구사항은 watcher 스트림에 backoff를 붙이는 것입니다:

// ✗ backoff 없으면 에러 시 타이트 재시도 루프
let stream = watcher(api, wc);

// ✓ 지수 백오프로 자동 재시도
let stream = watcher(api, wc).default_backoff();

default_backoff

ExponentialBackoff를 적용합니다: base 800ms, factor 2, max 30초, jitter 활성화. 성공적인 이벤트를 수신하면 backoff가 리셋됩니다.

커스텀 backoff

use backon::ExponentialBuilder;

let stream = watcher(api, wc).backoff(
ExponentialBuilder::default()
.with_min_delay(Duration::from_millis(500))
.with_max_delay(Duration::from_secs(30)),
);

Reconciler 에러와 error_policy

에러 타입 정의

Controller::run은 에러 타입에 std::error::Error + Send + 'static을 요구합니다. 최신 anyhow (1.0.65+)는 이 bound를 만족하지만, finalizer::Error 래퍼와의 호환성 문제가 있고 error_policy에서 에러 타입을 구분할 수 없게 됩니다. thiserror로 구체적인 에러 타입을 정의합니다:

#[derive(Debug, thiserror::Error)]
enum Error {
#[error("Kubernetes API error: {0}")]
KubeApi(#[from] kube::Error),

#[error("Missing spec field: {0}")]
MissingField(String),

#[error("External service error: {0}")]
External(String),
}

error_policy

reconciler가 Err를 반환하면 error_policy가 호출되어 다음 동작을 결정합니다:

fn error_policy(obj: Arc<MyResource>, err: &Error, ctx: Arc<Context>) -> Action {
tracing::error!(?err, "reconcile failed");

match err {
// 일시적 에러: 재시도
Error::KubeApi(_) => Action::requeue(Duration::from_secs(5)),
// 영구적 에러: 재시도하지 않음
Error::MissingField(_) => Action::await_change(),
}
}

일시적 에러와 영구적 에러를 구분할 수 있습니다:

유형예시처리
일시적네트워크 에러, 타임아웃, 429error_policy에서 requeue
영구적잘못된 spec, 설정 오류status에 condition 기록 + Action::await_change()

현재 한계

  • error_policy동기 함수입니다. async 작업(메트릭 전송, status 업데이트 등)을 할 수 없습니다
  • 성공 시 reset 콜백이 없습니다. per-key backoff를 구현하려면 reconciler를 wrapper로 감싸야 합니다 (Per-key backoff 패턴 참고)

Client 레벨 재시도

기본적으로 kube-client는 일반 API 호출을 재시도하지 않습니다. create(), patch(), get() 등이 실패하면 그대로 에러를 반환합니다.

버전 3부터 kube는 내장 RetryPolicy를 제공합니다. Tower의 retry 미들웨어를 구현하며, 429, 503, 504에 대해 지수 백오프로 재시도합니다:

use kube::client::retry::RetryPolicy;
use tower::{ServiceBuilder, retry::RetryLayer, buffer::BufferLayer};

let service = ServiceBuilder::new()
.layer(config.base_uri_layer())
.option_layer(config.auth_layer()?)
.layer(BufferLayer::new(1024))
.layer(RetryLayer::new(RetryPolicy::default()))
// ...

RetryPolicy429, 503, 504 응답에 대해서만 재시도합니다. 네트워크 에러나 다른 5xx 코드는 재시도하지 않습니다.

계층별 재시도 전략

아래 표는 에러별 처리 위치를 정리합니다. RetryPolicy는 client 계층만 담당하며, 다른 에러는 다른 계층에서 처리해야 합니다:

에러재시도처리 위치
429, 503, 504가능Client 계층: RetryPolicy (설정 시)
기타 5xx상황에 따라Reconciler: error_policy 또는 커스텀 Tower 미들웨어
타임아웃 / 네트워크가능Reconciler: error_policy requeue, 또는 watcher: backoff
4xx (400, 403, 404)불가요청 또는 RBAC 수정 필요
409 Conflict상황에 따라SSA: field manager 충돌. Non-SSA: resourceVersion 충돌

타임아웃 전략

reconciler 내부에서 느린 API 호출을 방어하려면 tokio::time::timeout으로 개별 호출을 감쌉니다:

// 첫 번째 ?는 timeout Result<T, Elapsed>를 풀고
// 두 번째 ?는 API Result<Pod, kube::Error>를 풉니다
let pod = tokio::time::timeout(
Duration::from_secs(10),
api.get("my-pod"),
).await??;

Controller 컨텍스트에서 스트림 타임아웃은 watcher 내부의 타임아웃과 스트림 backoff 파라미터, watcher::Config에 의존합니다. 보통 reconciler 내부의 개별 API 호출만 짧은 타임아웃이 필요합니다.