Kubernetes Anti Pattern 1
리더님께서 공유해주신 글을 정리하고 되새기는 용도로 정리했다. https://betterprogramming.pub/10-antipatterns-for-kubernetes-deployments-e97ce1199f2d
글이 좀 오래되긴 했지만 (3년 전) 근본적으로 내가 올바르게 쿠버네티스를 사용하고 있는가?에 대해서는 한번 생각해보면 좋을 것 같다.
총 10개의 안티 패턴이 있고, 5개씩 나누어 정리할 예정이다.
1. 도커 이미지 내에, 또는 설정 파일을 위치시키는 것
이 안티 패턴은 도커의 안티 패턴과도 연관이 있다. 컨테이너는
- 프로덕션 환경에서
- 소프트웨어의 전체 라이프사이클을 커버하는
- 단일 이미지를 사용할 수 있는 방법을 제공해줬다.
이 과정에서 흔하게 하는 실수 중 하나가 라이프사이클의 특정 환경에 대한 다른 아티팩트를 빌드한 각 페이즈의 이미지를 만드는 것이다.
하지만 이렇게 된다면 테스트한 것을 배포하는 것이 아니게 된다.
때문에, API key나 secret들을 ConfigMaps
와 같은 범용 목적을 가진 설정들을 외재화 시키는 것이 좋다.
ConfigMaps
는 볼륨으로 마운트 되거나 환경 변수로 전달될 수 있다.
하지만 Secrets
은 볼륨으로 마운트되어야 한다.
ConfigMaps
와 Secrets
은 쿠버네티스의 네이티브한 리소스이고 인테그레이션 과정을 필요로 하지 않는다. 물론 ConfigMaps
대신 Zookeeper를 사용하거나 Secrets
대신 Vault
를 사용하는 등 다른 프로덕트를 사용할 수 있다.
어플리케이션에서 설정을 느슨한 결합으로 묶게 된다면 설정을 업데이트해서 재컴파일 할 필요가 없어진다. 또한 어플리케이션이 실행되고 있는 동안에도 설정들이 업데이트 될 수 있다. 이 경우 빌드 과정에서 설정을 fetch하는 것이 아니라 런타임에 설정 정보를 가져온다.
어플리케이션이 소프트웨어의 전체 라이프사이클 안에서 동일한 소스 코드를 사용하게 해야 한다.
2. Helm과 같은 템플릿을 사용하지 않는 것
yaml 파일을 업데이트하는 것만으로 쿠버네티스를 사용한 배포를 관리할 수 있다. 새 버전의 어플리케이션을 배포할 때, 다음과 같은 것들을 전부 업데이트할 수 있다.
- 도커 이미지 이름
- 도커 이미지 태그
- 레플리카 개수
- 서비스 라벨들
- pod들
- ConfigMaps 와 같은 설정 정보
여러 개의 클러스터를 관리하는 입장에서 개발 - 스테이징 - 프로덕션 환경에 걸쳐 똑같은 내용의 업데이트를 하는 것은 지친다. 기본적으로는 모든 환경에 대한 배포는 동일한 파일에서 조그마한 수정사항이 생기는 것과 동일하다. 많은 양의 복사 - 붙여넣기나 탐색 - 교체를 하는 동시에 환경에 올바른 yaml이 위치하는지도 신경써야 한다. 이 과정은 다음과 같은 실수를 유발할 수 있다.
- 오타 (버전 정보의 오타, 이미지 이름의 오타 등)
- 의도한 업데이트가 아닌 yaml 파일의 적용 (다른 DB에 연결하는 설정 파일 적용 등)
- 리소스의 업데이트를 빼먹는 경우
템플릿은 쿠버네티스 어플리케이션의 설치와 관리에 대한 일련의 과정을 돕는다. 쿠버네티스가 네이티브한 템플릿 메커니즘을 지원하지 않기 때문에 다른 방법을 찾아야 하긴 하다.
Helm은 2015년부터 공개된 쿠버네티스를 위한 첫 번째 패키지 매니저이다. 쿠버네티스를 위한 Homebrew를 자칭하며 템플릿 능력을 갖추기 위해 진화해왔다.
Helm은 charts를 통해 리소스를 패키징한다. Helm charts는 쿠버네티스에 관련된 리소스들를 기술하는 파일들의 집합이다. chart repository에는 1400개가 넘는 공객된 chart들이 존재하고, 이들은 기본적으로 쿠버네티스 안에서 어떤 것을 설치하거나, 업그레이드하거나, 설치를 제거하기 위한 재사용 가능한 레시피들이다.
Helm chart를 사용하면 values.yaml
파일을 수정해서 쿠버네티스 배포에 필요한 변경 사항들은 세팅할 수 있다.
또한 각각의 환경에 대한 다른 Helm chart를 사용할 수도 있다. QA, 스테이징, 프로덕션 환경에 대한 Helm chart들만 관리한다면 각각의 환경의 각각의 배포에 대한 yaml 파일을 수정할 필요가 없다.
Helm chart의 또 다른 장점으로는 Helm rollback을 통해 이전 리비전으로의 롤백이 굉장히 용이하다는 점도 있다.
3. 어떤 것들을 순차적인 순서로 배포하는 것
어플리케이션은 의존 관계가 준비가 되지 않았다는 이유로 크래시되어서는 안된다. 전통적인 개발 방법에서는 어플리케이션을 구동하기 위해서는 순차적으로 무언가들을 시작하고 종료해야 하는 것이 있었다. 하지만 이 마인드를 컨테이너 오케스트레이션 개념으로 가져와서는 안된다.
- 쿠버네티스나 도커에서는 이 컴포넌트들은 동시에 시작하고, 따라서 특정한 ‘시작하기 위한 순서’가 존재할 수 없다.
- 만약 어플리케이션이 이미 실행 중인 경우에도 종속성 설정이 실패하거나 마이그레이션되어서 추가 이슈를 만들 수 있다.
- 쿠버네티스를 사용하는 현실 세계에서는 의종성에 도달할 수 없는 무수한 잠재적 통신 장애 지점으로 가득 차 있기 때문에, 그 동안 파드들이 크래시되거나 서비스를 이용할 수 없는 상태가 될 수 있다.
- weak signal이나 네트워크 인터럽트와 같은 네트워크 지연은 보통 이런 통신 장애의 일반적인 원인이다.
간단히 말해서 재고 DB와 스토어 UI를 가진 가상의 쇼핑 어플리케이션을 생각해보자.
- 어플리케이션이 시작되기 전에는 백엔드 서비스의 모든 검사들이 충족된 이후에 시작되어야 한다.
- 그 후에 프론트엔드 서비스가 모든 검사들이 충족된 이후에 시작되어야 한다.
이 때, 우리가 kubectl wait
명령을 통해 배포 순서를 강제했다고 가정해보자.
하지만 wait
명령에 대한 조건이 만족되지 않으면 다음 배포 과정은 진행될 수 없고, 이는 곧 전체 배포 과정을 깨뜨린다.
쿠버네티스는 자가 회복 과정을 포함한다. 표준적인 접근 방법은 어플리케이션의 모든 서비스들을 동시에 시작하고, 전체 서비스가 뜰 때까지 컨테이너가 크래시되고 재시작되는 것을 반복한다.
디커플링된, stateless한 클라우드 네이티브한 A 서비스와 B 서비스가 독립적으로 실행되는 경우에서,
- UI 서비스 (서비스 B)에게 더 예쁜 메시지 로딩(UX)을 위해 서비스 A가 준비될 때까지 기다리라고는 할 수 있다.
- 하지만 실제로 서비스 B의 시작이 서비스 A에 의해서 영향을 받아서는 안된다.
물론 서비스를 개발하고 운영하는 입장에서는 쿠버네티스의 자가 회복 과정에만 의존해서는 안된다. 필연적이고 일어날 수 있는 실패에 대한 에측을 해야 할 것이고, 그에 대한 해결책도 강구해야 한다. 실패하는 상황이 발생할 것으로 예상하고 다운 타임과 데이터 손실을 최소화하는 방향으로 대응하기 위한 아키텍쳐를 설계해야 한다.
서비스의 의존성이 만족되지 않아 서비스가 크래시되는 경우는 방금 서술했던 ‘항상 존재하는 가능성’에 속한다. 때문에 이 영향을 최소화하기 위해서는 Retry 패턴을 구현해야 한다.
- Cancel
- 일시적인 실패가 아니거나 반복된 시도에도 불구하고 프로세스가 실패하는 경우, 어플리케이션은 작업을 취소하고 예외에 대한 처리를 해야 한다.
- 인증 실패에 대한 예시가 이에 속한다. 유효하지 않은 자격 증명은 항상 실패한다.
- Retry
- 오류가 비정상적이거나 드문 경우 네트워크 패킷 손상과 같은 정말 드문 케이스일 수 있다.
- 동일한 오류가 발생할 가능성이 희박하기 때문에 어플리케이션은 요청을 즉각적으로 재시도해야 한다.
- Retry after delay
- 연결이나 트래픽 과다에 따른 일반적인 문제 때문에 오류가 발생하는 경우 요청을 재시도하기 전에 작업에 대한 백로그나 트래픽에 대한 정리를 할 시간을 가지는 것이 좋다.
- 이 경우 어플리케이션은 요청을 재시도하기 전에 잠시 기다린다.
복구 가능한 MSA를 만들기 위해서는 서킷 브레이킹 패턴을 구현하는 것이 중요하다. 서킷 브레이킹 패턴은 부분적인 네트워크 손실이나 서비스의 완전한 실패와 같이 해결하기 위해 더 많은 인적 리소스가 필요한 문제들의 영향을 최소화하면서 어플리케이션을 만드는 것을 돕는다.
4. pod들을 메모리와 CPU 제한 없이 배포하는 것
리소스 할당은 서비스에 따라 다르게 이루어져야 하고, 구현을 테스트하지 않고 컨테이너가 최적의 성능을 내기 위해서 필요한 리소스를 예측하는 것은 어렵다. 어떤 서비스는 고정된 CPU와 메모리 소모양이 필요하고, 어떤 서비스는 동적으로 필요한 리소스 사용량이 변화할 수 있다.
만약 이런 리소스 할당 제한에 대한 진중한 고민 없이 배포를 하게 된다면 리소스 경합과 불안정한 서비스 환경으로 이끌 수 있다. 컨테이너가 메모리나 CPU 제한을 가지지 않는다면 스케쥴러는 메모리 활용도를 0으로 평가할 것이고, 모든 노드에서 무제한의 파드들이 스케쥴 될 수 있다. 이 케이스는 결국 이용 가능한 노드와 리소스를 전부 소진할 것이고, kubelet은 죽게 된다.
컨테이너에 대한 메모리 제한 (또는 CPU)을 설정하지 않은 경우, 다음과 같은 시나리오가 고려될 수 있다.
- 컨테이너가 사용할 수 있는 메모리 양에 상한이 존재하지 않는다 -> 컨테이너는 노드 내에서 사용 가능한 모든 메모리를 사용할 수 있다 -> OOM 킬러 발동
- 컨테이너가 실행 중인 네임스페이스의 기본 메모리 제한이 컨테이너에 할당된다.
- 이 경우 클러스터 관리자가
LimitRange
를 통해 메모리 제한에 대한 기본 값을 설정할 수 있다.
- 이 경우 클러스터 관리자가
클러스터 내의 컨테이너들에 대한 메모리와 CPU 제한을 설정하는 것은 클러스터 내의 노드 리소스를 효율적으로 사용할 수 있도록 돕는다.
5. latest
태그를 프로덕션 컨테이너에서 사용하는 것
latest
태그를 사용하는 것은 특히 프로덕션 환경에서 좋지 않다.
파드들이 예상하지 못하게 크래시될 수 있는 가능성이 항상 존재하기 때문에 이미지를 언제든 가져올 수 있기 때문이다.
하지만 latest
태그는 빌드 과정이 어긋났을 때 정확한 이미지를 가르키지 않는다. 실행 중인 이미지가 어떤 버전이었는지 모르기 때문이다.
기본적으로 imagePullPolicy
는 Always
로 설정되고, 이는 기본적으로 파드가 재시작되었을 떄 항상 이미지를 내려 받는다.
만약 태그를 지정하지 않는다면 쿠버네티스는 latest
로 태그를 설정한다.
만약 imagePullPolicy
를 Always
가 아닌 다른 값을 설정했더라도 파드는 재시작하는 시점에서 이미지를 가져올 수 있다.
만약 버저닝을 사용하고, imagePullPolicy
를 v1.4.0처럼 의미 있는 태그로 설정한다면 가장 안정적인 버전으로 롤백할 수 있고, 코드에 잘못된 것이 있어도 쉽게 트러블 슈팅할 수 있는 장점이 있다.
또한 의미 있고 구체적인 도커 태그를 사용하기 위해서는 컨테이너가 stateless 하고 immutable하다는 것을 기억해야 한다. 컨테이너는 일시적인 데이터에 대한 개념이기 때문에 컨테이너 외부의 모든 데이터는 기본적으로 영구적인 저장소에 저장되어야 한다. 컨테이너를 spin up 한 이후에는 수정해서는 안된다. 설정을 업데이트해야 하는 경우 업데이트 된 설정으로 새 컨테이너를 배포해야 한다.
- 이 immutability는 더 안전하고 반복 가능한 배포들을 가능하게 한다.
- 이전 이미지를 재배포해야 하는 경우 훨씬 더 쉽게 롤백할 수 있다.
- 도커 이미지와 컨테이너를 immutable하게 유지함으로써, 동일한 컨테이너 이미지를 모든 단일 환경에서 배포할 수 있다.