[Docker Mastery] - 14

쿠버네티스 Volume

시작

볼륨

minikube는 호스트내 가상머신입니다. 컨테이너를 배포할때 클러스터 혹은 호스트에서 도커로 작업할 때, 컨테이너가 종료되거나 컨텍스트에서 호스팅하는 Pod가 제거되면 데이터가 유실됐죠. 이제 데이터를 유실하지 않고 관리하는 방법에 대해 알아보겠습니다.

볼륨의 종류로는 일반 볼륨, 영구 볼륨, 영구 볼륨 클레임이 존재합니다. 순차적으로 알아봅시다.

State 이해하기

사용자 생성 데이터, 사용자 계정, 앱에 관한 데이터 등 일반적으로 DB에 저장하지만 예로 파일로도 저장할 수 있죠. 임시 DB테이블인 메모리에 저장할 수도 있구요. 컨테이너가 중지되더라도 이러한 데이터들은 유실되면 안되겠죠.

이제 파드내 컨테이너에 볼륨을 구성하는 방법을 알아봅시다.

쿠버네티스는 마운트 볼륨을 설정하는 것을 지원합니다. 로컬 볼륨(워커노드) 말고도 또한 다른 클라우드 및 호스팅 프로바이더에서도 사용할 수 있어 다양한 유형의 볼륨을 이용할 수 있습니다.

볼륨은 파드마다 존재합니다. 생명주기도 파드를 따라갑니다. 컨테이너를 중지하면 볼륨은 살아있지만 Pod가 파괴되면 볼륨 또한 파괴됩니다. Pod또한 일종의 컴퓨터라고 보면 되겠군요. Pod를 제거한 후에도 볼륨을 유지하는 방법이 존재합니다. 이에 대해서는 차후 알아보겠습니다.

쿠버네티스 볼륨은 도커보다 조금 더 강력합니다. 다양한 드라이버와 유형을 지원하며 데이터가 저장되는 위치를 완벽히 제어할 수 있죠.

볼륨은 반드시 영구적인 것은 아닙니다. 쿠버네티스에서 컨테이너는 Pod의 재시작에도 살아있지만 볼륨은 제거됩니다. 반면 도커는 제거하지 않는 한 볼륨이 살아있죠.

시작하기

우선 다음과 같은 Node 서버를 실행합니다. API는 다음과 같습니다.

js

const filePath = path.join(__dirname, 'story', 'text.txt');

app.get('/story', (req, res) => {
  fs.readFile(filePath, (err, data) => {
    if (err) {
      return res.status(500).json({ message: 'Failed to open file.' });
    }
    res.status(200).json({ story: data.toString() });
  });
});

app.post('/story', (req, res) => {
  const newText = req.body.text;
  if (newText.trim().length === 0) {
    return res.status(422).json({ message: 'Text must not be empty!' });
  }
  fs.appendFile(filePath, newText + '\n', (err) => {
    if (err) {
      return res.status(500).json({ message: 'Storing the text failed.' });
    }
    res.status(201).json({ message: 'Text was stored!' });
  });
});

이미지 빌드를 위해 Dockerfile을 작성합니다.

yaml
FROM node:14-alpine

WORKDIR /app

COPY package.json .

RUN npm install

COPY . .

EXPOSE 3000

CMD [ "node", "app.js" ]

deployments yaml 입니다.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: story-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: story
  template:
    # kind: Pod
    metadata:
      labels:
        app: story
    spec:
      containers:
      - name: story
        image: 생성한 이미지

이어서 service yaml 입니다.

yaml
apiVersion: v1
kind: Service
metadata:
  name: story-service
spec:
  selector:
    # matchLabels: 생략
    app: story
  type: LoadBalancer
  ports:
  - protocol: "TCP"
    # 외부 노출 포트
    port: 80
    targetPort: 3000

서비스 및 배포를 실행합니다.

bash
kubectl apply -f=service.yaml -f=deployment.yaml

> kubectl get deployments
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
story-deployment   1/1     1            1           3d2h
> kubectl get service
NAME            TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
kubernetes      ClusterIP      10.96.0.1       <none>        443/TCP        9d
story-service   LoadBalancer   10.107.85.248   <pending>     80:31774/TCP   3d2h
> minikube service story-service
|-----------|---------------|-------------|---------------------------|
| NAMESPACE |     NAME      | TARGET PORT |            URL            |
|-----------|---------------|-------------|---------------------------|
| default   | story-service |          80 | http://192.168.49.2:31774 |
|-----------|---------------|-------------|---------------------------|
🏃  story-service 서비스의 터널을 시작하는 중
|-----------|---------------|-------------|------------------------|
| NAMESPACE |     NAME      | TARGET PORT |          URL           |
|-----------|---------------|-------------|------------------------|
| default   | story-service |             | http://127.0.0.1:50204 |
|-----------|---------------|-------------|------------------------|
🎉  Opening service default/story-service in default browser...
❗  darwin 에서 Docker 드라이버를 사용하고 있기 때문에, 터미널을 열어야 실행할 수 있습니다

해당 서비스에서는 GET 요청으로 텍스트의 저장 텍스트를 불러오고 POST요청으로는 텍스트를 저장합니다. 이 상황에서 파드가 재시작된다면 데이터는 사라질겁니다.

데이터를 제거하지 않도록 볼륨 설정을 시작해봅시다.

쿠버네티스는 다양한 볼륨 타입과 드라이버를 제공합니다. 지금은 minikube 를 이용해 로컬에서 가상머신으로 노드를 구성하고 있습니다.

특정 클라우드 프로바이더 또는 데이터 센터에 서비스를 배포하려면 공식 DOCS를 참고하면 됩니다. 다양한 볼륨 및 타입별 YAML 작성법이 나와있으니 참고하시길 바랍니다.

우선 CSI 유형에 중점을 두고 진행합니다. emptyDir > hostPath 순으로 진행하겠습니다.

emptyDir 유형으로 볼륨 구성하기

우선 데이터가 보존이 안되는 문제에 대해 들여다봅시다.

다시 이전 이미지를 빌드하기전으로 돌아가 코드를 수정합니다.

js
app.get('/error', () => {
  process.exit(1)
})

error API를 호출하면 서버가 종료되도록 해봅시다. 이상 징후가 발견되면 컨트롤 플레인에서 파드를 재실행할겁니다.

deployment yaml에서 이미지를 변경한 뒤 설정 파일을 적용합니다.

bash
kubectl apply -f=deployment.yaml

# 파드가 정상 실행되면 이전 파드는 제거됨.
kubectl get pods
NAME                                READY   STATUS        RESTARTS      AGE
story-deployment-7db5f4c576-f9z8c   1/1     Running       0             32s
story-deployment-cbf5f7f47-fg72b    1/1     Terminating   1 (40m ago)   3d3h

minikube service story-service

이제 아래와 같이 파드가 실행됐을때, 요청을 보내봅시다.

bash
> kubectl get pods
NAME                                READY   STATUS    RESTARTS     AGE
story-deployment-7db5f4c576-f9z8c   1/1     Running   1 (7s ago)   2m18s

파드가 정상 실행중입니다. Postman을 이용해 Post 요청을 보냅니다.

volume-test-post

텍스트를 조회한 결과입니다.

volume-test-get

서비스가 종료되도록 에러를 발생시킵니다.

volume-error-get

컨테이너에 이상이 감지되어 파드를 재시작합니다.

bash
> kubectl get pods
NAME                                READY   STATUS   RESTARTS       AGE
story-deployment-7db5f4c576-f9z8c   0/1     Error    1 (108s ago)   3m59s
> kubectl get pods
NAME                                READY   STATUS             RESTARTS      AGE
story-deployment-7db5f4c576-f9z8c   0/1     CrashLoopBackOff   1 (14s ago)   4m6s
> kubectl get pods
NAME                                READY   STATUS    RESTARTS      AGE
story-deployment-7db5f4c576-f9z8c   1/1     Running   2 (16s ago)   4m8s

다시 GET 요청을 보내지만 이전 데이터는 제거됩니다.

retry-volume-test-get

이제 서비스가 종료되어도 데이터가 보존되도록 설정을 변경해보겠습니다.

볼륨의 수명은 파드와 직결됩니다. 파드별로 다르구요. 때문에 Pod를 정의하는 위치에 볼륨을 정의해야 합니다.

우선 emptyDir 유형의 설정입니다.

emptyDir은 컨테이너가 종료되어도 데이터를 보존합니다. 단 파드가 재생성되면 디렉토리도 다시 생성됩니다.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: story-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: story
  template:
    # kind: Pod
    metadata:
      labels:
        app: story
    spec:
      containers:
      - name: story
        image: wooglim/kub-data-demo:1
        volumeMounts:
        # 마운트될 컨테이너 내부 경로
        # 아래 emptyDir 유형의 볼륨을 사용하게 됨
        - mountPath: /app/story
          # 볼륨 중 사용할 볼륨 name을 명시
          name: story-volume
      # 다중 볼륨을 설정할 수도 있다. 컨테이너별로 다른 볼륨을 마운트해 이용할 수 있음.
      volumes:
      - name: story-volume
        # {} : 특정 구성은 없으나 기본 설정 및 유형을 그대로 사용
        # Pod가 시작될 때마다 단순히 새로운 디렉토리 생성. 디렉토리를 활성 상태로 유지해 데이터를 채운다.
        # 컨테이너는 이 디렉토리를 이용. 컨테이너가 제거되도 데이터는 유지 단, 파드가 제거 되는 경우 이 디렉토리는 제거됨
        # 파드가 재생성되면 비어 있는 새 디렉토리로 생성
        emptyDir: {}

이제 error를 발생시켜 컨테이너가 재시작 되더라도 데이터는 잔존해있는 결과를 볼 수 있습니다.

hostPath 유형으로 볼륨 구성하기

레플리카가 2개 이상인 경우에는 이전 emptyDir 설정이 어떻게 작동할까요?

deployemnt yaml 파일에서 레플리카 갯수를 늘려봅시다.

yaml
# 동일 파드 / 컨테이너 2쌍 생성
spec:
  replicas: 2

설정을 다시 적용해봅시다.

bash
> kubectl apply -f=deployment.yaml
deployment.apps/story-deployment configured

> kubectl get pods
NAME                                READY   STATUS    RESTARTS      AGE
story-deployment-6bcf4c6c98-6vxdm   1/1     Running   1 (91s ago)   3m3s
story-deployment-6bcf4c6c98-xjqv5   1/1     Running   0             3s

요청을 다시 보내봅시다. 여전히 데이터는 보존되고 있습니다.

volume-test-get

서비스가 종료되도록 에러를 발생시킵니다.

volume-error-get

이제 다시 요청을 보내봅시다.

two-replica-get

데이터를 가져오는데 실패했다고 표시됩니다.

이유는 트래픽이 다른 Pod로 리디렉션 되었기 때문입니다. 첫 번째 파드에서 오류가 발생했으니 다른 파드에서 요청을 받게 되겠죠? (LoadBalancer Service)

볼륨은 파드와 직결되는데, 만일 다른 Pod로 전송된다면 데이터는 손실됩니다. 이를 해결하려면 Pod 당 새 빈 디렉토리를 생성하는 것으로 전환해야 합니다.

호스트 머신인 노드에서 Pod를 실행하는 실제 머신에서 경로를 설정할 수 있으므로 경로의 데이터가 각각 Pod에 노출됩니다.

여러 Pod가 특정 경로 대신 호스트 머신의 동일 경로 하나를 공유할 수 있습니다. 동일 Pod에서 모든 요청을 처리하는 경우에 유용할겁니다.

yaml을 수정합니다.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: story-deployment
spec:
  replicas: 2
  selector:
    matchLabels:
      app: story
  template:
    # kind: Pod
    metadata:
      labels:
        app: story
    spec:
      containers:
      - name: story
        image: wooglim/kub-data-demo:1
        volumeMounts:
        # 아래 호스트 경로의 데이터를 공유받게 됨
        - mountPath: /app/story
          # 볼륨 중 사용할 볼륨 name을 명시
          name: story-volume
      volumes:
      - name: story-volume
        hostPath:
          # 호스트 경로. 바인드 마운트와 유사함
          path: /data
          # 위 경로의 폴더가 존재하지 않는다면 생성되어야 함을 의미
          # Directory: 폴더가 존재하지 않는 다면 Fail
          type: DirectoryOrCreate

이제 두 파드 모두 호스트 경로내 데이터를 공유받으므로 한쪽에서 에러가 발생해 다른 Pod로 요청이 발생해도 정상적으로 데이터를 전달할 겁니다.

동일 노드 Pod에서만 이 데이터에 접근할 수 있습니다.

CSI 유형 볼륨 이해하기

CSI(Container Storage Interface)는 Amazon Elastic File System, Azure, NFS 등의 클라우드 볼륨을 연동하기에 최적화된 유형입니다.

매우 유연하며 AWS EFC CSI 드라이버와 같이 여러 드라이버가 지원되는 한 전 세계의 모든 스토리지 솔루션을 연결해 사용할 수 있습니다.

차후 AWS를 이용해 서비스를 배포하는 경우 이 유형에 대해 알아보도록 하겠습니다.

볼륨에서 영구 볼륨으로

emptyDir, hostPath 등의 볼륨은 파드가 종료되어 새로운 파드로 교체될 때 Pod가 제거되면 볼륨도 제거됩니다.

minikube는 하나의 워커 노드만 존재하기 때문에 문제를 체감할 수 없었지만, 여러 노드가 존재하는 환경에서는 hostPath도 별로 도움이 되지 않습니다.

임시 데이터의 경우에는 제거되도 무관하겠지만 중요 보관데이터도 있겠죠. 이러한 데이터들을 보존하기위해 k8s는 Persistent Volumes를 지원합니다.

영구 볼륨은 독립적입니다. 파드와 노드에 영향을 받지 않습니다. 이전 type of Volumes을 보면 이미 독립적인 볼륨을 제공하는 서비스가 존재했습니다.

AWS 블록 스토어, AzureDisk, NFS 등이 그렇습니다.

데이터는 노드나 파드가 아니라 해당 서버에 저장이 되겠죠. 아무 영향이 없게됩니다.

여러 파드마다 일반 볼륨을 구성하면 관리자도 골머리를 앓게 되겠지만 영구 볼륨을 사용 하면 클러스터에 새 리소스, 엔티티를 가져 노드와 파드로 부터 완전히 분리됩니다. 단 hostPath는 제외입니다.

각 노드는 이 PV Claim이라는 일종의 파드를 실행하고 영구 볼륨에 도달해 접근할 수 있게됩니다. 동일 노드내 다른 파드에서 실행 중인 컨테이너가 이 볼륨에 접근할 수 있도록 돕습니다.

클러스터내 여러 Persistent Volume을 포함해 claim으로 선택도 가능합니다.

자세한 유형과 유형별 엑세스 모드 및 설정 가능한 옵션은 persistent-volumes를 참고합니다.