[Kubernetes in Action] Best practices for developing apps

2022. 3. 20. 18:00DevOps/Docker & Kubernetes

 

 

 

Bringing everything together

앞선 여러 포스팅들을 통해 쿠버네티스가 어떤 리소스들을 갖는지, 어떤 식으로 리소스들을 구성하는지에 대해 살펴보았습니다. 이번 포스팅에서는 위의 내용들을 종합하여 일반적인 애플리케이션이 쿠버네티스 위에서 어떤 식으로 동작하는지 알아보겠습니다.

 

 

일반적으로 쿠버네티스를 통해 애플리케이션을 배포할 때는 Deployment 혹은  Statefulset을 반드시 사용합니다. 이들은 하나 이상의 컨테이너를 포함하는 파드 템플릿(pod template)을 가지고 있으며, 이 템플릿 안에는 liveness probe, readiness probe가 모두 정의되어 있습니다. 파드 안의 컨테이너 내에서 실행되는 애플리케이션은 서비스(Service)를 통해 접근할 수 있으며, 클러스터 외부에서 해당 서비스에 접근하기 위해서는 Loadbalancer, Nodeport 타입의 서비스를 사용하면 됩니다. (Ingress를 사용해 외부로 노출할 수도 있습니다.)

 

 

파드 템플릿은 두 종류의 Secret을 Reference합니다. 하나는 private image registry에서 이미지를 가져오기 위해서 사용하는 Secret이고, 다른 하나는 컨테이너 내부에서 사용하는 Secret입니다. (환경변수 등을 관리하기 위해) 여기서 'Reference'한다고 이야기한 이유는 실제로 파드 템플릿에서 해당 값을 '참조'만 할 뿐 value 자체를 들고 있지는 않기 때문입니다. 즉 manifest에 포함되지 않기 때문에 개발자가 생성하는 값이 아니고, 클러스터 관리자나 Operation Team에서 생성한 Secret을 참조하여 해당 값을 주입하는 식으로 동작하게 됩니다. 

 

 

환경변수를 관리하기 위해 Secret이외에도 ConfigMap을 사용할 수 있으며, 저장공간(Storage)이 필요한 경우 PVC(Persistent Volume Claim)을 사용합니다. Volumes를 다룬 포스팅에서 확인할 수 있듯, 클러스터 관리자라 PV(Persistent Volume) 리소스를 생성해두면 개발자가 해당 리소스를 PVC를 통해 사용하는 식의 형태를 취합니다. 

 

 

주기적인 작업이 필요한 경우 Jobs나 Cronjob을 사용하고, 시스템 관리자들이 DaemonSet을 생성할 수도 있습니다. 이외에도 클러스터 관리자는 LimitRange, ResourceQuota 등을 사용해서 파드들의 리소스 사용량(CPU, Memory)을 적절하게 제한하고 관리할 수 있습니다.

 

 

애플리케이션이 배포되면 서비스와 함께 Endpoint들이 생성되고 Deployment / StatefulSet과 함께 ReplicaSet이 생성됩니다. (Deployment를 다룬 포스팅에서 확인할 수 있듯, Deployment는 내부적으로 ReplicaSet을 생성합니다.) 이외에도 쿠버네티스 리소스에는 Label을 붙여서 관리하고, 메타데이터를 사용하여 특정 자원들을 저장하고 운영 및 관리할 수 있습니다.

 

 

 

Understanding the pod's lifecycle

파드와 VM(가상 머신)은 하나의 애플리케이션을 관리할 때 비슷한 방식(완전히 격리된 환경에서 동작)을 사용한다고 생각할 수도 있지만 실제로는 주요한 차이점이 존재합니다. 차이점 중 하나는 VM과는 다르게 파드는 언제든지 삭제되고 다시 생성될 수 있으며, 한 노드에서 Kill 된 파드가 다른 노드에서 새롭게 생성될 수 있다는 점입니다. 

 

 

Applications must expect to be killed and relocated

쿠버네티스를 사용해서 애플리케이션을 관리하는 경우, 애플리케이션은 매우 자주, 그리고 자동화된 프로세스에 의해 재배치됩니다.(relocated much more frequently and automatically). 따라서 애플리케이션 개발자들은 애플리케이션이 빈번하게 재배치된다는 것을 감안하여 안정적인 애플리케이션을 설계해야 합니다.

 

EXPECTING THE LOCAL IP AND HOSTNAME TO CHANGE

파드가 Kill되고 다른 곳에서 다시 새로운 인스턴스로 생성되는 경우, 새로운 IP 주소와 hostname을 할당받게 됩니다. 애플리케이션이 이러한 변경에 자유롭다면(Stateless 하다면) 문제가 없지만, Stateful 한 경우 예상치 못한 문제를 일으킬 수 있습니다. StatefulSet을 사용하면 Hostname을 유지할 수 있으나, 여전히 IP주소는 새롭게 할당됩니다. 따라서 애플리케이션이 IP 주소의 변경에 영향을 받지 않도록 설계해야 합니다.

 

EXPECTING THE DATA WRITTEN TO DISK DISAPPEAR

kubelet은 파드 내의 컨테이너를 "재시작"하지 않습니다. 언제나 새로운 컨테이너를 새롭게 시작하기 때문에 liveness probe가 error를 내뱉거나 out of memory 에러로 인해 컨테이너가 다시 시작해야 할 경우, 기존 컨테이너에 저장되었던 Writable Layer는 모두 삭제되고 새로운 Writable Layer가 생성됩니다. 즉, 기존에 저장되었던 데이터가 모두 삭제된다는 의미입니다. 따라서  Persistent Storage를 마운트 해서 컨테이너 내부가 아닌 컨테이너 외부의 Volume에 데이터를 저장해야 데이터 유실을 막을 수 있습니다.

 

 

 

 

USING VOLUMES TO PRESERVE DATA ACROSS CONTAINER RESTARTS

Persistent Volume은 컨테이너 내부가 아닌 "파드 내부"에 볼륨을 마운트 하므로 컨테이너가 재시작되어 Writable Layer가 완전히 새롭게 생성되어도, 기존 PV에는 영향을 받지 않습니다. 하지만 이러한 방법도 경우에 따라서는 좋지 않을 수 있는데, Volume 내의 데이터 자체에 문제가 생긴 경우 컨테이너가 해당 데이터를 사용하면서 "반복적으로" 에러를 발생시키며 kill 되는 현상이 발생할 수도 있습니다.

 

 

 

Rescheduling of dead or partially dead pods

파드내의 모든 컨테이너가 정상적으로 동작하지 않는 상태를 'dead'라고 하고, 파드가 2개 이상의 컨테이너로 구성된 상황에서 일부 컨테이너가 정상적으로 동작하지 않는 상태를 'partially dead'라고 합니다. dead나 partially dead 상태인 파드가 ReplicaSet의 구성요소로써 동작하고 있는 경우라고 할지라도, 해당 상태의 파드는 Rescheduling이 일어나지 않는다는 사실에 주목할 필요가 있습니다. 즉 ReplicaSet의 desired Pod Count가 3이고 그중의 1개의 파드가 dead나 partially dead 상태인 경우, 실제로 ReplicaSet은 2개의 정상적인 파드만 동작하도록 두고 나머지 1개의 파드는 재시작하지 않습니다. 

 

 

다시 말해서, ReplicaSet Controller는 파드가 dead 상태인지에 대해서는 별로 관심이 없다는 것을 의미합니다. 단지 존재하는 파드의 개수가 ReplicaSet의 Desired Replica Count와 일치하는지만 확인합니다. 이렇게 동작하는 이유는 어차피 파드는 격리된 환경에서 동작하고 있으므로, Rescheduling 되더라도 같은 환경에서 동작하고 있을 확률이 높다고 가정하기 때문입니다.

 

 

 

 

Starting pods in a specific order

UNDERSTANDING HOW PODS ARE STARTED

쿠버네티스를 통해 Multi-pod 애플리케이션을 실행시킬 경우, 각각의 애플리케이션을 지정된 순서로 실행할 수 있는 build-in way는 존재하지 않습니다. 물론 YAML / JSON 파일에 순서대로 리소스를 명시하면 쿠버네티스는 각 리소스를 파일에 입력된 순서대로 처리하기는 하지만 이는 어디까지나 etcd에 해당 순서대로 리소스를  write 하는 것뿐이고, 생성된 리소스가 정상적으로 '실행되는 순서'는 보장하지 않습니다. 따라서 쿠버네티스에서는 init container를 사용하여 특정 조건이 만족되기 전까지는 파드의 메인 컨테이너를 실행하지 않는 방법을 제공합니다.

 

INTRODUCING INIT CONTAINERS

Init Container는 파드를 시작할 때 (초기화할 때) 사용하는 컨테이너 입니다. 파드가 가질 수 있는 Init Container의 개수에는 제한이 없으며, 다른 리소스와 동일하게 YAML에 입력한 순서대로 실행되고, 마지막 Init Container가 종료되어야 MainContainer가 실행됩니다. 따라서 Init Container를 사용하면 특정 컨테이너가 종료되기 전까지 Main Container의 실행을 지연시킬 수 있게 됩니다.

 

spec:
  initContainers:
  - name: init
    image: busybox
    command:
    - sh
    - -c
    - 'while true; do ...;'

 

Adding lifecycle hooks

Init Container와는 다르게 파드에는 2가지의 Lifecycle Hook이 존재합니다. Hook은 컨테이너별로 생성할 수 있으며, 컨테이너 내에서 특정 Command를 실행하거나 HTTP Get 요청을 보낼 수 있도록 도와줍니다.

 

 

Post Start Hook

Post Start Hook은 컨테이너의 프로세스가 시작되자마자 실행됩니다. 이는 컨테이너 프로세스와 별개로 추가적인 작업을 실행할 수 있게 해 주며, 컨테이너 안의 프로세스가 Third Party Application인 경우 도움이 될 수 있습니다. Hook은 컨테이너 프로세스와 병렬로 실행되지만, 실제로 Hook이 완료되기 전까지 컨테이너는 ContainerCreating에 있게 되며 따라서 Waiting 상태로 실행되지 않고 있습니다. 때문에 Hook이 종료되기 전까지 파드의 상태는 Running이 아닌 Pending 상태에 있게 됩니다. 

apiVersion: v1
kind: Pod
metadata:
  name: pod-with-poststart-hook
spec:
containers:
- image: luksa/kubia
  name: kubia
  lifecycle:
    postStart:
      exec:
        command:
            - sh 
            - -c
            - "echo 'hook will fail with exit code 15'; sleep 5; exit 15"

 

Pre Stop Hook

Pre Stop hook은 컨테이너가 죽기 전에 실행됩니다. 컨테이너가 종료될 때 Kubelet은 우선 Pre Stop Hook을 실행하고 컨테이너 프로세스에게 SIGTERM 을 보내 컨테이너 프로세스를 종료합니다. Pre Stop hook를 사용하면 컨테이너의 graceful shutdown을 구현할 수 있으며, 이외에도 컨테이너 프로세스 종료 전에 해야 하는 정리 작업을 수행할 수도 있습니다.

 

Post Start hook 과 다른 점은 실행 결과와 관계없이 컨테이너 프로세스가 종료된다는 점입니다. (Post Start Hook은 해당 훅이 실패하면 메인 컨테이너도 kill 됩니다.) 물론 pre-stop hook 이 실패하면 event에FailedPreStopHook 이 발생하지만, 컨테이너가 곧 지워지므로 눈치채지 못할 수는 있습니다.

 

 

Lifecycle hooks for Containers

한 가지 주의할 점은 Lifecycle Hook은 컨테이너 레벨에서 실행해야 하며 파드 레벨에서 실행하는 것이 아니라는 사실입니다. 위에서 살펴보았듯, Lifecycle Hook은 파드의 어떤 액션에 대해 트리거 되는 것이 아니라 Container가 시작될 때, 종료될 때 트리거되는 것이기 때문에 하나의 파드에서 여러 번 수행될 수 있기 때문입니다. 따라서 컨테이너가 교체될 때 파드 전체의 데이터를 정리하는 Pre Stop Hook을 사용하는 등의 Usage는 지양해야 합니다.

 

 

Understanding pod shutdown

파드 종료는 API 서버에 HTTP Delete Request를 보냄으로써 트리거 됩니다. API 서버가 해당 요청을 받으면 바로 해당 오브젝트(파드)를 지우는 것이 아니라, deletionTimestamp를 해당 파드에 붙입니다. 이 필드 값이 존재하는 파드들은 Terminating 상태가 됩니다.

 

이제 Kubelet이 해당 파드가 종료되어야 한다는 사실을 감지하면 컨테이너를 하나씩 종료합니다. 이때 기본적으로 30초의 graceful shutdown time을 제공하며 해당 시간이 지나면 컨테이너를 강제 종료합니다. 이 시점에서 구체적으로는 다음의 과정을 거칩니다

 

  1. Pre Stop Hook이 존재하는 경우, 이를 실행합니다. 
  2. Pre Stop Hook이 완료되는 경우 컨테이너에 SIGTERM을 보냅니다
  3. 해당 시그널을 받고 컨테이너가 Grace Period 내에 존재하지 않는다면  SIGKILL을 보내 강제로 종료합니다.

 

 

 

 

Shutdown Handler

컨테이너에서 실행 중인 애플리케이션은 SIGTERM 시그널을 받으면 이에 적절하게 반응하여 종료할 준비를 시작해야 합니다. 애플리케이션이 Stateless 한 경우에는 그냥 해당 컨테이너를 종료해도 문제가 없습니다. 하지만 해당 애플리케이션이 Stateful 하다면, 종료 전에 해당 데이터를 옮겨야 하며, 데이터가 옮겨졌다는 것을 보장받은 뒤에 해당 컨테이너를 종료해야 합니다.

 

 

이를 수행하기 위해 애플리케이션 컨테이너가 SIGTERM을 받았을 때 새로운 Job 리소스를 생성하고 해당 Job이 애플리케이션의 데이터를 다른 곳으로 옮겨줄 파드를 생성하도록 할 수 있습니다. 다만 노드에 문제가 생기는 경우 해당 Job이나 파드가 정상적으로 생성되지 않을 수 있으므로, 이러한 지점이 우려된다면 아예 파드 하나를 데이터 이전 용도로 하나 띄워두는 방법을 고려할 수도 있습니다. 

 

 

 

 

Ensuring all client requests are handled properly

 

Preventing broken client connection when pod is starting up

서비스의 Endpoint 리소스에 파드의 IP가 들어가려면 파드가 Ready 상태가 되어야 합니다. 따라서 이를 처리하기 위해 Readiness Probe만 잘 사용하게 된다면 파드가 시작될 때 요청을 처리하지 못할 걱정은 할 필요가 없습니다. 즉, 해당 파드가 IP를 통해 요청을 받을 수 있으려면 애초에 파드가 Ready 상태가 되어야 하기 때문에 Readiness Probe을 적절히 사용해서 파드가 올바른 상태로 Ready 될 수 있도록 처리하면 된다는 의미입니다.

 

Readiness Probe를 별도로 명시하지 않으면 파드는 항상 Ready 상태로 간주됩니다. 따라서 파드가 준비되어있지 않은 상태에서 Ready 상태가 되면 파드가 위치한 iptables에 해당 파드의 ip가 업데이트되며 클라이언트는 이 IP로 요청을 보낼 수 있게 됩니다. 이 경우 클라이언트는 "connection refused"와 같은 에러를 보게 됩니다.

 

Preventing broken connections during pod shutdown

파드가 종료될 때 Client Request를 처리하는 것에 대해서는 크게 2가지 사항을 고려할 수 있습니다. 

  • 요청을 받았지만 아직 처리되지 않았을 수 있고
  • Persistent Connection이 존재할 수도 있습니다.

이를 어떻게 해결할지 알아보기 위해 우선 Pod의 Shutdown Process가 어떻게 진행되는지를 알아보겠습니다.

 

 

 

API 서버가 클라이언트로부터 DELETE Pod 요청을 받으면 가장 먼저 etcd의 상태를 변경합니다. 그 후 해당 파드를 watch 하고 있는 프로세스에게 해당 파드가 DELETE Request를 받았다고 알려줍니다. 그 후 Kubelet과 Endpoints가 해당 사항에 대한 알림을 받게 됩니다. Kubelet의 경우 앞서 이야기한 Shutdown Procedure를 수행합니다. (hook 돌리고, container에 시그널을 보내 종료) Endpoint Controller의 경우 우선 해당 파드를 모든 서비스의 Endpoint에서 제거합니다. endpoint에서 제거하기 위해 API 서버에 요청을 하기 때문에 API 서버는 Endpoint에서 해당 파드를 제거하고 나서 Endpoint Object를 watch 하고 있는 각 노드의 kube-proxy에게 변경 사실을 알려줍니다. 그러면 kube-proxy는 해당 알림을 받고 노드의 iptables rule을 변경하여 종료 중인 파드로는 요청이 오지 않도록 처리합니다.

 

 

주목할 부분은 iptables rule이 변경되었다고 하더라도 이미 변경 전에 맺은 요청이 변경되지는 않는다는 점과, shutdown process를 수행하는 데 걸리는 시간보다 iptables rule을 수정하는 데 걸리는 시간이 더 길다는 것입니다. 따라서 이를 안정적으로 처리하기 위해서는 결국 컨테이너의 종료를 뒤로 미뤄야 합니다. 먼저 iptables를 수정한 뒤에 해당 수정이 끝나서 더 이상 요청이 들어오지 않게 되면, 그 뒤에 shutdown process를 수행하는 식으로 처리할 수 있습니다.

 

 

 

Making your apps easy to run and manage in Kubernetes

 

Making managable container images

이미지는 최대한 가볍게 유지하는 것이 좋습니다. 불필요한 파일은 다운로드 속도를 지연시켜 파드의 Scaling을 늦추게 됩니다. 경우에 따라서 추후 디버깅을 위해 애플리케이션 실행을 위한 코드 이외에도 curl과 같은 명령어 도구들은 남겨두는 것이 좋습니다.

 

Properly tagging your images and using ImagePullPolicy Wisely

이미지 태그를 latest로 설정하면 특정 버전이 어떤 것인지에 대해 알 수 없으므로 불편함을 초래합니다. 또한 이전 버전의 이미지로 롤백이 불가능해진다는 단점도 있습니다. 따라서 가급적이면 이미지 태그에 올바른 버전을 명시하여 사용하는 것이 좋습니다.

 

ImagePullPolicy의 경우 Always로 사용하게 되면 항상 레지스트리에 접속해서 이미지를 다운로드하기 때문에 파드의 시작이 느려질 수 있습니다. 또한 최악의 경우 레지스트리 접속에 실패하면 파드가 시작되지 않을 수도 있습니다.

 

Using multi-dimensional instead of single-dimensional labels

파드를 비롯한 모든 리소스들은 Label을 붙이는 것이 좋습니다. 또한, 각 리소스마다 Label은 여러 개 붙여서 각 key 별로 Selection이 가능하도록 처리하는 것이 좋습니다. 자주 사용되는 Key들은 다음과 같습니다.

 

  • 이름
  • Tier (프론트엔드/백엔드 등)
  • Environment (dev/staging/prod/qa)
  • 버전
  • 릴리스 타입 (stable, canary 등)

 

Describing each resource through annotations

추가적인 정보를 제공하기 위해서는 Annotation을 사용할 수 있습니다. 리소스를 설명하거나 담당자 연락처가 들어가는 것도 좋습니다. 만약 MSA를 사용한다면 이 파드를 사용하는 다른 마이크로 서비스들의 목록을 기입하는 것도 좋은 방법이 될 수 있습니다.

반응형