Kubernetes Kubernetes Helm

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 확인:

Terminal window
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 확인:

Terminal window
kubectl get cm ssa-test -n ssa-poc -o yaml --show-managed-fields
apiVersion: v1
data:
key1: value1
kind: ConfigMap
metadata:
managedFields:
- apiVersion: v1
fieldsType: FieldsV1
fieldsV1:
f:data:
f:key1: {} # ← 필드 경로만 추적. 값은 저장하지 않는다
manager: kubectl
operation: Apply
name: ssa-test
namespace: ssa-poc

annotation이 없다. managedFieldsf:data: f:key1: {}필드 경로만 기록된다. “이 필드를 누가 소유하는지”만 추적하고, 값은 저장하지 않는다.

소유권 충돌과 해결

두 manager가 같은 필드를 수정하려고 하면 어떻게 되는지 확인한다.

manager-a가 먼저 apply:

Terminal window
cat <<'EOF' | kubectl apply --server-side --field-manager=manager-a -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: conflict-test
namespace: ssa-poc
data:
shared-key: value-from-a
a-only: a-data
EOF

manager-b가 shared-key를 다른 값으로 수정 시도:

Terminal window
cat <<'EOF' | kubectl apply --server-side --field-manager=manager-b -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: conflict-test
namespace: ssa-poc
data:
shared-key: value-from-b
EOF
error: Apply failed with 1 conflict: conflict with "manager-a": .data.shared-key
Please review the fields above--they currently have other managers. Here
are 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: Apply

shared-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를 변경:

Terminal window
# HPA 컨트롤러의 동작을 시뮬레이션
kubectl patch deployment ssa-demo -n ssa-poc \
--type='merge' -p '{"spec":{"replicas":4}}' \
--field-manager=horizontal-pod-autoscaler \
--subresource=scale

managedFields를 확인하면 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를 확인했다. 소유자가 누구로 등록되어 있을까?

Terminal window
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: Update

Helm이 아니라 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: Apply
kubectl scale → manager: "kubectl", operation: Update, subresource: scale

SSA의 충돌 감지는 다른 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: 3replicas: 5
Map (granular)키별로 독립 머지labels의 각 키를 개별 관리
Map (atomic)통째로 교체selector.matchLabels 전체가 하나의 단위
List (associative)merge key로 항목 매칭containersname으로 매칭
List (atomic)통째로 교체args 전체가 하나의 단위
List (set)값 자체로 매칭finalizers의 각 문자열이 개별 단위

spec.template.spec.containersx-kubernetes-list-type: map이고 merge key가 name이다. 두 manager가 각각 다른 이름의 컨테이너를 소유할 수 있다. 반면 spec.selector.matchLabelsx-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이 기록된다.

ApplyUpdate
트리거kubectl apply --server-sidekubectl 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 ApplyServer-Side Apply
상태 추적last-applied-configuration annotationmanagedFields
변경사항 계산kubectl (클라이언트)API 서버
머지 알고리즘3-way merge (JSON patch)Structured Merge Diff (스키마 기반)
충돌 감지없음 (덮어쓰기)자동 감지
다중 관리자지원 안 함필드 단위 소유권
필드 삭제annotation 비교로 추론manifest에서 빼면 삭제 (프루닝)

PoC에서 확인한 핵심:

  • 충돌 감지는 manager name 기준이다. 같은 이름이면 operation이 달라도 충돌하지 않는다
  • manifest에서 필드를 빼면 프루닝으로 소유권이 해제된다. HPA에 replicas를 위임하려면 manifest에서 빼면 된다
  • CRD 소유자는 “누가 만들었느냐”에 따라 달라진다. --force-conflicts 전에 managedFields를 확인하자

참고

관련 콘텐츠

댓글