docker storage 개념은 두 나뉘어져 있는데, 하나는 storage drivers이고 하나는 volume drivers이다.
우리는 먼저 storage drivers에 대해서 알아보고, volume drievers를 알아보도록 하자.
docker가 설치되면 /var/lib/docker
에 여러 directory들을 만든다.
/var/lib/docker
/aufs
/containers/
/image
/volumes
container와 관련된 data들을 containers
에 들어가고, container의 volume에 관한 정보는 volumes
에 들어간다.
docker는 layered architecture를 가지는데, 크게 image
layer와 container
layer로 이루어져 있다.
가령 이미지만 있는 경우는 image layer를 사용하는데 다음과 같다.
---------image layer---------
| -------- |
| |app.py| |
| -------- |
-----------------------------
다음과 같이 image layer안에는 app.py
파일이 존재하는데, 이 파일은 image layer에 있기 때문에 read만 가능하지, write는 불가능하다. 따라서, 수정이 안된다.
다음은 container layer이다.
---------image layer---------
| -------- |
| |app.py| |
| -------- |
-----------------------------
|
|
-------container layer-------
| -------- |
| |app.py| |
| -------- |
-----------------------------
container layer는 image layer에 있는 app.py
를 그대로 가져온다. 재밌는 것은 app.py
를 수정할 수 있는데, 이는 사실 image layer에 있는 app.py
를 copy에서 container layer에 제공했기 때문에 가능한 것이다. 따라서, 수정은 했지만 image layer에는 반영되지 않으며 container가 사라지면 수정한 app.py
도 사라진다.
이렇게 image layer안에 있는 파일을 container layer에 복사하여 제공하는 방식을 COPY-ON-WRITE 방식이라고 한다.
그럼 누군가가 layered architecture를 유지하고, write가 가능한 layer를 만들고, layer간에 파일을 이동시키는 등을 가능하게해주는 것일까?? 바로 storage drivers이다.
docker는 storage drivers를 사용하여 layered architecture를 가능하게 한 것이다.
이 storage drivers는 os에 따라서 다양하게 사용되는데, 대표적으로 다음과 같은 것들이 있다.
즉, 이러한 storage driver들이 image와 container가 host의 disk에서 어떻게 관리되고, 저장되는 지에 대해서 담당하는 기능인 것이다.
그렇다면, container layer에 어떻게 데이터를 보존하게 만들 수 있을까?? 이러한 방법을 제시해주는 것이 바로 volumes이다.
docker를 통해서 먼저 volume을 만들면 /var/lib/docker/volumes
에 volume directory가 하나 생기게된다. 가령 data_volume
directory를 하나 만들어보도록 하자.
docker volume create data_volume
위의 명령어로 data_volume
을 만들면 다음과 같이 directory를 만들게 된다.
/var/lib/docker
/volumes
/data_volume
이 data_volume은 host에 있는 것이기 때문에 내부에 파일을 저장해도 계속 저장된다.
다음으로 docker container에 volume을 연결해놓아보자.
docker run -v data_volume:/var/lib/mysql mysql
이제 mysql
container에 data_volume이 container의 /var/lib/mysql
에 연결되었다. 따라서, container가 /var/lib/mysql
에 데이터를 쓰면 이는 data_volume volume과 연결되어, host에 저장된다. 따라서, 계속해서 남아 있게 되는 것이다.
재밌는 것은 이렇게 volume을 따로 만들지 않고도, host의 특정 directory에 바인딩하여 사용할 수도 있다. 이를 bind mounting이라고 한다.
docker run -v /data/mysql:/var/lib/mysql mysql
host의 /data/mysql
directory를 container의 /var/lib/mysql
에 연결하여, container가 /var/lib/mysql
에 file을 생성, 수정, 삭제하면 host의 /data/mysql
에 반영이 되어, container가 사라져도 계속해서 data가 남는 것이다.
한 가지 재밌는 것은 이제는 더이상 -v
옵션으로 volume 바인딩을 하지 않고, 다음과 같이 --mount
옵션을 사용한다.
docker run --mount type=bind,source=/data/mysql,target=/var/lib/mysql mysql
이렇게 volume을 담당하여 container와 volume을 연결해주는 것이 바로, volume drrvers이다. volume dirvers들도 여러 가지 종류가 있는데, 위와 같이 local host와 연결하거나, aws의 경우 EBS와 연결하여 사용한다.
docker run -it \
--name mysql \
--volume-driver rexray/ebs \
--mount src=ebs-vol,target=/var/lib/mysql mysql
다음은 aws의 ebs volume driver를 사용하여 mysql container와 ebs의 ebs-vol
과 container의 /var/lib/mysql
을 연결한 것이다.
kubernetes에서도 데이터를 저장하기 위해서는 이러한 volume driver를 필요로 하는데, 이를 통해서 kubernetes pod내부의 container에게 persistent data를 제공해줄 수 있기 때문이다.
kubernetes의 container runtime에 대한 인터페이스가 정의되어 있기 때문에, 해당 인터페이스인 CRI를 구현하는 CRI-O
, rkt
, containerd
같은 container runtime들이 존재하게 되었다.
이와 마찬가지로 kubernetes에서는 volume에 대한 하나의 인터페이스를 만들었는데,이를 Container Storage Interface라고 하며 CSI라고 한다.
CSI는 kubernetes상에서 pod에 대해서 volume을 제공할 때 사용되는 하나의 인터페이스로, 인터페이스를 구현한 구현체의 API를 호출하여 사용한다. 가령 CSI는 다음을 만족해야한다.
------------RPC------------
| CreateVolume |
| DeleteVolume |
| ControllerPublishVolume |
---------------------------
즉, kubernetes에서 CSI를 지키는 구현체에서 RPC로 특정 노드에 대해서 volume을 생성, 삭제, 관리에 대한 API들을 호출 시키는 것이다.
CSI를 만족하는 것들로 EBS, EMC, glusterFS 등이 있다. 참고로 CSI는 kubernetes 상에서만 사용하는 인터페이스가 아니라, container orchestration tool이라면 모두 CSI를 사용한다.
kubernetes의 pod 역시도 내부에 container로 동작하기 때문에 pod안의 file들은 pod가 사라지고 난 뒤에는 유지되지 않는다. 만약 유지시키고 싶다면 volume
을 사용해야한다.
다음은 kubernetes pod에 volume
을 지정하고 pod에 mount하는 과정을 보여준다. 단, volume은 여러 종류가 있을 수 있는데, 여기에서는 hostPath
를 사용하였다.
apiVersion: v1
kind: Pod
metadata:
name: random-number-generator
spec:
containers:
- name: alpine
image: alpine
command: ["/bin/sh", "-c"]
args: ["shuf -i 0-100 -n 1 >> /opt/number.out;"]
volumeMounts:
- name: data-volume
mountPath: /opt
volumes:
- name: data-volume
hostPath:
path: /data
type: Directory
위의 code는 0~100사이의 랜덤한 숫자를 뽑은 다음 /opt/number.out
에 저장하는 pod이다.
volumes
를 먼저 보면 random-number-generator
pod에 연결된 volume이 무엇인지 볼 수 있다. 현재 data-volume
volume이 연결되어있고, hostPath
로 연결되어 있기 때문에 해당 pod가 배포되는 node의 /data
volume과 연동되는 것을 알 수 있다.
pod에는 node의 /data
를 data-volume
으로 연결했지만, 아직 container와는 연결하지 않았다. container와 연결하기 위해서 volumeMounts
를 사용하였고 /opt
path로 data-volume
을 연결한 것을 알 수 있다.
----------------Node1---------------------
| -------random-number-generator-------- |
| | ---alpine--- | |
| | |/opt | | |
| | ------------ | |
| | | | |
| | ---data-volume--- | |
| | | | | |
| | ----------------- | |
|-------------|------------------------- |
| | |
| ------/data------ |
| | | |
| ----------------- |
------------------------------------------
다음 그림과 같이 연결된 것이다. 이제 data가 alpine
container가 /opt/number.out
파일을 만들면 random-number-generator pod의 data-volume
volume에 연동되고, pod가 배포된 node의 /data
에 연동된다.
-----------------Node1----------------------
| -------random-number-generator---------| |
| | ---alpine--------- | |
| | |/opt/number.out;| | |
| | ------------------ | |
| | | | |
| | ----data-volume--- | |
| | |/opt/number.out;| | |
| | ------------------ | |
|-------------|--------------------------- |
| | |
| ------/data------- |
| |/opt/number.out;| |
| ------------------ |
--------------------------------------------
따라서 pod에서 쓴 data가 pod가 다운되어도 유지되는 것이다.
이는 hostPath
를 사용하여 pod가 설치된 node에 volume을 만들어 연결하도록 한 것이다.
그런데 사실 hostPath
는 실제 배포 환경에서는 쓰지 않는 것이 좋다. 왜냐하면 node들이 여러 개 일때, 이들끼리의 data를 공유하는 환경이 필요한데, hostPath
로는 불가능하기 때문이다.
가령 Node2
에 배포된 pod는 Node1
에 배포된 pod가 만든 number.out
파일을 볼 수가 없다는 것이다.
이러한 문제를 해결하는 것이 다른 volume type을 사용하는 것인데, 대표적으로 file server를 제공하는 nfs, cephFS, amazon EBS 등이 있다. 다음은 amazon EBS에 대한 volume 설정을 보여준다.
volumes:
- name: data-volume
awsElasticBlockStore:
volumeID: <volume-id>
fsType: ext4
그런데, 각 pod마다 이렇게 volume을 설정주는 일은 쉽지 않다. 거기다, pod의 volume을 내가 직접 설정하지 않는 pod들도 있을 수 있다. 가령, 특정 pod에서 다른 pod를 생성하는 경우, 해당 pod의 생성 code나 스크립트를 수정하여 volume을 설정하도록 하는 수 밖에 없다.
이렇게, pod와 volume에 대한 의존성을 분리하고자하는 요구사항들이 넘치게되고, persistent volume인 PV가 등장하게 된다. PV는 하나의 저장소로 위에서 본 hostPath
나 amazon EBS, NFS와 같은 volume 공간을 말한다.
이렇게 미리 앞으로 사용될 volume 공간을 잡아두고, pod들은 PVC라는 것을 요구하도록 하는데, PVC는 persistent volume claim으로 내가 얼만큼 어떤 종류의 storage class를 가진 PV를 사용할 지 요구사항을 내는 것이라고 생각하면 된다.
가령 pod1, pod2가 있다면 pod1은 nfs PV에 대해서 10G를 요청하는 PVC를 만들어내고 pod2는 amazon EBS에 2G 용량을 요청하는 PVC를 만들어낼 수 있다.
PV에 대한 manifest는 다음과 같다.
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-voll
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 1Gi
awsElasticBlockStore:
volumeID: <volume-id>
fsType: ext4
참고로 PV는 namespace의 영역이 아니다.
awsElasticBlockStore
해당 부분이 volume의 type으로 hostPath
로도 사용할 수 있다. capacity
는 1Gi
만큼 할당받겠다는 것이다.
이제 PVC에 대해서 알아보도록 하자. PVC는 persistent volume claim으로 pod와 pv를 연결해놓는 매개체라고 생각하면 된다. 즉, pod에서 내가 어떤 storage를 얼마나 쓰겠다고 요구서를 제출하는 것과 같다.
PVC는 PV와 1대1로 연결되기 때문에, 한 번 연결된 PV는 다른 PVC가 와도 연결되지 않음을 주의하도록 하자.
그런데, 어떤 기준으로 PVC와 PV가 연결되는 것인가?? 여기에는 몇 가지 기준들이 있다.
가령 PV가 name: my-pv
라는 label을 가졌으면, PVC의 경우 아래와 같이 selector
로 matching을 시켜주면 된다.
selector:
matchLabels:
name: my-pv
만약 PVC에 연결될 PV가 없다면 PVC는 Pending
에 빠지게 된다.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: myclaim
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 500Mi
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-voll
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 1Gi
awsElasticBlockStore:
volumeID: <volume-id>
fsType: ext4
위ㅇ와 같이 서로 PVC와 PV가 accessMode
가 동일한 경우 연결된다. 여기서는 1Gi
를 가진 PV에 500Mi
를 쓰겠다고 요청한 것이다.
PVC가 PV에 연결이 된 이후부터는 해당 PV를 사용할 수 없으므로, 다른 PVC들은 다른 PV에 연결되도록 해야한다. 이를 위해서 자동으로 PV를 생성해주는 provisioner들도 있다.
참고로 PVC는 namespaced resource이기 때문에 namespace에 영향을 받는다.
그런데, 만약 PVC가 삭제된다면 어떻게 될까?? 이는 PV의 persistentVolumeReclaimPolicy
option을 따르는데, Retain
, Delete
이 있다. Recycle
이라는 것도 있었지만, 이제 사용하지 않는다.
Retain
: PVC가 삭제되어도 연결된 PV는 남아있어 Released
상태가 된다. 그러나 내부에 있는 data가 아직 있기 때문에, 다른 PVC가 연결되지 못한다. 따라서, 관리자가 수동으로 PV를 삭제하고 내부 데이터를 다 지워준다음, 다시 PV를 만들어야 한다.
Delete
: PVC가 삭제되면 PV도 함께 사라진다. 대부분의 동적 프로비저너는 Delete가 기본값이다.
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-voll
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 1Gi
persistentVolumeReclaimPolicy: Retain
awsElasticBlockStore:
volumeID: <volume-id>
fsType: ext4
persistentVolumeReclaimPolicy
가 Retain
으로 되어있기 때문에 PVC가 삭제되어도 PV가 남아있을 것이다.
그럼 PVC를 어떻게 pod에 연결할까?? 이는 volumes
의 persistentVolueClaim 속성을 이용하면 된다.
apiVersion: v1
kind: Pod
metadata:
name: mypod
spec:
containers:
- name: myfrontend
image: nginx
volumeMounts:
- mountPath: "/var/www/html"
name: mypd
volumes:
- name: mypd
persistentVolumeClaim:
claimName: myclaim
위의 예제는 volumes에 persistentVolumeClaim으로 myclaim
을 사용하여 연결한 것이다. 이렇게 pod의 persistentVolumeClaim
에 PVC이름을 써주면 된다.
재밌는 사실은 pod와 pvc가 연결되면 pvc를 지워도 삭제되지 않고 계속 terminating 상태가 된다.
NAMESPACE NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
default claim-log-1 Terminating pv-log 100Mi RWX <unset> 4m20s
다음과 같이 terminating 상태가 되는 것이다. 이 경우는 연동된 pod를 삭제해야 PVC가 삭제된다.
PVC를 만들기 전에 PV를 먼저 만들어야 하는데, 이를 static provisioning이라고 한다.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: myclaim
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 500Mi
다음의 PVC는 PV dsik를 요구하는데, 이를 위해서는 먼저 GCP의 disk volume을 먼저 만들어주어야 한다는 것이다.
gcloud beta compute disks create --size 1GB --region us-east1 pd-disk
이것이 바로 static provisioning으로, application이 생기기 전마다 매번 volume을 생성해야하는 수고스러움이 있다. 그리고 실제로 이는 거의 불가능에 가깝다.
그래서 사용하는 것이 바로 dynamic provisioning으로 StorageClass라는 kind를 통해서 자동으로 어떤 PV를 사용해서 만들 것이고, 그 PV는 어떤 provisioner를 통해서 동적으로 만들어지는지, 명시해놓는 것이다. 이 StorageClass를 하나 만들어놓고, PVC에 배정하면 PVC가 생성될 때 해당 StorageClass에 명시된 기록대로 PV를 만들어내는 것이다.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: goole-storage
provisioner: kubernetes.io/gce-pd
다음과 같이 StorageClass를 먼저 만들어놓고, PVC에 연결해주도록 하자.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: myclaim
spec:
accessModes:
- ReadWriteOnce
storageClassName: goole-storage
resources:
requests:
storage: 500Mi
이제 PVC가 StorageClass를 알게되고, 어떤 PV를 자동으로 만들어낼 지 알게된다. 따라서, provisioner라는 component가 PVC에 명시된 StorageClass에 따라서 PV를 동적으로 만들어내는 것이다.
위는 gce-pd라는 provisioner를 사용했지만, StorageClass에는 다양한 provisioner가 존재한다. NFS, cephFS, AzureFile 등등이 있는데, 각각의 provisioner는 추가적인 parameter를 넘길 수 있는다. 이 parameter는 개별 provitioner에 따라 입력 값이 다르니, 각각에 다르게 설정해주도록 하자.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: goole-storage
provisioner: kubernetes.io/gce-pd
parameters:
type: pd-standard
replication-type: none
다음은 GCE에서 persistent disk의 class를 type으로 넘기는 것이다.
마지막으로 storageclass에서는 volumeBindingMode
가 있는데, default로 Immediate
이다. Immediate
이면 PVC를 사용하는 pod가 없어도 PV와 바로 연결되는 것이다.
만약 WaitForFirstConsumer
로 두면 PVC와 PV가 연결되는 조건이 만족하더라도 PVC를 사용하는 pod가 없다면 PVC와 PV를 연결하지 않는다. 따라서 PVC는 pending상태에 빠지게 된다.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: delayed-volume-sc
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
다음과 같이 volumeBindingMode
을 WaitForFirstConsumer
로 설정하고 PVC를 사용하는 pod가 없다면, 바로 PVC와 PV를 연결하지 않는 것에 주의하도록 하자.