호스트 서버 (Ubuntu 20.04) 에 NFS 를 설정하고 Spark on Kubernetes 에서 동적으로 볼륨을 할당받기

Jung-taek Lim
21 min readDec 23, 2020

--

현재 데스크탑에 셋업한 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 -eExport 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 실행 예시를 아래와 같이 변경하여 동적으로 프로비저닝된 볼륨을 활용할 수 있다.

./bin/spark-submit \
--master k8s://https://10.38.12.80:6443 \
--deploy-mode cluster \
--conf spark.executor.instances=2 \
--conf spark.kubernetes.container.image=10.38.12.80:5000/spark-py:spark-3.1.0-SNAPSHOT-bin-hadoop3.2-with-hadoop-cloud-20201223-v1 \
--conf spark.kubernetes.driver.volumes.persistentVolumeClaim.stagingvol.options.claimName=OnDemand \
--conf spark.kubernetes.driver.volumes.persistentVolumeClaim.stagingvol.options.storageClass=nfs-storageclass \
--conf spark.kubernetes.driver.volumes.persistentVolumeClaim.stagingvol.options.sizeLimit=1Gi \
--conf spark.kubernetes.driver.volumes.persistentVolumeClaim.stagingvol.mount.path=/home/spark/work-dir \
--conf spark.kubernetes.driver.volumes.persistentVolumeClaim.stagingvol.mount.readOnly=false \
--conf spark.kubernetes.driver.volumes.persistentVolumeClaim.spark-local-dir-1.options.claimName=OnDemand \
--conf spark.kubernetes.driver.volumes.persistentVolumeClaim.spark-local-dir-1.options.storageClass=nfs-storageclass \
--conf spark.kubernetes.driver.volumes.persistentVolumeClaim.spark-local-dir-1.options.sizeLimit=1Gi \
--conf spark.kubernetes.driver.volumes.persistentVolumeClaim.spark-local-dir-1.mount.path=/home/spark/local-dir \
--conf spark.kubernetes.driver.volumes.persistentVolumeClaim.spark-local-dir-1.mount.readOnly=false \
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.stagingvol.options.claimName=OnDemand \
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.stagingvol.options.storageClass=nfs-storageclass \
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.stagingvol.options.sizeLimit=1Gi \
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.stagingvol.mount.path=/home/spark/work-dir \
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.stagingvol.mount.readOnly=false \
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-local-dir-1.options.claimName=OnDemand \
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-local-dir-1.options.storageClass=nfs-storageclass \
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-local-dir-1.options.sizeLimit=1Gi \
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-local-dir-1.mount.path=/home/spark/local-dir \
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-local-dir-1.mount.readOnly=false \
--conf spark.kubernetes.pyspark.pythonVersion=3 \
--conf spark.hadoop.mapreduce.outputcommitter.factory.scheme.s3a=org.apache.hadoop.fs.s3a.commit.S3ACommitterFactory \
--conf spark.hadoop.fs.s3a.committer.name=magic \
--conf spark.hadoop.fs.s3a.committer.magic.enabled=true \
--conf spark.hadoop.fs.s3a.buffer.dir=/home/spark/work-dir/s3a-buffer \
--conf spark.kubernetes.authenticate.driver.serviceAccountName=spark \
--conf spark.kubernetes.file.upload.path=s3a://spark-upload \
--conf spark.hadoop.fs.s3a.access.key="heartsavior" \
--conf spark.hadoop.fs.s3a.impl=org.apache.hadoop.fs.s3a.S3AFileSystem \
--conf spark.hadoop.fs.s3a.fast.upload=true \
--conf spark.hadoop.fs.s3a.secret.key="heartsavior" \
--conf spark.hadoop.fs.s3a.path.style.access=true \
--conf spark.hadoop.fs.s3a.endpoint=http://10.38.12.80:9000 \
--conf spark.hadoop.fs.s3a.connection.ssl.enabled=false \
--conf spark.sql.sources.commitProtocolClass=org.apache.spark.internal.io.cloud.PathOutputCommitProtocol \
--conf spark.sql.parquet.output.committer.class=org.apache.spark.internal.io.cloud.BindingParquetOutputCommitter \
--conf spark.driver.extraJavaOptions="-Divy.cache.dir=/tmp/ivy-cache -Divy.home=/tmp/ivy" \
./test_batch.py s3a://batch-output/query-output-a

위에서 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 디렉토리에 기록이 될 테니 비슷하게 할 수 있겠다. 다만 처음 요청 때 디렉토리가 동적으로 생성될 거라서 그걸 신경써 줘야 한다.

--

--

No responses yet