LabHub
배우기 러닝패스 코스

CRD 와 오퍼레이터 · 옛 버전을 지웠더니 오브젝트가 사라졌다 · 이론

저장된 것과 보여 주는 것은 다르다

LabHub 에서 이어서 보기

한 줄 요약

CRD 의 여러 버전은 같은 바이트를 다른 창으로 보여 주는 장치이고, 저장 버전을 올리는 일과 실제로 옮기는
일은 다른 일이다. 그 둘을 이어 주는 기록이 status.storedVersions 다.

왜 이 절차가 필요했나

API 는 한 번 공개하면 되돌리기 어렵다. 사용자가 Git 에 커밋해 둔 매니페스트가 있고, etcd 에는 그 표현으로
저장된 오브젝트가 쌓여 있다. 그래서 필드를 바꿔야 할 때 하는 일은 "고친다" 가 아니라 "새 버전을 낸다" 다.

여기서 한 가지를 반드시 갈라서 생각해야 한다. 제공(served)과 저장(storage)은 다른 축이다.

| 플래그 | 뜻 | 몇 개까지 |
| --- | --- | --- |
| served | 이 버전으로 요청을 받을 것인가 | 여러 개 |
| storage | 이 버전의 표현으로 etcd 에 쓸 것인가 | 정확히 하나 |

저장은 하나의 표현으로만 이루어진다. 그러니 v1alpha1 로 만든 오브젝트를 v1beta1 로 조회하면, 저장된
바이트를 꺼내 요청한 버전으로 변환해서 돌려준다. 변환 방식을 정하는 것이 spec.conversion.strategy 이고,
아무것도 적지 않으면 None 이다.

None 은 변환하지 않는다. apiVersion 딱지만 바꿔서 그대로 보여 준다. 필드 이름이 두 버전에서 같을
때만 쓸 수 있는 전략이고, 그래서 위험하다. 새 버전이 추가한 필드에 기본값이 있어도 그 값은 보이지 않는다.
기본값은 저장할 때 채워지는데, 이 오브젝트는 아직 옛 표현으로 저장돼 있기 때문이다. 화면에는 필드가
없는 것처럼 보이고, 그 상태를 그대로 믿은 컨트롤러가 잘못된 판단을 한다.

어떻게 동작하나

이전 절차는 네 걸음이고 순서가 전부다.

1. 새 버전을 served 로 추가한다            (옛 버전도 그대로 제공)2. storage 를 새 버전으로 옮긴다           (앞으로 저장할 표현만 바뀐다)3. 기존 오브젝트를 전부 한 번씩 다시 쓴다   (여기서 실제 이전이 일어난다)4. status.storedVersions 를 정리하고   옛 버전을 served: false → 제거한다

3번이 이 절차의 심장이다. 2번을 한 순간 status.storedVersions 에는 옛 버전과 새 버전이 둘 다 들어
있게 된다. 그 목록은 "이 CRD 로 지금까지 실제로 저장된 적이 있는 표현" 이라는 뜻이다. 오브젝트를 다시
쓰면 새 표현으로 저장되지만, API 서버는 옛 표현으로 저장된 것이 아직 남아 있는지 세어 주지 않는다.
그래서 4번의 정리는 사람이 '다시 쓰기를 끝냈다' 고 선언하는 행위다.

그 선언을 하지 않으면 API 서버가 마지막 문을 잠가 둔다. storedVersions 에 남아 있는 버전을
spec.versions 에서 빼려고 하면 요청이 거절되고, 오류가 어느 필드 때문인지 그대로 알려 준다. 이
안전장치가 없었다면 옛 스키마를 지우는 순간 그 표현으로 저장된 오브젝트가 읽히지 않게 된다.

버전을 내릴 때 쓰는 두 필드도 함께 알아 두면 좋다. deprecated: true 를 붙이면 그 버전으로 요청할 때
클라이언트가 경고를 받고, deprecationWarning 으로 문장을 직접 정할 수 있다. 경고는 요청을 막지 않는다 —
사람들이 옮겨 갈 시간을 주는 장치다.

현장에서 만나는 모습

첫째, 다시 쓰기를 건너뛴 사고. 가장 흔한 형태는 이렇다. 저장 버전을 올리고, 한동안 아무 문제가
없고(새로 만든 것들은 새 표현이므로), 몇 달 뒤 정리 작업에서 옛 버전을 지운다. 그 순간 오래된
오브젝트들이 목록에서 사라진다. 저장소에는 그대로 있는데 읽을 스키마가 없어졌기 때문이다.
storedVersions 를 먼저 손으로 지워 안전장치를 넘겨 버린 경우가 특히 위험하다.

둘째, 조용히 틀리는 컨트롤러. strategy: None 인 CRD 에서 새 필드에 기본값을 넣고, 컨트롤러가 그
필드를 읽어 동작을 바꾸도록 짜 놓는다. 새로 만든 오브젝트는 기본값이 채워져 있는데 옛 오브젝트는
비어 있다. 컨트롤러는 같은 종류의 오브젝트를 두 가지로 취급하게 되고, 그 차이는 만들어진 시점에만
달려 있다. 다시 쓰기는 이 문제도 함께 없앤다.

셋째, 오브젝트가 많을 때의 실무. 오브젝트가 수만 개면 다시 쓰기를 한꺼번에 돌리지 않는다. 매 건이
etcd 쓰기이고 watch 를 보는 모든 컨트롤러가 이벤트를 받는다. 보통은 나눠서, 사용량이 적은 시간에,
진행 상황을 세어 가며 돌린다. 쿠버네티스에는 이 일을 대신 해 주는 StorageVersionMigration 이라는
별도의 API 가 있다([공식 안내](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/storage-version-migration/)).
다만 절차의 뼈대는 지금 배우는 것과 같아서, 손으로 한 바퀴 돌려 본 사람만이 그 도구가 무엇을 대신해
주는지 안다. 이 실습에서는 걸음마다 무엇이 바뀌는지 직접 확인한다.

이 실습 환경의 한계

실습 파드에는 웹훅 서버를 띄울 수단이 없다. 그래서 conversion.strategy: Webhook 은 다루지 않고
None 의 범위 안에서만 확인한다. 바꿔 말하면 필드 이름이 바뀌는 이전은 이 환경에서 시험할 수 없다.
대신 None 이 정확히 무엇을 하고 무엇을 하지 않는지를 오브젝트로 증명한다 — 다시 쓰기 전에는 새 필드의
기본값이 채워지지 않고, 다시 쓴 뒤에는 채워진다. 이 차이가 이전이 실제로 일어났다는 증거가 된다.

다음 실습에서 할 것

두 버전을 가진 CRD 를 만들고 폐기 경고를 받아 본 뒤, 저장 버전을 올리고 모든 오브젝트를 다시 써서
기본값이 채워지는 것으로 이전을 증명한다. 그다음 옛 버전을 지우려다 API 서버에 막혀 보고, 올바른
순서로 기록을 정리한 뒤 옛 창을 닫는다. 마지막에는 이전이 끝났는지 한 번에 판정하는 점검 스크립트를 만든다.