호스트 서버 (Ubuntu 20.04) 에 NFS 를 설정하고 Spark on Kubernetes 에서 동적으로 볼륨을 할당받기
By: Jung-Taek Lim
Tags: Ubuntu, Nfs Server, Spark, Kubernetes

현재 데스크탑에 셋업한 k3s 클러스터는 클라우드 서비스에 종속적이지 않다. 다른 말로 하면, 필요한 인프라는 전부 알아서 셋업해야 된다는 뜻이다.

그 중 하나가 볼륨이다. 개별 워커 노드에 공간을 충분히 할애하고 emptyDir 이나 hostPath 등을 사용할 수도 있겠지만 노드에 미리 공간할애를 해 둬야 된다는 부분이 귀찮고 비효율적이다. 호스트 서버에는 할애한 공간 외에도 몇 백 기가정도의 공간이 따로 남아 있다. 어떻게 하면 좋을까?

조금 찾아보니 NFS 를 설정하고 Kubernetes 에서 PV/PVC 를 통해 해당 NFS 를 가져다 쓰는 게 서비스 비종속적인 방법 중에서는 가장 쉬워 보였다.

역시 디테일 없이 구글링해서 동작하는 걸 확인한 것만 정리했다. 디테일은 아래 문서들에서 찾자.

우선 호스트 서버에 NFS 를 설치해야 한다. Ubuntu 20.04 기준이다.

sudo apt-get install nfs-common nfs-kernel-server rpcbind portmap

systemctl enable nfs-kernel-server

systemctl start nfs-kernel-server

다음으로 디렉토리 하나를 NFS 에 내주자.

sudo mkdir -p /nfs/vol1

sudo chown nobody:nogroup /nfs/vol1

sudo vim /etc/exports /nfs/vol1 10.38.12.0/24(rw,sync,no_subtree_check,no_root_squash)

multipass 가 노드들의 IP 를 10.38.12.x 대역으로 내주고 있어서 그 대역에만 오픈해 주었다.

아래의 명령으로 반영하자.

sudo exportfs -r

아래의 명령으로 확인하자.

showmount -e Export list for jungtaek-ubuntu-desktop:

/nfs/vol1 10.38.12.0/24 sudo exportfs -v /nfs/vol1 10.38.12.0/24(rw,wdelay,no_root_squash,no_subtree_check,sec=sys,rw,secure,no_root_squash,no_all_squash)

k8s 노드들에도 NFS 클라이언트를 설치해줘야 한다. 설치하지 않으면 접근 시 오류가 발생한다.

NODE 에 개별 노드 명을 입력해서 모든 노드에 대해 실행하자.

multipass exec $NODE -- sudo apt-get install -y nfs-common

NFS 설정은 끝났다. 동적으로 볼륨을 할당받을 수 있도록 준비하자.

이 문서에서 dynamic provisioning 을 따라가자. dynamic provisioning 은 요청때마다 새로운 디렉토리를 만들어서 할당해 준다. 미리 할당받아 두는 static provisioning 은 마운트된 볼륨에서 데이터를 읽을 용도가 있거나 지정된 같은 디렉토리에 기록해야 하는 경우가 아니라면 필요성이 덜할 것이다. static provisioning 은 훨씬 쉬우니 문서를 참조하자.

service account 와 필요한 role 들을 정의해 준다.

vim nfs-prov-sa.yaml

kind: ServiceAccount

apiVersion: v1

metadata:

name: nfs-pod-provisioner-sa

---

kind: ClusterRole # Role of kubernetes

apiVersion: rbac.authorization.k8s.io/v1 # auth API

metadata:

name: nfs-provisioner-clusterRole

rules:

- apiGroups: [""] # rules on persistentvolumes

resources: ["persistentvolumes"]

verbs: ["get", "list", "watch", "create", "delete"]

- apiGroups: [""]

resources: ["persistentvolumeclaims"]

verbs: ["get", "list", "watch", "update"]

- apiGroups: ["storage.k8s.io"]

resources: ["storageclasses"]

verbs: ["get", "list", "watch"]

- apiGroups: [""]

resources: ["events"]

verbs: ["create", "update", "patch"]

---

kind: ClusterRoleBinding

apiVersion: rbac.authorization.k8s.io/v1

metadata:

name: nfs-provisioner-rolebinding

subjects:

- kind: ServiceAccount

name: nfs-pod-provisioner-sa

namespace: default

roleRef: # binding cluster role to service account

kind: ClusterRole

name: nfs-provisioner-clusterRole # name defined in clusterRole

apiGroup: rbac.authorization.k8s.io

---

kind: Role

apiVersion: rbac.authorization.k8s.io/v1

metadata:

name: nfs-pod-provisioner-otherRoles

rules:

- apiGroups: [""]

resources: ["endpoints"]

verbs: ["get", "list", "watch", "create", "update", "patch"]

---

kind: RoleBinding

apiVersion: rbac.authorization.k8s.io/v1

metadata:

name: nfs-pod-provisioner-otherRoles

subjects:

- kind: ServiceAccount

name: nfs-pod-provisioner-sa # same as top of the file

# replace with namespace where provisioner is deployed

namespace: default

roleRef:

kind: Role

name: nfs-pod-provisioner-otherRoles

apiGroup: rbac.authorization.k8s.io

kubectl apply -f nfs-prov-sa.yaml

다음으로 storage class 를 만들자.

vim storageclass-nfs.yaml

apiVersion: storage.k8s.io/v1

kind: StorageClass

metadata:

name: nfs-storageclass # IMPORTANT pvc needs to mention this name

provisioner: nfs-dynamic # name can be anything

parameters:

archiveOnDelete: "false"

kubectl apply -f storageclass-nfs.yaml

위에서 provisioner 와 name 을 (적당히 수정하고) 잘 기억해 두자.

이제 pvc 가 요청될 때 매칭되는 pv 를 provisioning 해 줄 provisioner 를 실행하자.

vim pod-provision-nfs.yaml

kind: Deployment

apiVersion: apps/v1

metadata:

name: nfs-pod-provisioner

spec:

selector:

matchLabels:

app: nfs-pod-provisioner

replicas: 1

strategy:

type: Recreate

template:

metadata:

labels:

app: nfs-pod-provisioner

spec:

serviceAccountName: nfs-pod-provisioner-sa # name of service account

containers:

- name: nfs-pod-provisioner

image: quay.io/external_storage/nfs-client-provisioner:latest

volumeMounts:

- name: nfs-provisioner-v

mountPath: /persistentvolumes

env:

- name: PROVISIONER_NAME # do not change

value: nfs-dynamic # SAME AS PROVISIONER NAME VALUE IN STORAGECLASS

- name: NFS_SERVER # do not change

value: 192.168.1.60 # Ip of the NFS SERVER

- name: NFS_PATH # do not change

value: /nfs/vol1 # path to nfs directory setup

volumes:

- name: nfs-provisioner-v # same as volumemouts name

nfs:

server: 192.168.1.60

path: /nfs/vol1

kubectl apply -f pod-provision-nfs.yaml

주석 처리된 부분을 잘 보고, provisioner 이름, NFS server 의 IP, NFS path 를 수정해 준 다음 provisioner 를 띄워 준다.

문서에는 테스트로 pvc 를 만들어서 실제로 볼륨을 할당받아보는 것까지 있는데, 문서 따라가서 테스트해 보았다고 가정하고, Spark 에서 이를 활용해 보자.

(대응되는 기능이 Spark 3.1.0 에 추가되어서 공식 사이트에 문서가 아직 없다. 위의 링크는 branch-3.1 의 md 파일)

약간 가져와 보자면…

For example, you can mount a dynamically-created persistent volume claim per executor by using OnDemand as a claim name and storageClass and sizeLimit options like the following. This is useful in case of Dynamic Allocation spark.kubernetes.executor.volumes.persistentVolumeClaim.data.options.claimName=OnDemand

spark.kubernetes.executor.volumes.persistentVolumeClaim.data.options.storageClass=gp

spark.kubernetes.executor.volumes.persistentVolumeClaim.data.options.sizeLimit=500Gi

spark.kubernetes.executor.volumes.persistentVolumeClaim.data.mount.path=/data

spark.kubernetes.executor.volumes.persistentVolumeClaim.data.mount.readOnly=false

claimName 에 “OnDemand” 라고 쓰면 Spark 가 unique 한 이름으로 대체해 준다. 중요한 부분은 storageClass 와 sizeLimit… 둘 다 명시가 되어야 한다. storageClass 는 아까 NFS 동적 프로비저닝 설정할 때 쓴 storageClass 를 쓰면 된다. mount.path 는 container 에 mount 될 경로를 지정한다. readOnly 는 그 자체가 설명이니…

개별 executor 에 적용되며, “executor” 를 “driver” 로 교체하여 driver 에서도 볼륨을 받을 수 있다. “data” 자리에 볼륨 이름이 들어가는데 볼륨이 설정 단위이기 때문에 여러 볼륨을 할당받아야 한다면 볼륨 이름을 다르게 해서 여러 개를 설정할 수 있을 것으로 보인다. (하나의 큰 볼륨을 받을 수 있다면 하나만 받아서 디렉토리로 구분해서 써도 문제가 없겠지만…)

문서에 바로 이어서 설명되어 있지만, local dir 도 볼륨을 받아서 쓸 수 있다. 볼륨 이름이 “spark-local-dir-” 로 시작하면 된다. 마운트된 경로들이 SPARK_LOCAL_DIRS 에 설정된다고 한다. 설정 방법은 볼륨 이름 부분 외에는 전부 같다. driver/executor 모두 설정 가능하다는 부분도 같다.

예를 들어, 이전 문서에서 magic committer 를 사용하는 batch query 실행 예시를 아래와 같이 변경하여 동적으로 프로비저닝된 볼륨을 활용할 수 있다.

위에서 spark.kubernetes.driver.volumes.persistentVolumeClaim 와 spark.kubernetes.executor.volumes.persistentVolumeClaim 으로 시작하는 설정들을 눈여겨 보고, 마운트된 디렉토리를 어느 설정에서 활용하는지 확인해 보자.

여러 Spark 어플리케이션에서 기록하는 event log 들을 SHS 로 서빙하는 건 테스트가 좀 필요할 것 같다. static provisioning 을 통해 하나의 PVC 를 만들고 같은 NFS 디렉토리에 기록하도록 하는 방식이 쉬워 보이긴 하는데 하나의 PVC 를 동시에 접근하고 기록하고 돌려써야 되는데 테스트가 필요해 보인다. TODO 로 남겨 두자. dynamic provisioning 도 claimName 을 같게 한다면 (OnDemand 제외) 같은 NFS 디렉토리에 기록이 될 테니 비슷하게 할 수 있겠다. 다만 처음 요청 때 디렉토리가 동적으로 생성될 거라서 그걸 신경써 줘야 한다.