Server-Side Apply 동작을 파헤쳐보았다
Server-Side Apply의 필드 소유권, 충돌 감지, 프루닝을 Home Cluster에서 직접 검증했다. 4개 시나리오를 돌리면서 예상과 다르게 동작한 부분도 있었다.
Cilium CRD 업그레이드를 하면서 SSA를 제대로 검증해봤다. 충돌 감지, 소유권 이전, HPA와의 공존 — 4개 시나리오를 돌렸고, 예상과 다르게 동작한 부분도 있었다.
CSA → SSA: 뭐가 달라지나
본론에 앞서 배경만 짧게 정리한다.
Client-Side Apply (CSA)
flowchart LR A["로컬 manifest"] --> B["kubectl<br/>(3-way merge)"] C["API 서버에서<br/>현재 상태 GET"] --> B D["last-applied<br/>annotation"] --> B B --> E["계산된 patch<br/>→ API 서버 전송"]kubectl이 로컬에서 변경사항을 계산한다. 이전 상태는 last-applied-configuration annotation에 전체 manifest JSON으로 저장된다. 누가 어떤 필드를 소유하는지 추적하지 않는다.
Server-Side Apply (SSA)
flowchart LR A["로컬 manifest<br/>(원본 그대로)"] --> B["API 서버<br/>(Structured Merge Diff)"] B --> C["managedFields<br/>소유권 업데이트"] B --> D["충돌 감지<br/>또는 적용 완료"]kubectl이 계산하지 않는다. manifest 원본을 API 서버에 보내면 서버가 스키마를 보고 필드별로 머지하고, 필드 단위 소유권을 추적한다.
PoC 시나리오
여기서부터 본론이다. Home Cluster에서 SSA의 동작을 시나리오별로 검증한다.
추적 방식 비교
같은 ConfigMap을 CSA와 SSA로 각각 생성했다.
CSA — annotation 확인:
kubectl get cm csa-test -n ssa-poc -o jsonpath='{.metadata.annotations}' | jq .{ "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"key1\":\"value1\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"name\":\"csa-test\",\"namespace\":\"ssa-poc\"}}\n"}전체 manifest가 JSON 문자열로 들어가 있다. CRD처럼 큰 리소스는 이 annotation만으로 수십 KB가 될 수 있다.
SSA — managedFields 확인:
kubectl get cm ssa-test -n ssa-poc -o yaml --show-managed-fieldsapiVersion: v1data: key1: value1kind: ConfigMapmetadata: managedFields: - apiVersion: v1 fieldsType: FieldsV1 fieldsV1: f:data: f:key1: {} # ← 필드 경로만 추적. 값은 저장하지 않는다 manager: kubectl operation: Apply name: ssa-test namespace: ssa-pocannotation이 없다. managedFields에 f:data: f:key1: {}로 필드 경로만 기록된다. “이 필드를 누가 소유하는지”만 추적하고, 값은 저장하지 않는다.
소유권 충돌과 해결
두 manager가 같은 필드를 수정하려고 하면 어떻게 되는지 확인한다.
manager-a가 먼저 apply:
cat <<'EOF' | kubectl apply --server-side --field-manager=manager-a -f -apiVersion: v1kind: ConfigMapmetadata: name: conflict-test namespace: ssa-pocdata: shared-key: value-from-a a-only: a-dataEOFmanager-b가 shared-key를 다른 값으로 수정 시도:
cat <<'EOF' | kubectl apply --server-side --field-manager=manager-b -f -apiVersion: v1kind: ConfigMapmetadata: name: conflict-test namespace: ssa-pocdata: shared-key: value-from-bEOFerror: Apply failed with 1 conflict: conflict with "manager-a": .data.shared-keyPlease review the fields above--they currently have other managers. Hereare the ways you can resolve this warning:* If you intend to manage all of these fields, please re-run the apply command with the `--force-conflicts` flag.* If you do not intend to manage all of the fields, please edit your manifest to remove references to the fields that should keep their current managers.* You may co-own fields by updating your manifest to match the existing value; in this case, you'll become the manager if the other manager(s) stop managing the field (remove it from their configuration).409 Conflict. manager-a가 소유한 필드를 manager-b가 다른 값으로 바꾸려고 해서 거부당했다.
충돌 해결 옵션은 세 가지다:
- 강제 덮어쓰기 —
--force-conflicts로 소유권을 빼앗는다 - 소유권 포기 — manifest에서 해당 필드를 제거한다
- 공유 소유권 — 서버와 같은 값으로 apply하면 충돌 없이 공동 소유가 된다
--force-conflicts로 소유권을 이전하면:
managedFields: - apiVersion: v1 fieldsV1: f:data: f:a-only: {} # manager-a는 자기 필드만 유지 manager: manager-a operation: Apply - apiVersion: v1 fieldsV1: f:data: f:b-only: {} f:shared-key: {} # ← 소유권이 manager-b로 이전됨 manager: manager-b operation: Applyshared-key가 manager-b 소유로 바뀌었다. 값도 value-from-b로 변경. a-only는 여전히 manager-a 소유다.
HPA와 Deployment의 replicas 공존
SSA를 쓰는 가장 실용적인 이유 중 하나다. Deployment를 SSA로 생성하고, 다른 컨트롤러(HPA)가 replicas를 변경한 상황에서 어떤 일이 일어나는지 확인한다.
Deployment를 SSA로 생성 — 이 시점에서 spec.replicas는 kubectl(Apply)이 소유한다.
다른 컨트롤러가 scale subresource를 통해 replicas를 변경:
# HPA 컨트롤러의 동작을 시뮬레이션kubectl patch deployment ssa-demo -n ssa-poc \ --type='merge' -p '{"spec":{"replicas":4}}' \ --field-manager=horizontal-pod-autoscaler \ --subresource=scalemanagedFields를 확인하면 replicas의 소유자가 추가되어 있다:
managedFields: - manager: kubectl operation: Apply # spec.replicas 포함 - manager: horizontal-pod-autoscaler operation: Update subresource: scale # spec.replicas를 새로 소유이 상태에서 replicas를 포함한 채 SSA apply:
error: Apply failed with 1 conflict: conflict with "kube-controller-manager" with subresource "scale": .spec.replicas충돌이 발생한다. 다른 manager가 scale subresource를 통해 replicas를 소유하고 있기 때문이다.
해결: manifest에서 replicas를 제거하고 apply. 결과를 확인하면:
managedFields: - manager: kubectl operation: Apply fieldsV1: f:spec: f:selector: {} # replicas 소유권이 사라졌다 f:template: { ... } - manager: kube-controller-manager operation: Update subresource: scale fieldsV1: f:spec: f:replicas: {} # ← scale manager만 replicas를 소유kubectl Apply에서 f:spec:f:replicas가 사라졌다. SSA의 프루닝이 동작한 것이다 — manifest에서 replicas를 빼니 “더 이상 소유하지 않겠다”는 선언이 되었다. 이제 CI/CD에서 이 manifest를 배포해도 replicas를 건드리지 않는다.
Operator가 관리하는 CRD 확인
Cilium CRD의 managedFields를 확인했다. 소유자가 누구로 등록되어 있을까?
kubectl get crd ciliumnetworkpolicies.cilium.io -o yaml --show-managed-fields \ | grep -E "manager:|operation:" manager: cilium-operator operation: Update manager: kube-apiserver operation: Update manager: cilium-operator-generic operation: UpdateHelm이 아니라 cilium-operator가 직접 관리하고 있었다. 모두 operation: Update. Operator가 CRD를 직접 생성/수정하는 패턴이다.
기존 소유자가 없는 필드(annotation)를 SSA로 추가하면 충돌 없이 성공한다. 기존 필드를 건드리지 않으면 다른 manager와 충돌이 나지 않는다.
예상과 달랐던 것들
PoC를 돌리면서 두 가지가 예상과 달랐다.
kubectl scale은 충돌을 일으키지 않는다
HPA 시나리오를 처음에 kubectl scale로 테스트했는데, SSA apply와 충돌이 나지 않았다. 이유는 manager name이 같기 때문이다.
kubectl apply --server-side → manager: "kubectl", operation: Applykubectl scale → manager: "kubectl", operation: Update, subresource: scaleSSA의 충돌 감지는 다른 manager name이 소유한 필드를 바꿀 때만 동작한다. 같은 이름이면 operation이나 subresource가 달라도 충돌로 보지 않는다. 실제 HPA 컨트롤러는 horizontal-pod-autoscaler라는 별도 manager name을 사용하므로 충돌이 발생한다.
CRD 소유자가 Helm이 아니었다
kubectl apply --server-side --force-conflicts -f crds/를 쓰는 이유가 “Helm이 CRD의 소유자이므로 소유권 이전이 필요하다”고 알고 있었다. 그런데 실제로 확인해보니 Cilium CRD의 소유자는 Helm이 아니라 cilium-operator였다. Operator가 CRD를 직접 관리하는 패턴이면 소유자도 Operator가 된다.
어떤 도구가 CRD를 생성했느냐에 따라 소유자가 달라지므로, --force-conflicts를 쓰기 전에 managedFields를 확인하는 습관이 필요하다.
이 결과를 만드는 메커니즘: Structured Merge Diff
PoC 결과가 왜 이렇게 나오는지, API 서버 내부 동작을 정리한다.
스키마 기반 머지
API 서버는 OpenAPI 스키마를 보고 각 필드를 어떻게 머지할지 결정한다. 모든 필드가 같은 방식으로 처리되는 게 아니다.
| 필드 타입 | 머지 방식 | 예시 |
|---|---|---|
| Scalar | 새 값으로 교체 | replicas: 3 → replicas: 5 |
| Map (granular) | 키별로 독립 머지 | labels의 각 키를 개별 관리 |
| Map (atomic) | 통째로 교체 | selector.matchLabels 전체가 하나의 단위 |
| List (associative) | merge key로 항목 매칭 | containers는 name으로 매칭 |
| List (atomic) | 통째로 교체 | args 전체가 하나의 단위 |
| List (set) | 값 자체로 매칭 | finalizers의 각 문자열이 개별 단위 |
spec.template.spec.containers는 x-kubernetes-list-type: map이고 merge key가 name이다. 두 manager가 각각 다른 이름의 컨테이너를 소유할 수 있다. 반면 spec.selector.matchLabels는 x-kubernetes-map-type: atomic이라 한 manager가 통째로 소유한다.
이 정보는 Kubernetes Go 소스코드의 struct tag(patchStrategy, patchMergeKey)에서 OpenAPI 스키마로 컴파일된다. CRD의 경우 직접 x-kubernetes-list-type 같은 확장 필드로 지정한다.
Apply 처리 흐름
Apply 요청(PATCH + application/apply-patch+yaml)이 API 서버에 도착하면:
flowchart TB A["Apply 요청 도착"] --> B["etcd에서 현재 객체 로드<br/>+ managedFields 디코딩"] B --> C["스키마 기반 머지<br/>live + config → merged"] C --> D["필드 추출<br/>config → fieldSet<br/>(이 manager가 소유할 필드)"] D --> E["프루닝<br/>이전에 소유했지만<br/>이번에 빠진 필드 제거"] E --> F{"충돌 검사<br/>다른 manager가 소유한<br/>필드를 바꿨나?"} F -- "충돌 없음" --> G["managedFields 업데이트<br/>→ etcd 저장"] F -- "충돌 있음" --> H{"--force-conflicts?"} H -- "Yes" --> I["다른 manager에서<br/>소유권 빼앗기"] --> G H -- "No" --> J["HTTP 409 Conflict 반환"]스키마 기반 머지 — live 객체와 config를 스키마를 따라 재귀 순회한다. 스칼라는 config 값이 이기고, granular map은 키별로 머지하고, associative list는 merge key로 항목을 매칭한다. 이 단계에서는 삭제가 일어나지 않는다 — 합치기만 한다.
프루닝 — 이전 Apply에서 소유했지만 이번 config에서 빠진 필드를 찾는다. 이 manager만 소유하고 있었다면 live 객체에서 삭제한다. HPA 시나리오에서 replicas를 manifest에서 빼니 소유권이 사라진 이유가 이 프루닝 때문이다.
충돌 검사 — merged 결과와 live 객체를 비교해서 실제로 변경된 필드를 찾고, 그 필드를 다른 manager가 소유하고 있는지 확인한다.
Apply vs Update
managedFields에는 두 종류의 operation이 기록된다.
| Apply | Update | |
|---|---|---|
| 트리거 | kubectl apply --server-side | kubectl edit, kubectl scale, PUT 등 |
| 프루닝 | manifest에서 뺀 필드 → 삭제됨 | 삭제하지 않음 |
| 충돌 | 다른 소유자의 필드 수정 시 차단 | 항상 소유권을 가져감 (implicit force) |
Cilium CRD의 manager가 모두 operation: Update였던 이유가 이거다. Operator가 imperative하게(PUT/PATCH) 리소스를 생성하면 Update로 기록된다.
이 구분은 CSA → SSA 마이그레이션에서 함정이 된다. CSA로 적용한 리소스는 operation: Update로 기록되어 있다. 이걸 SSA로 전환하면 새 operation: Apply 엔트리가 추가되는데, 이전 Update 엔트리가 남아있다. SSA에서 필드를 빼도 이전 Update manager가 소유하고 있어서 삭제되지 않는 “유령 필드” 현상이 생길 수 있다.
정리
| 항목 | Client-Side Apply | Server-Side Apply |
|---|---|---|
| 상태 추적 | last-applied-configuration annotation | managedFields |
| 변경사항 계산 | kubectl (클라이언트) | API 서버 |
| 머지 알고리즘 | 3-way merge (JSON patch) | Structured Merge Diff (스키마 기반) |
| 충돌 감지 | 없음 (덮어쓰기) | 자동 감지 |
| 다중 관리자 | 지원 안 함 | 필드 단위 소유권 |
| 필드 삭제 | annotation 비교로 추론 | manifest에서 빼면 삭제 (프루닝) |
PoC에서 확인한 핵심:
- 충돌 감지는 manager name 기준이다. 같은 이름이면 operation이 달라도 충돌하지 않는다
- manifest에서 필드를 빼면 프루닝으로 소유권이 해제된다. HPA에 replicas를 위임하려면 manifest에서 빼면 된다
- CRD 소유자는 “누가 만들었느냐”에 따라 달라진다.
--force-conflicts전에 managedFields를 확인하자
참고
관련 콘텐츠
Gateway API 전환기 (1) - Cilium을 Kubespray에서 Helm으로
Kubespray로 설치한 Cilium을 Helm 관리로 전환하는 과정에서 겪은 트러블슈팅과 교훈을 공유합니다.
DevOpsLinkedIn에서 발견한 Tencent WeKnora, GraphRAG PoC하고 PR까지 Merged
LinkedIn에서 발견한 Tencent WeKnora를 홈 Kubernetes 클러스터에서 PoC하고, Helm Chart PR까지 Merge한 여정
DevOpsn8n v1 → v2 업그레이드: Kubernetes에서 메이저 버전 넘기
n8n v1.120.4에서 v2.9.4로 2단계 순차 업그레이드한 기록. 연쇄 CVE 대응, Migration Report 활용, community node emptyDir 트레이드오프, Queue 모드 호환성 튜닝.