본문으로 건너뛰기

Server-Side Apply

Server-Side Apply(SSA)는 Kubernetes의 필드 소유권 기반 패치 방식입니다. 여러 컨트롤러가 같은 리소스를 수정해도 필드 단위로 소유권을 나누기 때문에 충돌 없이 안전하게 동작합니다.

왜 SSA인가

기존 패치 방식의 한계:

방식한계
Merge patch배열 전체를 덮어씀. 필드 삭제가 명시적이지 않음
Strategic merge patchk8s-openapi 타입에만 동작. CRD에는 불완전
JSON patch정확한 경로 지정 필요. race condition에 취약

SSA의 장점:

  • 필드 소유권: "이 컨트롤러가 이 필드를 소유한다"를 서버가 기록합니다
  • 충돌 감지: 다른 소유자의 필드를 건드리면 409 Conflict가 발생합니다
  • 선언적: "이 필드들이 이 값이어야 한다"만 선언하면 나머지는 건드리지 않습니다
  • reconciler의 idempotent 패턴과 자연스럽게 맞습니다

기본 패턴

use kube::api::{Patch, PatchParams};

let patch = Patch::Apply(serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": { "name": "my-cm" },
"data": { "key": "value" }
}));
let pp = PatchParams::apply("my-controller"); // field manager 이름
api.patch("my-cm", &pp, &patch).await?;

PatchParams::apply("my-controller")"my-controller"가 field manager 이름입니다. 이 이름으로 필드 소유권이 기록됩니다. 같은 field manager로 다시 apply하면 소유 필드가 업데이트되고, 다른 field manager의 필드는 건드리지 않습니다.

흔한 실수들

apiVersion과 kind 누락

// ✗ 400 Bad Request
let patch = Patch::Apply(serde_json::json!({
"data": { "key": "value" }
}));

// ✓ apiVersion과 kind 필수
let patch = Patch::Apply(serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": { "name": "my-cm" },
"data": { "key": "value" }
}));

Merge patch와 달리 SSA는 apiVersionkind가 필수입니다.

field manager 미지정

// ✗ field_manager가 None → API 서버가 요청을 거부합니다
let pp = PatchParams::default();

// ✓ 명시적 field manager
let pp = PatchParams::apply("my-controller");

SSA에서 field manager는 필수입니다. field_managerNone(기본값)이면 API 서버가 에러를 반환합니다. SSA 작업에는 항상 PatchParams::apply("my-controller")를 사용합니다.

force 남용

// 주의: 다른 field manager의 필드도 강제로 덮어씁니다
let pp = PatchParams::apply("my-controller").force();

force: true는 다른 컨트롤러의 소유 필드도 강제로 가져옵니다. CRD 등록 등 단일 소유자 상황에서만 사용합니다.

불필요한 필드 포함

Rust struct를 통째로 serialization하면 Default 값 필드도 포함됩니다. SSA가 해당 필드의 소유권을 가져가서, 다른 컨트롤러가 그 필드를 수정하면 충돌이 발생합니다.

Status patching

status는 /status 서브리소스를 통해 수정합니다.

let status_patch = serde_json::json!({
"apiVersion": "example.com/v1",
"kind": "MyResource",
"status": {
"phase": "Ready",
"conditions": [{
"type": "Available",
"status": "True",
"lastTransitionTime": "2024-01-01T00:00:00Z",
}]
}
});
let pp = PatchParams::apply("my-controller");
api.patch_status("name", &pp, &Patch::Apply(status_patch)).await?;
전체 객체 구조로 감싸야 합니다
// ✗ status만 보내면 안 됩니다
serde_json::json!({ "phase": "Ready" })

// ✓ apiVersion, kind, status 구조로 감싸야 합니다
serde_json::json!({
"apiVersion": "example.com/v1",
"kind": "MyResource",
"status": { "phase": "Ready" }
})

Kubernetes API가 /status 엔드포인트에서도 전체 객체 형태를 기대하기 때문입니다.

Typed SSA 패턴

serde_json::json!() 대신 Rust 타입을 사용하면 타입 안전성과 IDE 자동완성을 얻을 수 있습니다.

let cm = ConfigMap {
metadata: ObjectMeta {
name: Some("my-cm".into()),
..Default::default()
},
data: Some(BTreeMap::from([("key".into(), "value".into())])),
..Default::default()
};
let pp = PatchParams::apply("my-controller");
api.patch("my-cm", &pp, &Patch::Apply(cm)).await?;

k8s-openapi 타입은 커스텀 직렬화를 사용하여 None 필드를 생략합니다 (Option 필드는 값이 있을 때만 직렬화됩니다). 커스텀 타입에서는 skip_serializing_if를 직접 설정해야 합니다.

현재 한계: Rust에는 ApplyConfigurations가 없습니다

Go의 client-go에는 SSA 전용으로 설계된 ApplyConfigurations가 있습니다 — 모든 필드가 Option인 완전 optional builder 타입으로, 소유할 필드만 포함합니다. Rust에는 아직 동등한 것이 없습니다 (kube#649). k8s-openapi는 upstream Go struct를 그대로 반영하므로 일부 필드가 Option이 아닙니다 (예: HorizontalPodAutoscalerSpecmax_replicas: i32). default struct를 직렬화하면 해당 필드도 포함되어 SSA가 소유권을 가져갑니다. serde_json::json!()으로 partial patch를 작성하는 것이 권장되는 우회 방법입니다.

#[derive(Serialize)]
struct MyStatus {
phase: String,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}