無知

갈 길이 먼 공부 일기

기술 공부/쿠버네티스

쿠버네티스 (10) | 스테이트풀셋

moozii 2022. 3. 9. 18:18
앞으로의 스터디 내용은 <Kubernetes in Action>을 기반으로 진행합니다.
자세한 내용은, 해당 책을 확인해주세요! 
http://www.yes24.com/Product/Goods/89607047 

핵심 내용

  • 복제된 파드에 개별 스토리지 제공
  • 파드에 안정적인 아이덴티티 제공
  • 스테이트풀셋을 위한 헤드리스 거버닝 서비스 생성
  • 스테이프풀셋 스케일링 및 업데이트
  • DNS를 통한 스테이트풀셋 피어 디스커버리
  • 호스트 이름을 통한 다른 멤버 연결
  • 스테이트풀 파드 강제 삭제

 

스테이트풀 파드 복제하기 (스테이트풀셋의 필요성)

인스턴스마다 개별적인 스토리지 제공하기

레플리카셋을 통해 파드 템플릿으로 여러 파드 레플리카를 생성하는 경우, 각 레플리카가 별도의 퍼시스턴트 볼륨 클레임을 사용하도록 만들 수 없다. 각 인스턴스 별로 각각의 스토리지를 갖도록 하기 위해서는, 레플리카셋 사용이 어렵다. 개별 스토리지를 갖는 레플리카 여러 개를 만들기 위해서는 아래의 방법들이 존재한다.

  1. 수동으로 파드 생성하기 :
    파드의 감시 및 관리 측면에서 번거로우므로 제외한다.
  2. 파드 인스턴스 별로 각각의 레플리카셋 생성하기 :
    스케일링 측면에서 레플리카 수 변경이 어려우므로, 추가 레플리카셋을 수동으로 생성해주는 번거로움이 발생한다.
  3. 동일 볼륨을 여러 디렉토리로 사용하기 :
    각 인스턴스가 생성되는 시점에, 다른 인스턴스가 사용하지 않는 데이터 디렉터리를 자동으로 선택 혹은 생성하도록 한다. 다만, 인스턴스 간 조정이 필요하고, 공유 스토리지 볼륨에 병목 현상이 발생할 우려가 있어 권고되지 않는다.

 

애플리케이션의 안정적인 아이덴티티 제공하기

오랜 시간 지속적으로 변하지 않는 아이덴티티를 애플리케이션에 제공해야 하는 경우가 생긴다. 안정적인 네트워크 아이덴티티를 지니지 못하는 경우는 다음과 같이 발생한다. 파드가 교체되어 새로운 호스트 이름과 IP를 지니게 될 경우, 새로운 네트워크 아이덴티티를 가지게 되는 것이므로, 이때마다 애플리케이션의 모든 클러스터 멤버들의 각 설정 파일에 그 변경사항을 기재해주어야 하므로, 리스케줄링마다 애플리케이션 클러스터의 재구성이 진행되어야만 한다.

이를 해결하기 위해서는 각 개별 멤버에 전용 쿠버네티스 서비스를 생성해 안정적인 네트워크 주소를 제공해야 한다.

 

개별 스토리지 + 안정적인 아이덴티티

위의 2가지 문제에 대한 해결책을 하나의 구조로 표현하면, 각 파드마다 서비스와 레플리카셋을 하나씩 제공하는 것이다. 이는, 개별 파드 입장에서는 어떤 서비스로 노출되는지 알 수 없다는 점에서 다른 파드에 스스로를 등록할 수 없는 문제가 발생하는 등, 불완전한 해결책이다.

이 문제들을 완전히 해결하기 위해서는 스테이트풀셋을 사용해야 한다.

 

스테이트풀셋 이해하기

스테이트풀셋은, 인스턴스가 안정적인 이름/상태를 가지고 개별적으로 취급될 필요가 있는 애플리케이션용 리소스이다.

 

https://cloud.google.com/kubernetes-engine/docs/concepts/statefulset

스테이트풀셋은 애플리케이션의 스테이트풀을 관리하는데 사용하는 워크로드 API 오브젝트이다.

파드 집합의 디플로이먼트와 스케일링을 관리하며, 파드들의 순서 및 고유성을 보장한다 .

디플로이먼트와 유사하게, 스테이트풀셋은 동일한 컨테이너 스펙을 기반으로 둔 파드들을 관리한다. 디플로이먼트와는 다르게, 스테이트풀셋은 각 파드의 독자성을 유지한다. 이 파드들은 동일한 스팩으로 생성되었지만, 서로 교체는 불가능하다. 다시 말해, 각각은 재스케줄링 간에도 지속적으로 유지되는 식별자를 가진다.

스토리지 볼륨을 사용해서 워크로드에 지속성을 제공하려는 경우, 솔루션의 일부로 스테이트풀셋을 사용할 수 있다. 스테이트풀셋의 개별 파드는 장애에 취약하지만, 퍼시스턴트 파드 식별자는 기존 볼륨을 실패한 볼륨을 대체하는 새 파드에 더 쉽게 일치시킬 수 있다.
https://kubernetes.io/ko/docs/concepts/workloads/controllers/statefulset/ 

StatefulSet의 별칭, PetSets를 살펴보며 그 특성을 이해하자.

 

애완동물과 가축, Pet and Cattle

애플리케이션을 어떠한 방식으로 취급하는가를 다룰 때 자주 등장하는 개념이다. 애플리케이션에 이름을 부여하고 개별적으로 하나하나 관리해주는 방식은 애완동물을 대하는 것과 비슷하고, 개별 인스턴스에 주의를 기울이지 않고 보다 객체로서 취급하는 방식은 가축을 대하는 것과 비슷하다는 의미이다.

스테이트리스 애플리케이션의 경우 명세를 통해 정의하고 언제든 교체 가능성을 염두해 두는 등, 가축과 같은 관리 방식을 사용한다고 볼 수 있다. 이와 달리 스테이트풀 애플리케이션은 인스턴스 각각이 대체되기 어렵고, 교체를 할 신규 애플리케이션은 교체 대상과 완전히 같은 상태, 아이덴티티가 보장되어야 하므로 애완동물과 같은 관리방식을 가져야 한다.

 

레플리카셋, 레플리케이션 컨트롤러와 스테이트풀셋 비교

  레플리카셋 레플리케이션 컨트롤러 스테이풀셋
파드 교체성
단순 교체
(Stateless)
삭제된 파드와 동일한 이름, 호스트 이름 보장 (Stateful)
레플리카 수 지정
O
파드 템플릿 사용
O
생성 파드 특성
자체 스토리지 미보유
각 파드별 자체 볼륨 세트 (스토리지)를 보유
무작위 아이덴티티
예측가능한 아이덴티티
(파드 및 호스트 이름, 거버닝 서비스)
스케일 업
동일한 파드이므로 무작위 생성
동일한 이름, 호스트이름, 아이덴티티 부여.
신규 파드에 대한 이름 예측 가능 (인덱스 +)
스케일 다운
동일한 파드이므로 무작위 삭제
가장 높은 인덱스 인스턴스를 항상 우선 삭제
인스턴스 전체가 정상일 경우만 진행

파드만 삭제하고 볼륨 클레임은 삭제하지 않음

 

안정적인 네트워크 아이덴티티

안정적인 네트워크 아이덴티티는,

  1. 파드 및 호스트 이름의 예측 가능성
  2. 자체 DNS 엔트리를 지니게 하는 파드별 네트워크 아이덴티티

등으로 구성된다.

안정성, 예측 가능성을 높이기 위해, 파드, 호스트의 이름은 정해진 규칙에 따라 명명된다.

  • 스테이트풀셋의 이름을 활용한다.
  • 스테이트풀셋이 생성한 파드에, 0부터 시작하는 서수 인덱스가 할당된다.
  • 컨테이너 호스트 이름은 파드의 이름과 동일하게 설정된다.

즉, 파드의 이름은 {statefulset_name}-{pod_index}의 형태가 된다.

또한, 각 파드에게 실제 네트워크 아이덴티티를 제공하기 위해 거버닝 헤드리스 서비스를 스테이스풀셋이 생성한다.

각 파드는 이를 통해 자체 DNS 엔트리를 가지고, 클러스터 피어 혹은 클러스터 내 다른 클라이언트가 호스트 이름으로 파드 주소 지정이 가능하다. FQDN은, {pod-name}.{governing_service_name}.{namespace_name}.svc.cluster.local과 같이 설정된다. 이는 레플리카셋과는 구분되는 특성이다.

또한, {governing_service_name}.{namespace_name}.svc.cluster.local 도메인으로 레코드를 조회하면 DNS를 통해 모든 스페이트풀셋 내 파드 이름을 조회할 수 있다.

 

파드의 교체

스테이트풀셋의 파드가 다시 스케줄링될 경우, 레플리카셋과 마찬가지로 어느 노드에 생성되는가가 중요하지는 않지만, 사라진 기존 파드의 이름 및 호스트 이름이, 교체할 새로운 파드의 이름/호스트 이름과 동일하도록 보장한다. 즉, 동일한 아이덴티티를 지닌 파드로 교체하는 것이다.

 

안정적인 전용 스토리지 제공

리스케줄링이 되어도 신규 인스턴스가 기존에 사용하던 동일 스토리지에 연결되도록 하려면, 스테이트풀셋의 각 파드는 별도의 퍼시스턴트볼륨을 갖도록 서로 다른 퍼시스턴트 볼륨 클레임을 참조해야 한다. 즉, 스테이트풀셋은 각 파드마다 퍼시스턴트 볼륨 클레임을 복제하도록 볼륨 클레임 템플릿을 가진다.

 

퍼시스턴트 볼륨 클레임이 요청하는 퍼시스턴트 볼륨은, 지난 챕터를 상기하자면, 사전에 관리자가 수동으로 프로비저닝할 수도 있지만, 동적 프로비저닝으로 생성할 수 있다.

 

스케일링

스테이트풀셋이 스케일링해서, 새로운 파드를 추가로 생성한다면, 이전에 언급한 예측 가능한 이름 명명 규칙에 따라, 기존 인스턴스 이후의 서수, 인덱스를 부여한다.

또한, 스케일 다운 과정에서 어떤 파드를 삭제할 지도 예측할 수 있다. 항상 가장 높은 인덱스를 먼저 제거한다는 것이다.

스테이트풀셋은 인스턴스 중 하나라도 정상 작동하지 않으면 스케일 다운이 진행되지 않는다. 이는 특정 스테이트풀 애플리케이션은 스케일 다운 시 시점 당 하나의 인스턴스만 스케일다운할 수 있어 그 속도가 느리다는 특성을 가질 수 있다는 점과 연관된다. 예를 들어 데이터 엔트리마다 2개의 복제본을 저장하는 분산 데이터 저장소 애플리케이션이라면, 2개 노드가 동시에 다운되면 데이터를 잃는다. 따라서 순차적으로 하나씩 인스턴스를 스케일다운하면서 손실된 복사본을 대체할 추가 복제본을 다른 곳에 생성하는 시간이 소요된다. 즉, 2개의 복제본 중 하나는 비정상이고 하나는 스케일 다운 대상이라면 동시에 2개가 다운된 상황이므로 2개의 클러스터 멤버의 상실이 일어나는 것이다.

 

또한, 앞서 말했듯, 스테이트풀셋은 스케일링함에 따라 파드만 생성하는 것이 아니라 파드가 참조할 퍼시스턴트볼륨 클레임을 템플릿에 따라 생성한다. 그러나 스케일 다운 시에는 파드만 삭제하고 클레임은 삭제하지 않는데, 그 이유는 클레임에 바인딩된 퍼시스턴트볼륨이 재활용되거나 삭제될 염려가 있기 때문에 유지하는 것이다. 볼륨에 저장된 데이터가 손실되지 않도록 자동으로 클레임을 삭제하지 않으며, 퍼시스턴트 볼륨 해제를 위해서는 수동으로 클레임을 삭제해줘야 한다. 만약 클레임을 삭제하지 않았다면 그 이후 다시 스케일 업이 진행될 때 동일한 클레임이 새로운 파드와 연결되어 되돌릴 수 있고 지속적인 상태값을 가진다.

스테이트풀셋에서 관리하는 스테이트풀 파드가 비정상인 경우에는 스테이트풀셋을 축소할 수 없다. 축소는 스테이트풀 파드가 실행되고 준비된 후에만 발생한다.
spec.replicas > 1인 경우 쿠버네티스는 비정상 파드의 원인을 결정할 수 없다. 영구적인 오류 또는 일시적인 오류의 결과일 수 있다. 일시적인 오류는 업그레이드 또는 유지 관리에 필요한 재시작으로 인해 발생할 수 있다.
영구적인 오류로 인해 파드가 비정상인 경우 오류를 수정하지 않고 확장하면 스테이트풀셋 멤버십이 올바르게 작동하는 데 필요한 특정 최소 레플리카 수 아래로 떨어지는 상태로 이어질 수 있다. 이로 인해 스테이트풀셋을 사용할 수 없게 될 수 있다.
일시적인 오류로 인해 파드가 비정상 상태이고 파드를 다시 사용할 수 있게 되면 일시적인 오류가 확장 또는 축소 작업을 방해할 수 있다. 일부 분산 데이터베이스에는 노드가 동시에 가입 및 탈퇴할 때 문제가 있다. 이러한 경우 애플리케이션 수준에서 확장 작업에 대해 추론하고 스테이트풀 애플리케이션 클러스터가 완전히 정상이라고 확신할 때만 확장을 수행하는 것이 좋다.
https://kubernetes.io/ko/docs/tasks/run-application/scale-stateful-set/

 

스테이트풀셋 보장

쿠버네티스가 파드 상태를 확인하기 어려울 경우에도 상관 없이 원하는 작업을 수행할 수 있도록, 스테이트풀셋은 동일한 아이덴티티를 가지는 교체 파드를 생성한다. 즉, 기존 파드가 실행되지 않는 경우, 동일하게 시스템에 실행되고, 동일한 스토리지에 바인딩되고, 동일한 파일을 작성하는 교체 파드를 생성해 실행하기 때문에, 작업 수행의 성공을 보장한다는 의미이다.

여러 스테이트풀 파드 인스턴스가 동시에 동일한 작업을 수행하지 않도록, at-most-one-semantics, 최대 하나의 의미를 보장해야 한다. 교체 파드 생성 이전에 기존 파드가 실행 중이지 않음을 확신할 수 있어야 한다는 의미이다.

 

스테이트풀셋 사용하기

스테이트풀셋 실습을 위해 클러스터된 데이터 저장소 구축 실습을 진행한다.

먼저, 인스턴스가 단일 데이터 엔트리를 저장, 검색할 수 있도록 확장한다.

$ cat kubia-pet-image/app.js
const http = require('http');
const os = require('os');
const fs = require('fs');

const dataFile = "/var/data/kubia.txt";

function fileExists(file) {
  try {
    fs.statSync(file);
    return true;
  } catch (e) {
    return false;
  }
}

var handler = function(request, response) {
  if (request.method == 'POST') {
  # POST 요청을 받으면 요청의 body를 데이터 파일에 저장
    var file = fs.createWriteStream(dataFile);
    file.on('open', function (fd) {
      request.pipe(file);
      console.log("New data has been received and stored.");
      response.writeHead(200);
      response.end("Data stored on pod " + os.hostname() + "\n");
    });
  } else {
    # GET 및 다른 모든 유형의 요청에 대해 호스트 이름, 데이터파일 콘텐츠를 반환
    var data = fileExists(dataFile) ? fs.readFileSync(dataFile, 'utf8') : "No data posted yet";
    response.writeHead(200);
    response.write("You've hit " + os.hostname() + "\n");
    response.end("Data stored on this pod: " + data + "\n");
  }
};

var www = http.createServer(handler);
www.listen(8080);
$ cat kubia-pet-image/Dockerfile
FROM node:7
ADD app.js /app.js
ENTRYPOINT ["node", "app.js"]

이 도커파일로 위 app.js를 포함한 이미지를 빌드한 뒤, 애플리케이션 배포를 스테이트풀셋을 통해 진행하면 된다.

배포에 필요한 오브젝트는 다음과 같다.

  • 데이터 파일을 저장하기 위한 퍼시스턴트 볼륨 (동적 프로비저닝 혹은 수동)
  • 스테이트풀셋을 위한 거버닝 서비스
  • 스테이트풀셋

 

Step 1. 퍼시스턴트 볼륨 생성

먼저 퍼시스턴트 볼륨을 수동으로 생성한다.

$ kubectl create -f persistent-volumes-hostpath.yaml
persistentvolume/pv-a created
persistentvolume/pv-b created
persistentvolume/pv-c created

명세는 다음과 같다.

$ cat persistent-volumes-hostpath.yaml
kind: List
apiVersion: v1
items:
- apiVersion: v1
  kind: PersistentVolume
  metadata:
    name: pv-a
  spec:
    capacity:
      storage: 1Mi
    accessModes:
      - ReadWriteOnce
    persistentVolumeReclaimPolicy: Recycle
    hostPath:
      path: /tmp/pv-a
- apiVersion: v1
  kind: PersistentVolume
  metadata:
    name: pv-b
  spec:
    capacity:
      storage: 1Mi
    accessModes:
      - ReadWriteOnce
    persistentVolumeReclaimPolicy: Recycle
    hostPath:
      path: /tmp/pv-b
- apiVersion: v1
  kind: PersistentVolume
  metadata:
    name: pv-c
  spec:
    capacity:
      storage: 1Mi
    accessModes:
      - ReadWriteOnce
    persistentVolumeReclaimPolicy: Recycle
    hostPath:
      path: /tmp/pv-c

 

Step 2. 거버닝 서비스 생성

스테이트풀 파드에 네트워크 아이덴티티를 제공하는 헤드리스 서비스를 생성한다.

$ cat kubia-service-headless.yaml
apiVersion: v1
kind: Service
metadata:
  name: kubia
spec:
  clusterIP: None
  selector:
    app: kubia
  ports:
  - name: http
    port: 80
헤드리스 서비스 복습 :
때때로 로드-밸런싱과 단일 서비스 IP는 필요치 않다. 이 경우, "헤드리스" 서비스라는 것을 만들 수 있는데, 명시적으로 클러스터 IP (.spec.clusterIP)에 "None"을 지정한다. 쿠버네티스의 구현에 묶이지 않고, 헤드리스 서비스를 사용하여 다른 서비스 디스커버리 메커니즘과 인터페이스할 수 있다. 헤드리스 서비스의 경우, 클러스터 IP가 할당되지 않고, kube-proxy가 이러한 서비스를 처리하지 않으며, 플랫폼에 의해 로드 밸런싱 또는 프록시를 하지 않는다. DNS가 자동으로 구성되는 방법은 서비스에 셀렉터가 정의되어 있는지 여부에 달려있다.
출처 : https://kubernetes.io/ko/docs/concepts/services-networking/service/
추가 출처 : https://velog.io/@idnnbi/kubernetes-Headless-Service

헤드리스 서비스 생성을 통해 파드 간 피어 디스커버리 사용이 가능하다. DNS를 통해 다른 파드 속 멤버 조회가 가능해지는 서비스의 역할을 앞선 스테이트풀셋 특성 이해 부분에서 살펴본 바 있다.

 

Step 3. 스테이트풀셋 생성

거버닝 헤드리스 서비스를 생성하고 났으니 스테이트풀셋을 매니페스트를 기반으로 생성한다.

$ cat kubia-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: kubia
spec:
  serviceName: kubia
  replicas: 2
  selector:
    matchLabels:
      app: kubia # has to match .spec.template.metadata.labels
  template:
    metadata:
      labels: # 스테이트풀셋으로 생성된 파드가 갖게 되는 레이블
        app: kubia
    spec:
      containers:
      - name: kubia
        image: luksa/kubia-pet
        ports:
        - name: http
          containerPort: 8080
        volumeMounts: # 파드 내부 컨테이너가 볼륨을 마운트하는 위치
        - name: data
          mountPath: /var/data
  volumeClaimTemplates: # 퍼시스턴트 볼륨 클레임 생성 템플릿
  - metadata:
      name: data
    spec:
      resources:
        requests:
          storage: 1Mi
      accessModes:
      - ReadWriteOnce
$ kubectl create -f kubia-statefulset.yaml
statefulset.apps/kubia created

생성을 진행하면, 파드가 바로 의도하던대로 2개가 생성되지 않고, 하나씩 순차적으로 생성되는데, 그 이유는 2개 이상의 멤버가 동시에 생성될 경우 레이스 컨디션에 빠질 가능성이 있기 때문이다. 레이스 컨디션이란, 작업 순서에 따라 동시 진행되는 작업 결과에 영향이 생기는 경우를 의미한다.

$ kubectl get po
NAME                     READY   STATUS              RESTARTS   AGE
kubia-0                  0/1     ContainerCreating   0          7s

$ kubectl get po
NAME      READY   STATUS    RESTARTS   AGE
kubia-0   1/1     Running   0          34s
kubia-1   1/1     Running   0          30s

 

생성 결과 확인하기

$ kubectl get po kubia-0 -o yaml
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: "2022-03-09T04:52:51Z"
  generateName: kubia-
  labels:
    app: kubia
    controller-revision-hash: kubia-c94bcb69b
    statefulset.kubernetes.io/pod-name: kubia-0
  name: kubia-0
  namespace: default
  (중략)
spec:
  containers:
  - image: luksa/kubia-pet
    imagePullPolicy: Always
    name: kubia
    ports:
    - containerPort: 8080
      name: http
      protocol: TCP
    resources: {}
    terminationMessagePath: /dev/termination-log
    terminationMessagePolicy: File
    volumeMounts:
    - mountPath: /var/data
      name: data
    - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
      name: kube-api-access-vzvfc
      readOnly: true
  (중략)
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: data-kubia-0
  - name: kube-api-access-vzvfc
    (중략)
status:
  conditions:
  (중략)
  containerStatuses:
  (중략)
  hostIP: 192.168.59.100
  phase: Running
  podIP: 172.17.0.2
  podIPs:
  - ip: 172.17.0.2
  qosClass: BestEffort
  startTime: "2022-03-09T04:52:51Z"
$ kubectl get pvc
NAME           STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
data-kubia-0   Bound    pvc-8349bd54-ae0c-4471-9b36-d022e9ff960e   1Mi        RWO            standard       25m
data-kubia-1   Bound    pvc-52894070-9a9e-42b7-a31e-18d3c3715668   1Mi        RWO            standard       25m

위와 같이 생성된 파드와 퍼시스턴트 볼륨 클레임 확인이 가능하다.

 

API 서버로 파드와 통신하기

이제 데이터 저장소의 역할을 할 클러스터의 노드들이 실행 중이므로 이에 대해 확인해보자. 우리는 거버닝 헤드리스 서비스를 생성했으므로 헤드리스의 특성 상 서비스를 통한 개별 파드와의 통신이 어렵고, 개별 파드에 직접 연결해야 한다.

다만 이전 실습과는 다른 방법을 통해 파드에 접근해보고자 한다.

API 서버를 파드의 프록시로 활용하는 방안이다. API 서버의 기능 중, 개별 파드에 직접 프록시 연결을 만드는 기능을 활용해, kubectl proxy를 통해 API 서버와 통신하여 파드에 요청을 보내는 방식이다.

<apiServerHost>:<port>/api/v1/namespaces/default/pods/kubia-0/proxy/<path>
 
$ kubectl proxy
Starting to serve on 127.0.0.1:8001
$ curl 127.0.0.1:8001/api/v1/namespaces/default/pods/kubia-0/proxy/
You've hit kubia-0
Data stored on this pod: No data posted yet

통신 방법을 확인했으니 이제 데이터를 저장하고 반환해보자.

$ curl -X POST -d "Saving Data to kubia-0 for test. Test Code : 202203091515-A" 127.0.0.1:8001/api/v1/namespaces/default/pods/kubia-0/proxy/
Data stored on pod kubia-0

$ curl 127.0.0.1:8001/api/v1/namespaces/default/pods/kubia-0/proxy/
You've hit kubia-0
Data stored on this pod: Saving Data to kubia-0 for test. Test Code : 202203091515-A

curl에 대한 옵션 활용법은 다음 아티클을 확인했다.

https://www.lesstif.com/software-architect/curl-http-get-post-rest-api-14745703.html

 

리스케줄링된 파드가 동일 스토리지에 연결되는지 확인하기

$ kubectl delete po kubia-0
pod "kubia-0" deleted

$ kubectl get po
NAME      READY   STATUS              RESTARTS   AGE
kubia-0   0/1     ContainerCreating   0          0s
kubia-1   1/1     Running             0          87m

$ kubectl get po
NAME      READY   STATUS    RESTARTS   AGE
kubia-0   1/1     Running   0          8s
kubia-1   1/1     Running   0          87m

$ curl 127.0.0.1:8001/api/v1/namespaces/default/pods/kubia-0/proxy/
You've hit kubia-0
Data stored on this pod: Saving Data to kubia-0 for test. Test Code : 202203091515-A

리스케줄링되어도, 파드 이름, 호스트 이름, 스토리지가 동일하게 유지됨을 확인 가능하다.

 

헤드리스가 아닌 서비스로 노출하기

$ cat kubia-service-public.yaml
apiVersion: v1
kind: Service
metadata:
  name: kubia-public
spec:
  selector:
    app: kubia
  ports:
  - port: 80
    targetPort: 8080

외부에 서비스를 노출하지는 않는 일반적인 서비스를 생성하여, 클러스터 내부에서만 접근 가능한 서비스를 파드 앞에 추가해보자. 개별 파드에 접근하기 위해 이전과 마찬가지로 프록시를 사용해야 한다.

$ kubectl create -f kubia-service-public.yaml
service/kubia-public created

$ curl 127.0.0.1:8001/api/v1/namespaces/default/services/kubia-public/proxy/
You've hit kubia-1
Data stored on this pod: No data posted yet

이와 같이 퍼블릭 서비스에 요청을 보내 임의의 클러스터 노드를 거쳐 매 호출마다 데이터를 가져올 수 있다.

 

피어 디스커버리

피어 디스커버리는, 클러스터된 애플리케이션의 주요 사항 중 하나로, 클러스터의 다른 멤버를 찾는 기능을 의미한다.

API 서버와 통신해서 확인하는 지난 방법들 이외에, 쿠버네티스-독립적으로, Kuberenetes-Agnostic하게, 애플리케이션을 유지하면서 기능을 노출하는 것이다. DNS 레코드의 한 유형인 SRV 레코드를 사용하면 가능하다.

SRV 레코드(SRV record)는 DNS의 데이터 규격 중 하나이다. RFC 2782에 정의되어 있으며 타입 코드는 33이다. 
https://ko.wikipedia.org/wiki/SRV_%EB%A0%88%EC%BD%94%EB%93%9C

The DNS "service" (SRV) record specifies a host and port for specific services such as voice over IP (VoIP), instant messaging, and so on. Most other DNS records only specify a server or an IP address, but SRV records include a port at that IP address as well. Some Internet protocols require the use of SRV records in order to function.
https://www.cloudflare.com/ko-kr/learning/dns/dns-records/dns-srv-record/

 

SRV 레코드

특정 서비스를 제공하는 서버의 호스트 이름, 포트를 가리키는 데에 사용된다. DNS lookup 도구인 dig를 실행해 SRV 레코드를 조회할 수 있다.

$ kubectl run -it srvlookup --image=tutum/dnsutils --rm --restart=Never -- dig SRV kubia.default.svc.cluster.local

; <<>> DiG 9.9.5-3ubuntu0.2-Ubuntu <<>> SRV kubia.default.svc.cluster.local
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 54952
;; flags: qr aa rd; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 3
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;kubia.default.svc.cluster.local. IN	SRV

;; ANSWER SECTION:
kubia.default.svc.cluster.local. 30 IN	SRV	0 50 80 kubia-1.kubia.default.svc.cluster.local.
kubia.default.svc.cluster.local. 30 IN	SRV	0 50 80 kubia-0.kubia.default.svc.cluster.local.

;; ADDITIONAL SECTION:
kubia-0.kubia.default.svc.cluster.local. 30 IN A 172.17.0.2
kubia-1.kubia.default.svc.cluster.local. 30 IN A 172.17.0.3

;; Query time: 1 msec
;; SERVER: 10.96.0.10#53(10.96.0.10)
;; WHEN: Wed Mar 09 06:45:27 UTC 2022
;; MSG SIZE  rcvd: 350

pod "srvlookup" deleted

srvlookup이라는 일회용 파드를 실행해 콘솔에 연결되고 실행이 종료되면 바로 삭제하는 명령어를 통해 단일 컨테이너를 일시적으로 수행했다. 해당 컨테이너에서 실행한 명령은 ‘--’ 글자 뒤에 이어지는 dig SRV kubia.default.svc.cluster.local 부분으로, 스테이트풀 파드의 SRV 레코드를 조회한다.

ANSWER 부분은 헤드리스 서비스를 보조하는 2개의 파드를 가리키는 2개의 레코드이다.

ADDITIONAL 부분은 자체 A 레코드를 가진다.

파드가 스테이트풀셋의 다른 모든 파드의 목록을 가져오기 위해 SRV DNS 룩업을 수행하면 된다.

dns.resolveSrv("kubia.default.svc.cluster.local", callBackFunction);

 

DNS를 통한 피어 디스커버리

이제 기존에 생성한 데이터 저장소 애플리케이션이 클러스터화되지 않았으므로, 상호 간 통신이 가능하도록 변경을 우선 진행해야 한다. 모든 데이터 엔트리를 확인할 수 있도록, 즉 클라이언트가 모든 파드의 데이터를 가져오기 위해서는 많은 요청이 필요하다. 즉, 노드가 모든 클러스터 노드의 데이터를 응답하도록 하여 개선해야 한다.

먼저, 노드는 모든 피어를 찾아야 한다. 이는 앞선 SRV 레코드를 활용한다.

$ cat kubia-pet-peers-image/app.js
const http = require('http');
const os = require('os');
const fs = require('fs');
const dns = require('dns');

const dataFile = "/var/data/kubia.txt";
const serviceName = "kubia.default.svc.cluster.local";
const port = 8080;


function fileExists(file) {
  try {
    fs.statSync(file);
    return true;
  } catch (e) {
    return false;
  }
}

function httpGet(reqOptions, callback) {
  return http.get(reqOptions, function(response) {
    var body = '';
    response.on('data', function(d) { body += d; });
    response.on('end', function() { callback(body); });
  }).on('error', function(e) {
    callback("Error: " + e.message);
  });
}

var handler = function(request, response) {
  if (request.method == 'POST') {
    var file = fs.createWriteStream(dataFile);
    file.on('open', function (fd) {
      request.pipe(file);
      response.writeHead(200);
      response.end("Data stored on pod " + os.hostname() + "\n");
    });
  } else {
    response.writeHead(200);
    if (request.url == '/data') {
      var data = fileExists(dataFile) ? fs.readFileSync(dataFile, 'utf8') : "No data posted yet";
      response.end(data);
    } else {
      response.write("You've hit " + os.hostname() + "\n");
      response.write("Data stored in the cluster:\n");
      
      # 이 아래 명령을 통해 애츨리케이션에서 SRV 레코드를 얻기 위해 DNS 룩업을 수행한다
      dns.resolveSrv(serviceName, function (err, addresses) {
        if (err) {
          response.end("Could not look up DNS SRV records: " + err);
          return;
        }
        var numResponses = 0;
        if (addresses.length == 0) {
          response.end("No peers discovered.");
        } else {
        
          # SRV 레코드가 가리키는 각 파드는 데이터를 가져오기 위해 연결된다.
          addresses.forEach(function (item) {
            var requestOptions = {
              host: item.name,
              port: port,
              path: '/data'
            };
            
            # SRV 레코드가 가리키는 각 파드는 데이터를 가져오기 위해 연결된다.
            httpGet(requestOptions, function (returnedData) {
              numResponses++;
              response.write("- " + item.name + ": " + returnedData + "\n");
              if (numResponses == addresses.length) {
                response.end();
              }
            });
          });
        }
      });
    }
  }
};

var www = http.createServer(handler);
www.listen(port);

즉, 아래와 같은 과정을 수행한다.

  1. 애플리케이션이 GET 요청을 받는다.
  2. 요청을 받은 서버는 먼저 헤드리스 kubia 서비스의 SRV lookup, 레코드 룩업을 DNS에 수행한다.
  3. GET 요청을 각 파드 전체에 보낸다. (요청을 받은 파드도 코드의 단순함을 위해 스스로 보내고 받는다)
  4. 각 노드에 저장된 데이터를 모다 모든 노드의 리스트를 최종적으로 반환한다.

 

스테이트풀셋의 파드 템플릿 업데이트

파드가 새로운 이미지를 사용하도록 템플릿을 업데이트하고, 레플리카 수를 3으로 변경하고자 한다고 하자.

kubectl edit 명령을 통해 수정 가능하다. 수정 시 그 아래의 코드와 같은 탬플릿이 편집기 형태로 나오게 되는데, 주석 부분과 같이 수정을 진행하면 된다.

$ kubectl edit statefulset kubia
statefulset.apps/kubia edited
# Please edit the object below. Lines beginning with a '#' will be ignored,
# reopened with the relevant failures.
#
apiVersion: apps/v1
kind: StatefulSet
metadata:
  creationTimestamp: "2022-03-09T04:52:51Z"
  generation: 1
  name: kubia
  namespace: default
  resourceVersion: "224019"
  uid: 44f4adf1-543d-4d46-b2d5-a9a4b6afc5fa
spec:
  podManagementPolicy: OrderedReady
  replicas: 3                 # spec.replicas를 2에서 3으로 수정
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app: kubia
  serviceName: kubia
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: kubia
    spec:
      containers:
      - image: luksa/kubia-pet-peers  # 이미지를 kubia-pet에서 다음과 같이 변경
        imagePullPolicy: Always
        name: kubia
        ports:
        - containerPort: 8080
          name: http
          protocol: TCP
        resources: {}
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
        volumeMounts:
        - mountPath: /var/data
          name: data
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
  updateStrategy:
    rollingUpdate:
      partition: 0
    type: RollingUpdate
  volumeClaimTemplates:
  - apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      creationTimestamp: null
      name: data
    spec:
      accessModes:
      - ReadWriteOnce
      resources:
        requests:
          storage: 1Mi
      volumeMode: Filesystem
    status:
      phase: Pending
status:
  availableReplicas: 2
  collisionCount: 0
  currentReplicas: 2
  currentRevision: kubia-c94bcb69b
  observedGeneration: 1
  readyReplicas: 2
  replicas: 2
  updateRevision: kubia-c94bcb69b
  updatedReplicas: 2

그 뒤 확인하면, 변경된 매니페스트를 기반으로 새로이 파드를 생성했음을 확인할 수 있다.

의도된 레플리카 수에 맞춰 신규 파드가 생성된 것뿐만 아니라, 매니페스트 정의대로 롤링업데이트의 적용에 따라 기존 레플리카도 삭제되고 신규 이미지에 맞춰 재생성되었음을 확인할 수 있다. (파드 각각에 kubectl describe로 이미지를 확인하였다)

$ kubectl get po
NAME      READY   STATUS    RESTARTS   AGE
kubia-0   1/1     Running   0          35s
kubia-1   1/1     Running   0          69s
kubia-2   1/1     Running   0          108s
롤링업데이트 외에 OnDelete 업데이트 전략도 사용 가능하며, RollingUpdate를 진행할 때에 updateStrategy field에 partition을 추가하여 단계적 업데이트, 카나리 롤링아웃도 가능하다. 이는 공식 튜토리얼에서 확인 가능하다.
https://kubernetes.io/ko/docs/tutorials/stateful-application/basic-stateful-set/#%EC%8A%A4%ED%85%8C%EC%9D%B4%ED%8A%B8%ED%92%80%EC%85%8B-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%ED%95%98%EA%B8%B0

 

클러스터된 데이터 저장소 사용하기

데이터 저장소의 동작을 확인해보자.

$ curl -X POST -d "10.4.3. Chapter Test Code A" 127.0.0.1:8001/api/v1/namespaces/default/services/kubia-public/proxy/
Data stored on pod kubia-2

$ curl -X POST -d "10.4.3. Chapter Test Code B" 127.0.0.1:8001/api/v1/namespaces/default/services/kubia-public/proxy/
Data stored on pod kubia-2

$ curl -X POST -d "10.4.3. Chapter Test Code C" 127.0.0.1:8001/api/v1/namespaces/default/services/kubia-public/proxy/
Data stored on pod kubia-0

$ curl 127.0.0.1:8001/api/v1/namespaces/default/services/kubia-public/proxy/
You've hit kubia-0
Data stored in the cluster:
- kubia-0.kubia.default.svc.cluster.local: 10.4.3. Chapter Test Code C
- kubia-2.kubia.default.svc.cluster.local: 10.4.3. Chapter Test Code B
- kubia-1.kubia.default.svc.cluster.local: No data posted yet

클라이언트의 요청이 클러스터 노드 중 하나에 도달하면, 모든 피어를 디스커버리해 모든 데이터를 반환해줌을 확인했다.

(실습에서 데이터를 같은 파드에 연속으로 기록하면, 가장 최신의 것만 남는 것도 확인했다.)

 

스테이트풀셋의 노드 실패 처리

앞선 스케일 다운에 대한 이야기에서, 쿠버네티스는 기존 스테이트풀 파드가 실행 중이지 않아야만 새로운 교체용 파드를 생성한다고 이야기한 바 있다. 노드가 갑작스레 실패하면, 쿠버네티스는 파드의 상태를 알 수 없어, 실행 중이 아닌지 스테이트풀셋이 별도로 실행할 수 있는 바가 없다. 즉, 클러스터 관리자가 파드 혹은 해당 노드를 삭제하는 등의 조치를 확실하게 취해줘야 한다. 노드 중 하나가 연결이 종료되는 경우를 확인해보자.

 

노드의 네트워크 연결 해제 실습 (셧다운 시 파드 삭제 과정)

minikube에서는 실습 진행이 불가한 영역이다.

책 속의 실습은 다음과 같은 방식으로 진행된다.

  1. 노드의 네트워크 어댑터, 네트워크 인터페이스를 셧다운하기 >> kubelet이 더 이상 쿠버네티스 API 서버와 연결할 수 없고, 노드 및 파드의 실행 여부를 확인할 수 없다.
  2. 쿠버네티스 마스터에서 본 노드 상태 확인하기 >> 컨트롤 플레인은 노드들의 상태를 NotReady로 표기한다. 파드의 상태는 Unknown으로 표기한다.
  3. Unknown 상태 파드의 삭제 과정 이해하기 >> 노드가 다시 온라인 상태로 회복되면, 컨트롤플레인(마스터)이 Unknown 상태가 지속된 파드를 자동으로 노드에서 삭제한다. Kubectl이 파드가 deletion 표기를 확인하고 파드 종료를 시작한다. 파드 종류 사유는 NodeLost로 표기된다. 노드가 응답이 없어 노드가 손실된 것으로 간주한다.

** 실제로는 파드의 컨테이너는 종료되지 않았다. 컨트롤 플레인의 관점에서의 처리 과정이다.

 

수동으로 파드를 삭제하기

상기 실습이 우선 진행되어야만 책과 같은 결과 확인이 가능하다. minikube에서는 단순한 파드 삭제가 가능하다.

$ kubectl delete po kubia-0
pod "kubia-0" deleted

$ kubectl get po
NAME      READY   STATUS              RESTARTS   AGE
kubia-0   0/1     ContainerCreating   0          0s
kubia-1   1/1     Running             0          29m
kubia-2   1/1     Running             0          30m

책의 내용은 다음과 같다.

  • 책의 환경에서는 수동으로 파드를 삭제해도, 삭제되지 않고 Unknown 상태로만 표기되는데, 삭제되지 않고 파드가 남은 이유는 그 이전 실습에서 컨트롤 플레인이 노드에서 제거하고자 하는 목적으로 이미 deletion 표기를 해두었기 때문이다. 이후 해당 노드의 kubelet이 API 서버에 파드의 컨테이너가 종료됐음을 통지하자마자 제거될 예정이나, 노드 네트워크가 다운된 경우에는 절대 발생하지 않는다.
  • 위와 같은 상태를 막고 바로 API 서버에 파드를 삭제하도록 알리는 방법의 명령어도 있다. kubectl delete po {pod_name} --force --grace-period 0 명령어를 통해 수행 가능하다.

 

추가 사항

  • 스테이트풀셋은 스테이트풀셋의 삭제 시 파드의 종료에 대해 어떠한 보증을 제공하지 않는다. 스테이트풀셋에서는 파드가 순차적이고 정상적으로 종료(graceful termination)되도록 하려면, 삭제 전 스테이트풀셋의 스케일을 0으로 축소할 수 있다.
    https://kubernetes.io/ko/docs/concepts/workloads/controllers/statefulset/