저장된 것과 보여 주는 것은 다르다
한 줄 요약
CRD 의 여러 버전은 같은 바이트를 다른 창으로 보여 주는 장치이고, 저장 버전을 올리는 일과 실제로 옮기는
일은 다른 일이다. 그 둘을 이어 주는 기록이 status.storedVersions 다.
왜 이 절차가 필요했나
API 는 한 번 공개하면 되돌리기 어렵다. 사용자가 Git 에 커밋해 둔 매니페스트가 있고, etcd 에는 그 표현으로 저장된 오브젝트가 쌓여 있다. 그래서 필드를 바꿔야 할 때 하는 일은 "고친다" 가 아니라 "새 버전을 낸다" 다.
여기서 한 가지를 반드시 갈라서 생각해야 한다. 제공(served)과 저장(storage)은 다른 축이다.
| 플래그 | 뜻 | 몇 개까지 |
|---|---|---|
served |
이 버전으로 요청을 받을 것인가 | 여러 개 |
storage |
이 버전의 표현으로 etcd 에 쓸 것인가 | 정확히 하나 |
저장은 하나의 표현으로만 이루어진다. 그러니 v1alpha1 로 만든 오브젝트를 v1beta1 로 조회하면, 저장된
바이트를 꺼내 요청한 버전으로 변환해서 돌려준다. 변환 방식을 정하는 것이 spec.conversion.strategy 이고,
아무것도 적지 않으면 None 이다.
None 은 변환하지 않는다. apiVersion 딱지만 바꿔서 그대로 보여 준다. 필드 이름이 두 버전에서 같을
때만 쓸 수 있는 전략이고, 그래서 위험하다. 새 버전이 추가한 필드에 기본값이 있어도 그 값은 보이지 않는다.
기본값은 저장할 때 채워지는데, 이 오브젝트는 아직 옛 표현으로 저장돼 있기 때문이다. 화면에는 필드가
없는 것처럼 보이고, 그 상태를 그대로 믿은 컨트롤러가 잘못된 판단을 한다.
본문의 이전 절차는 네 걸음인데 그림은 세 단으로 묶었다. 새 버전을 제공하는 일과 저장 버전을 옮기는 일은 둘 다 앞으로 저장할 표현을 정하는 일이라 한 단에 담았다.
- 새 버전을 served 로 추가하고 storage 를 옮긴다옛 버전도 그대로 제공한다. 바뀌는 것은 앞으로 저장할 표현뿐이고 실제 이전은 아직 일어나지 않았다.
- 기존 오브젝트를 전부 한 번씩 다시 쓴다이 절차의 심장이다. 다시 쓰면 새 표현으로 저장되고 실제 이전이 여기서 일어난다.
- storedVersions 를 정리하고 옛 버전을 닫는다옛 버전을 served: false 로 바꾼 뒤 제거한다. 정리는 사람이 다시 쓰기를 끝냈다고 선언하는 행위이다.
여기서 구분할 것 storage 를 옮긴 순간 storedVersions 에는 옛 버전과 새 버전이 둘 다 들어 있다. API 서버는 옛 표현으로 저장된 것이 남았는지 세어 주지 않는다. 안전장치를 넘기려고 storedVersions 를 먼저 손으로 지우는 것이 가장 위험하다.
잠깐, 예측해 보세요 오브젝트가 수만 개인 CRD 에서 다시 쓰기를 한꺼번에 몰아서 돌려도 될까?
설명 확인 · 채점 없는 자가 점검
권하지 않는다. 한 건마다 etcd 쓰기이고 watch 를 보는 모든 컨트롤러가 이벤트를 받는다. 보통은 나누어, 사용량이 적은 시간에, 진행 상황을 세어 가며 돌린다. 이 일을 대신해 주는 StorageVersionMigration 이라는 별도의 API 도 있다. 수만 개라는 숫자는 설명용 예시이다.
어떻게 동작하나
이전 절차는 네 걸음이고 순서가 전부다.
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 가 있다(공식 안내).
다만 절차의 뼈대는 지금 배우는 것과 같아서, 손으로 한 바퀴 돌려 본 사람만이 그 도구가 무엇을 대신해
주는지 안다. 이 실습에서는 걸음마다 무엇이 바뀌는지 직접 확인한다.
이 실습 환경의 한계
실습 파드에는 웹훅 서버를 띄울 수단이 없다. 그래서 conversion.strategy: Webhook 은 다루지 않고
None 의 범위 안에서만 확인한다. 바꿔 말하면 필드 이름이 바뀌는 이전은 이 환경에서 시험할 수 없다.
대신 None 이 정확히 무엇을 하고 무엇을 하지 않는지를 오브젝트로 증명한다 — 다시 쓰기 전에는 새 필드의
기본값이 채워지지 않고, 다시 쓴 뒤에는 채워진다. 이 차이가 이전이 실제로 일어났다는 증거가 된다.
conversion.strategy 를 적지 않아 None 인 CRD 의 사례다. 새 버전이 필드를 추가하고 기본값을 두었을 때, 저장된 시점의 표현에 따라 무엇이 다르게 보이는지 견준다.
- 저장 버전을 옮긴 뒤에 새로 저장된 오브젝트새 표현으로 저장된다. 기본값은 저장할 때 채워지므로 새 필드에 값이 들어 있다.
- 옛 표현으로 저장된 채 남은 오브젝트None 전략은 변환하지 않고 apiVersion 딱지만 바꿔 보여 준다. 기본값이 채워진 적이 없으므로 새 필드가 없는 것처럼 보인다.
여기서 구분할 것 그 필드를 읽어 동작을 바꾸는 컨트롤러는 같은 종류의 오브젝트를 두 가지로 취급하게 된다. 그 차이는 내용이 아니라 만들어진 시점에만 달려 있다. 다시 쓰기는 이 문제도 함께 없앤다.
잠깐, 예측해 보세요 새 필드 retention 에 기본값을 두었다. 어제 만든 CR 에는 값이 보이고 지난달에 만든 CR 에는 비어 있다. 사람이 쓴 spec 은 같다. 무엇이 차이를 만들었고 어떻게 맞출까?
설명 확인 · 채점 없는 자가 점검
저장된 시점의 표현이 만든 차이다. 옛 표현으로 저장된 오브젝트는 None 전략에서 변환되지 않아 기본값이 보이지 않는다. 기존 오브젝트를 다시 쓰면 새 표현으로 저장되며 기본값이 채워진다. 필드 이름은 설명용 예시이다.
다음 실습에서 할 것
두 버전을 가진 CRD 를 만들고 폐기 경고를 받아 본 뒤, 저장 버전을 올리고 모든 오브젝트를 다시 써서 기본값이 채워지는 것으로 이전을 증명한다. 그다음 옛 버전을 지우려다 API 서버에 막혀 보고, 올바른 순서로 기록을 정리한 뒤 옛 창을 닫는다. 마지막에는 이전이 끝났는지 한 번에 판정하는 점검 스크립트를 만든다.