온프레미스 쿠버네티스에서 NAS, GPU 사용하기 (with RKE2, NFS, gpu-operator)

스타트업 온프레미스 K8S 구축기 2편

Kevin Jo
오토피디아
48 min readAug 21, 2022

--

안녕하세요? 오토피디아 리서치팀에서 데이터 엔지니어링과 머신러닝 프로젝트에 참여하고 있는 데이터 배관공 Kevin입니다. 이번 2편에서는 앞선 1편 —온 프레미스 쿠버네티스 프로덕션 환경 10개월 운영기 글에서 구상한 온프레미스 쿠버네티스 아키텍쳐를 실제로 구현하기 위해서 어떤 과정을 밟았는지 알 수 있는 Step-by-Step 튜토리얼을 작성해보았습니다.

RKE2 기반으로 고가용성 온프레미스 쿠버네티스 클러스터를 구축하고, 클러스터 외부에서 URL을 통해 내부 서비스에 접근하고, NAS 장비 내 공유 폴더에 PV를 마운팅하고, GPU 리소스까지 쓸 수 있도록 세팅하는 방법이 궁금하신 분들께 이 글이 도움 되었으면 합니다.

목차

1편 — 온프레미스 쿠버네티스 프로덕션 환경 10개월 운영기

2편 ← (보고 계신 글)

RKE2 클러스터 세팅하기

쿠버네티스 GUI로 관리하기

URL로 Service 접근하기

NAS 붙이기

GPU 붙이기

3편 — 온프레미스 HA 쿠버네티스 클러스터 장애 대응 훈련하기

1. RKE2 클러스터 세팅하기

(1) Route53에서 클러스터 Endpoint 세팅하기

온프레미스 쿠버네티스이더라도 해당 클러스터에 접근할 별도의 도메인을 활용하는 것이 바람직합니다. 본 글에서는 onprem.example.com 이라는 서브 도메인에 클러스터를 연결하는 것을 가정하고 설명해보겠습니다. example.com 도메인을 Route53에서 관리할 수 있도록 소유권을 등록한 상태에서 onprem.example.com 에 대한 A 레코드를 생성하고 값으로 첫번째 컨트롤 플레인 노드의 퍼블릭 IP를 할당해줍니다. Route53의 레코드 테스트 기능을 활용하면 onprem.example.com 를 입력하고 레코드 유형을 A로 선택한 후 응답 수신 을 클릭했을 때 첫번째 컨트롤 플레인 노드의 IP가 반환되면 정상적으로 설정된 것입니다.

Route53에서의 첫번째 컨트롤 플레인 노드 A레코드 세팅 화면

(2) RKE2로 컨트롤 플레인 노드 구성하기

RKE2의 공식 설치 글이 큰 도움이 되었습니다. 첫번째 컨트롤 플레인 노드를 세팅하기 위해서는 아래와 같이 /etc/rancher/rke2/config.yaml 경로에 파일을 하나 만들어줍니다. 공식 설치 가이드의 yaml 설정을 따라갔을 때 노드의 내부 IP가 클러스터에 등록되는 경우가 발생해서 퍼블릭 IP가 항상 클러스터에 등록되도록 bind-address, advertise-address, node-ip , node-external-ip 설정 값을 추가했습니다.

token: my-shared-secret-string
tls-san:
- onprem.example.com
bind-address: <node external ip> (예: 23.253.82.10)
advertise-address: <fist-master-node-external-ip>
node-ip: <fist-master-node-external-ip>
node-external-ip: <fist-master-node-external-ip>
node-name: a6000a

두번째와 세번째 컨트롤 플레인 노드에서는 위 yaml 파일에 server 파라미터만 추가하면 됩니다. RKE2는 마스터 노드간 통신에 9345 포트를 사용하기에 Endpoint 뒤에 포트 정보를 추가해줍니다. 나머지 address 정보에는 해당 노드의 퍼블릭 IP를 기입하면 됩니다.

token: my-shared-secret-string
server: <https://onpremise.example.com:9345>
tls-san:
- on-premise.doctor-cha.com
bind-address: <nth-master-node-external-ip>
advertise-address: <nth-master-node-external-ip>
node-ip: <nth-master-node-external-ip>
node-external-ip: <nth-master-node-external-ip>
node-name: <nth-node-label>

이후에 첫번째 노드에서 아래 명령어를 실행하면 v1.23.5 버전의 RKE2를 다운로드 후 설치되며 첫번째 마스터 노드가 가동됩니다. (참 쉽죠?)

curl -sfL <https://get.rke2.io> | INSTALL_RKE2_CHANNEL=v1.23.5+rke2r1 sh -
systemctl enable rke2-server.service
systemctl start rke2-server.service

아래 명령어로 가동 상태를 체크할 수 있으며 5~10분 정도 소요될 수 있습니다.

journalctl -eu rke2-server -f

첫번째 노드가 정상적으로 가동되기 시작했다면 두번째와 세번째 노드에서도 동일한 명령어를 사용해서 RKE2를 가동해줍니다. 이때에는 server 파라미터 정보를 바탕으로 첫번째 노드와 통신하며 클러스터에 조인하게 됩니다.

정상적으로 클러스터가 모두 붙었다면 아래 명령어를 통해서 컴포넌트의 상태를 확인할 수 있습니다.

$ kubectl get cs
Warning: v1 ComponentStatus is deprecated in v1.19+
NAME STATUS MESSAGE ERROR
controller-manager Healthy ok
scheduler Healthy ok
etcd-0 Healthy {"health":"true","reason":""}

이제 서울오피스에 있는 2대의 CPU 노드와 IDC의 1대의 GPU 노드가 클러스터를 이루고 있기 때문에 2개 이상의 Zone에 소속된 3개의 컨트롤 플레인 노드로 구성된 고가용성(HA) 클러스터가 완성되었습니다.

(3) 다른 마스터 노드의 IP도 A 레코드로 추가하기

이때 놓칠 수 있는 부분으로 onprem.example.com 은 여전히 첫번째 마스터 노드의 IP만을 A 레코드로 가지고 있으므로 첫번째 노드에 장애가 발생할 경우 모든 마스터 노드들 간의 통신이 불가해지며 클러스터는 길을 잃게 됩니다. 두번째와 세번째 노드의 퍼블릭 IP도 동일한 가중치 값을 가진 A 레코드로 추가해줍니다.

더불어서 모든 A레코드의 TTL 값을 짧게 설정하는 것이 좋은데요. TTL 값은 일종의 DNS 쿼리 값의 캐싱 만료 기간으로 만약 5분으로 설정되어 있다면 한번 onprem.example.com 에 대한 DNS 쿼리 값이 장애가 일어난 노드의 IP로 반환된 적이 있다면 5분 동안은 이 값이 캐싱되어 동일한 IP로 요청을 시도하기 때문에 장애가 일어난 노드의 A레코드 가중치를 0으로 변경하더라도 반영이 지연될 수 있습니다. 이러한 지연을 최소화하기 위해서 본 프로젝트에서는 10초로 설정하였습니다.

(4) RKE2 에이전트 노드 구성하기

RKE2를 통해서 컨트롤 플레인이 구성되고 난 이후에 워커 노드를 붙이는 과정도 매우 쉽습니다. /etc/rancher/rke2/config.yaml 경로에 아래와 같이 token, server 정보와 노드의 IP 정보, 노드 라벨 정보를 담은 설정 값을 작성합니다.

token: my-shared-secret-string
server: <https://onprem.example.com:9345>
bind-address: <nth-agent-node-external-ip>
advertise-address: <nth-agent-node-external-ip>
node-ip: <nth-agent-node-external-ip>
node-external-ip: <nth-agent-node-external-ip>
node-name: <nth-agent-node-label>

이후에 아래 명령어를 입력하면 RKE2 agent를 다운로드 자동으로 설치, 실행까지 수행합니다.

curl -sfL <https://get.rke2.io> | INSTALL_RKE2_TYPE="agent" INSTALL_RKE2_CHANNEL=v1.23.5+rke2r1 sh -
systemctl enable rke2-agent.service
systemctl start rke2-agent.service

journalctl 명령어를 통해서 실제로 잘 가동되었는지 로그를 확인할 수 있습니다. 구성에 따라 다르겠지만 대략 5~10분 정도 기다리면 정상적으로 클러스터에 붙는 것을 확인할 수 있었습니다.

journalctl -u rke2-agent -f

위 과정을 각 노드 별로 반복해주면 이제 고가용성이 확보된 컨트롤 플레인과 워커 노드로 구성된 온프레미스 쿠버네티스 클러스터가 완성됩니다.

2. 쿠버네티스 GUI로 관리하기

[1] 과정을 통해서 쿠버네티스 클러스터가 완성되었는데요, 흔히 사용되는 kubectl CLI 명령어를 활용한다면 얼마든지 클러스터를 제어할 수 있습니다. 하지만 저는 이번 프로젝트를 통해 쿠버네티스를 처음 접하는 상황이었고 kubectl CLI 명령어에 익숙치 않아서 클러스터 내에 다른 리소스를 띄우거나 운영하는 속도가 빠르지 못했습니다.

이때 웹 기반의 클러스터 매니지먼트 서비스들을 활용하면 GUI를 통해서 쿠버네티스의 현황 파악과 리소스 생성 및 편집을 쉽게 할 수 있습니다. 다양한 서비스들이 있지만 이번 프로젝트에서는 RKE2를 만든 회사에서 배포한 Rancher를 사용하기로 결정하였습니다.

RKE2를 설치하면 기본적으로 Helm3가 함께 설치되므로 바로 Helm 차트를 통해서 Rancher도 쉽게 설치할 수 있습니다.

helm install rancher rancher-stable/rancher \\
--namespace cattle-system \\
--set hostname=onprem.example.com \\
--set bootstrapPassword=admin \\
--set ingress.tls.source=letsEncrypt \\
--set letsEncrypt.email=myemail@example.com \\
--set letsEncrypt.ingress.class=nginx

이때 한가지 팁으로 Rancher 설치 시에 ingress.tls.source , letsEncrypt.email , letsEncrypt.ingress.class 옵션을 추가적으로 세팅해주면 letsEncrypt로 self-signed CA 인증서가 발급되면서 HTTPS가 적용된 웹 Rancher 서비스를 이용할 수 있습니다. 처음에는 이 옵션을 몰라서 크롬 브라우저에서 안전하지 않은 연결이라는 수많은 경고를 받았지만 HTTPS가 적용되면서 매우 편-안해졌습니다.

Helm을 통해 Rancher 설치가 완료되면 hostname 값(Route53에 등록한 서브도메인)으로 별도 포트번호 없이 접속해봅니다. 최종적으로 아래 사진과 같이 로그인 화면이 뜨면 Rancher까지 정상적으로 설치된 것입니다.

https://onprem.example.com/

Rancher는 UI가 깔끔해서 기분이 좋습니다.

3. URL로 Service 접근하기

[1], [2]의 과정을 통해 K8S 클러스터와 이를 쉽게 관리할 수 있는 GUI 인터페이스까지 구축되었습니다. 클러스터 내부 워크로드를 운영하고 이를 관리하는 정도의 유즈 케이스라면 문제가 없지만, Airflow의 웹 콘솔, ML API 등을 HTTP를 통해서 접근하기 위해서는 클러스터 내 서비스를 외부에 노출시키는 추가 작업이 필요합니다.

HTTP 요청과 클러스터 내 서비스는 Ingress 리소스를 통해 연결할 수 있습니다. 조금 더 구체적으로 Ingress 리소스에 미리 다양한 외부 HTTP 요청 패턴(Hostname, URL Path 등) 별로 어떤 서비스의 몇번 포트로 연결시킬 것인지 정의해놓습니다. 더불어서 Ingress Controller는 실질적으로 새로운 HTTP 요청이 클러스터에 도달했을 때 Ingress 리소스에 정의된 트래픽 라우팅 룰에 따라 매칭되는 Service 리소스의 로드 밸런서의 External IP로 라우팅해주는 역할을 합니다. 그러나 거의 대부분의 온프레미스 쿠버네티스에는 물리적인 로드 밸런서 장비까지 구축하는 경우가 없기 때문에 소프트웨어적인 방식으로 로드 밸런서를 구현할 수 있는 MetalLB 를 설치하여 함께 사용하는 경우가 많습니다.

즉, Ingress 리소스에 의한 트래픽 라우팅 규칙 정의 + Ingress 리소스 상에 명시된 규칙에 따라 실질적으로 라우팅을 수행하는 Ingress Controller + 소프트웨어적인 로드 밸런서를 에뮬레이팅해주는 MetalLB 로 외부 HTTP 요청과 클러스터 내 서비스를 연결하는 구조입니다.

그러나 조금 더 쉬운 방법으로는, RKE2 설치 시에 기본으로 함께 설치되는 RKE Nginx Ingress Controller를 HostNetwork 사용 모드로 설정하고 DaemonSet으로 배포하면 MetalLB와 같은 가상 로드 밸런서 없이도 클러스터 외부 HTTP 요청을 ClusterIP 타입으로 배포된 Service로 라우팅이 가능해집니다. (단, 보안 측면에서는 공식 가이드 상에서 설명하는 주의점이 있습니다)

챕터[3]에서는 기설치된 RKE Nginx Ingress Controller의 설정을 변경함으로써 클러스터 외부에서 도메인 URL과 URL 경로를 기반으로 HTTP 요청이 수신되었을 때 원하는 Service로 연결될 수 있도록 해봅니다.

(1) nginx-ingress DaemonSet 변경

RKE2의 설치 시에는 외부 HTTP 요청의 라우팅을 담당하는 nginx-ingress가 replica=1인 Deployment 리소스로 배포되기 때문에 해당 Deployment의 Pod이 죽었을 때 정상적인 라우팅이 불가해집니다. 이러한 상황은 모든 노드마다 1개의 Pod씩 배포되는 DaemonSet으로 배포함으로써 해결이 가능합니다. 더불어서 useHostPort: true 옵션을 활용하며 앞서 이야기했던 Ingress 리소스에 정의된 라우팅 규칙을 MetalLB 없이도 적용할 수 있습니다.

이를 위해서 아무 마스터 노드 상에 존재하는 /var/lib/rancher/rke2/server/manifests/rke2-ingress-nginx.yaml 파일을 아래 내용으로 덮어씁니다. 별도의 클러스터 재시작 없이 변경 사항이 자동으로 재배포됩니다.

apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: rke2-ingress-nginx
namespace: kube-system
spec:
valuesContent: |-
controller:
kind: DaemonSet
daemonset:
useHostPort: true

DaemonSet으로 체크해봤을 때 rke2-ingress-nginx-controller 가 노드 수 만큼 표시된다면 정상적으로 배포된 것입니다.

kubectl get daemonsets --all-namespaces

(2) Nginx Deployment + Service 띄우기

Ingress 리소스를 활용하기 전에는 Service를 클러스터 외부로 노출시키기 위해서는 NodePort 타입으로 배포한 후 onprem.example.com:<NodePortNumber> 와 같은 형태로 URL과 서비스에 할당된 포트번호를 같이 지정해야 접속이 가능했는데요. (1)의 과정을 통해 모든 노드의 Nginx Ingress Controller에서 80, 443 포트에 대해서 리슨하고 있기 때문에 URL 만으로 접속이 가능해집니다.

이를 테스트하기 위해서 아래 yaml 파일로 간단한 nginx를 2개 띄웁시다. 이때 metadata/namespace 혹은 UI 상에서 namespace를 지정해줍니다. 이 예제에서는 production 을 사용했습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
selector:
matchLabels:
app: nginx
replicas: 2 # tells deployment to run 2 pods matching the template
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80

이제 nginx Service 도 만들어줍니다. 이때 타입을 별도로 지정하지 않으면 알아서 ClusterIP로 생성됩니다.

apiVersion: v1
kind: Service
metadata:
name: nginx
spec:
ports:
- name: http
port: 8080
protocol: TCP
targetPort: 80
selector:
app: nginx
type: ClusterIP

(3) Ingress 리소스 생성해서 서브도메인 + /path 로 서비스에 접근 테스트하기

이제 Ingress 리소스를 만들 수 있습니다. Ingress 리소스는 일반적으로 서비스가 생성된 후에 만들어야 정상적으로 작동됩니다. 더불어서 알 수 없는 이유로 아래 yaml 파일로 생성한 후에 Rancher UI 상에서 path를 변경하면 경로 재설정이 정상적으로 안되는 것을 느꼈습니다. 따라서 yaml 파일로 한방에 잘 만들어야 합니다.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-ingress
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/rewrite-target: /
# key: string
labels: {}
spec:
rules:
- http:
paths:
- backend:
service:
port:
number: 8080
name: nginx
path: /nginx
pathType: Prefix
host: onprem.example.com

https://onprem.example.com/nginx 로 접속하면 환영 메시지가 나옵니다.

어 그래 나도 반갑다!

(4) 실전 Ingress 리소스 운영 팁

이제 포트번호 없이, URL Path를 통해 원하는 클러스터 내의 서비스에 접속할 수 있게 되었습니다. 이때 아래와 같이 host 파라미터와 path 파라미터를 적절히 활용하여 클러스터 운영 과정에서 이점을 챙길 수 있었습니다.

a. 서브도메인으로 운영할 서비스 구분하기

사내에서 장기적으로 활용될 서비스들은 아예 별도의 서브도메인으로 등록하여서 운영하는 방법도 있습니다. 1편 글에서 언급된 요구사항처럼 추후에 클라우드로 마이그레이션이 용이하게 쿠버네티스 내의 리소스들을 관리하자 의 목적 하에서 onprem.example.com/airflow 와 같은 URL은 onprem.example.com 이라는 서브도메인에 종속되기 때문에 추후에 Airflow 자체가 클라우드로 이전되었을 때 URL이 좀 애매해집니다. 따라서 서비스 배포 시에는 인프라 정보가 내포되지 않는 네이밍이 바람직하다고 판단했습니다.

이를 위해서 Airflow를 예시로 들자면 airflow.example.com 을 서브도메인으로 등록한 후에 Rotue53에서 CNAME 레코드를 onprem.example.com 을 바라보도록 설정해줍니다. 이렇게 되면 airflow.example.com 을 입력했을 때 자동으로 onprem.example.com 상에 등록되어 있는 컨트롤 플레인 노드들의 IP로 DNS 쿼리가 되며, 컨트롤 플레인 노드의 IP 풀은 onprem.example.com 에서만 관리해주면 되기 때문에 운영하는 서비스별 서브도메인이 많아지더라도 관리를 일원화 할 수 있다는 장점이 있습니다.

서브도메인 레코드 등록 및 CNAME 설정이 완료된 후에 Ingress 리소스 내 host 값을 airflow.example.com 으로 지정하면 됩니다.

b. URL Path로 환경 구분하기

한가지 더 신경 쓴 점으로 같은 서비스더라도 운영 환경에 따른 배포가 필요한 경우가 있습니다. 이때 Ingress의 path 파라미터를 운영 환경을 구분 짓는데 활용하기로 했습니다.

위 두가지 운영 규칙을 적용하면 Airflow의 경우 airflow.example.com/prod , airflow.example.com/dev와 같은 형태로 운영할 수 있습니다.

4. NAS 붙이기

쿠버네티스 상에서 다양한 워크로드를 실행하다보면 DB 데이터 혹은 웹 페이지 정적 파일 저장과 같이 영속되어야 하는 데이터를 PV(Persistence Volume)리소스에 보관하는 경우가 있습니다. 일반적으로 PV는 컴퓨팅 노드가 아닌 공유 스토리지에 생성하는데요. DB를 함께 운영하는 Pod를 예시로 들어볼까요? 단순하게 Pod가 스케쥴되는 노드 상의 로컬 스토리지 상에 PV를 생성하고 DB 저장소로 활용하는 경우, 만약 해당 Pod가 죽어 다른 노드에 Pod가 다시 복구되더라도 다른 노드의 스토로지에 저장된 PV에 접근이 불가능합니다. 이 때문에 대부분의 쿠버네티스 워크로드에서는 컴퓨팅 노드와 분리된 공유 스토리지를 활용합니다. AWS EKS를 예시로 들자면 EBS, EFS, S3과 같은 다양한 형태의 공유 스토리지 타입을 지원합니다.

다행히 온프레미스 쿠버네티스 환경에서도 클라우드와 유사한 공유 스토리지 환경을 구축할 수 있는데요. 본 챕터에서는 NFS(Network File System)를 통해 Synology NAS 상의 SSD 볼륨을 클러스터 내의 노드들이 원격으로 마운팅할 수 있는 공유 스토리지로 세팅해봅니다. 온프레미스 쿠버네티스 환경에서 S3와 같은 오브젝트 스토리지 저장소를 운영하고자 한다면 S3와 호환되는 MinIO를, 분산형 블록 스토리지는 Longhorn을 눈여겨 볼 필요가 있습니다.

구축 과정에서 이 가이드 글이 큰 도움이 되었으며 Synology NAS 상에서 NFS를 설정하고 PV와 PVC를 만드는 법을 잘 설명해주고 있습니다. 더불어서 NAS 쪽에서 해주어야 하는 설정에 대해서는 이 글에서 자세히 설명해주고 있습니다.

(1) NFS 설정하기

공유 폴더 생성 후 NFS 권한 설정

이때 아래 사진처럼 k8s-data 공유 폴더를 만든 후 편집 → NFS 권한 탭에 들어가서 K8S 클러스터의 IP 영역대를 넣고 매핑 없음, 비동기 활성화, 하위 폴더 엑세스 허용을 활성화합니다. 그리고 실험을 위해 공유 폴더 안에 nginx-1 이라는 폴더도 만들고 테스트 페이지로 index.html 파일을 생성 후 배치합니다.

<!DOCTYPE html> <html> <head> <style> </style> </head> <body> <h1>Kubernetes - Webtest 1</h1> <p>This page is located on a persistent volume, and run on a k8s-cluster!</p> </body> </html>

(2) 노드에 NFS 패키지 설치하기

이때 Synology 설정을 완료했다고 하더라도 각 노드 상에서 nfs-common 을 설치해주어야 NFS 마운팅 명령어를 수행할 수 있습니다. 모든 컨트롤 플레인과 워커 노드에 모두 설치해줍니다.

sudo apt-get install -y nfs-common

(3) PV, PVC 수동으로 만들어서 index.html 연결하기

PV, PVC 오브젝트 만들기

이제 PV 오브젝트를 먼저 만들어봅니다. 여기서 주의할 사항으로는

  • NAS 서버의 IP를 잘 입력할 것
  • path 인자의 경우 Synology 상에서는 사용자가 지정한 볼륨 이름을 넣으면 될 것 같지만, 실제로는 아래 사진에 나온 것처럼 마운트 경로라고 표시하는 경로를 입력해야 합니다.

아래 Yaml 파일을 통해 PV와 PVC를 생성해봅니다.

apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-nfs-kubedata-nginx-1 # < Name of the persistent volume
namespace: production
spec:
storageClassName: ""
capacity:
storage: 1Gi # < Maximum storage size you want to reserve
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
mountOptions:
- hard
- nfsvers=4.1
nfs:
server: <nas-node-public-ip>
path: "/volume2/k8s-data/nginx-1" # < The NFS volumename
readOnly: false
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-nfs-kubedata-nginx-1
namespace: production
spec:
storageClassName: ""
volumeName: pv-nfs-kubedata-nginx-1 # < The volumename needs correpond with the persistent volume
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

❗ 만약 PV나 PVC를 실수로 잘못 만들었을 때는 하나만 삭제 후 재생성이 불가능하다. 특히 PV의 persistentVolumeReclaimPolicy=Retain 인 경우에는 PVC를 지우고 새로 생성하더라도 같은 이름의 PVC와만 붙을려고 하기 때문에 PVC와 엮인 디플로이먼트 삭제 → PVC 삭제 → PV 삭제를 순차적으로 밟고 PV부터 다시 만들어야 한다.

(4) Nginx Deployment / Service / Ingress 만들기

이제 PVC가 생성되었으니 Nginx Deployment Pod에 볼륨을 정의해주고, 해당 볼륨에 생성된 PVC를 연결시켜줍니다. 더불어서 웹을 통해 index.html을 접근할 수 있도록 Ingress와 Service 리소스도 만들어봅니다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-1
namespace: production
labels:
app: nginx-1
spec:
replicas: 1
selector:
matchLabels:
app: nginx-1
template:
metadata:
labels:
app: nginx-1
spec:
volumes:
- name: nginx-1-volume
persistentVolumeClaim:
claimName: pvc-nfs-kubedata-nginx-1
containers:
- image: nginx
name: nginx-1
imagePullPolicy: Always
resources:
limits:
memory: 512Mi
cpu: "1"
requests:
memory: 256Mi
cpu: "0.2"
volumeMounts:
- name: nginx-1-volume
mountPath: /usr/share/nginx/html
---
kind: Service
apiVersion: v1
metadata:
name: nginx-1-service
namespace: production
spec:
selector:
app: nginx-1
ports:
- name: http
port: 8555
protocol: TCP
targetPort: 80
type: ClusterIP
---

❗ 튜토리얼 상에서는 LoadBalancer 타입으로 설정하였는데 우리는 ClusterIP로 type을 지정해줍니다.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-pv-ingress
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/rewrite-target: /
# key: string
labels:
{}
# key: string
namespace: production
spec:
rules:
- http:
paths:
- backend:
service:
port:
number: 8555
name: nginx-1-service # <- service name
path: /pv-test
pathType: Prefix
host: on-premise.doctor-cha.com

❗ port 설정 아래의 name 인자에서는 앞의 서비스 오브젝트의 이름을 정확하게 지정해야 한다.

배포 확인하기. 아래 링크를 클릭해서 NAS 공유 폴더 내의 index.html 파일이 로딩되는지 확인해봅니다.

http://onprem.example.com/pv-test
NAS 상의 데이터에 PV가 정상적으로 생성되었습니다!

Ingress → Service → Nginx Deployment → Volume → PV → NFS → NAS 공유 폴더의 경로를 통해서 NAS SSD에 저장된 정보가 네트워크를 타고 각 컴퓨팅 노드에 마운팅 된 것을 웹을 통해서 확인할 수 있습니다.

이때 Nginx Deployment의 Replica를 10으로 늘린 후 새로고침을 열심히 해보며 10개의 Pod가 동시에 하나의 PV를 읽는 ReadMany 상황도 정상 작동함을 확인할 수 있습니다.

(5) Storage Class로 PV, PVC 자동으로 만들기

앞선 과정을 통해서 NFS를 타고 PV가 정상적으로 마운팅 되는 것을 확인했습니다. 그러나 새로운 PVC 리소스가 필요해질 때마다 대응되는 PV의 생성과 삭제를 수동으로 관리하는 것은 매우 번거롭습니다.

이때 StorageClass 리소스를 사전에 정의해두고 PVC 리소스 생성 시에 원하는 StorageClass 종류를 할당하면 자동으로 원하는 형태의 PV가 생성되고, PVC 리소스가 삭제될 때 연결된 PV의 생애주기도 함께 관리되는 편의성을 누릴 수 있습니다.

이어지는 튜토리얼 글을 통해 StorageClass를 Provision해서 PVC 리소스를 생성하면 자동으로 NAS 상에 PV가 생성될 수 있도록 설정해보겠습니다.

kubectl create namespace nfs-client-provisioner

우선 위 명령어를 통해서 네임스페이스를 새로 생성해줍니다.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: managed-nfs-storage
namespace: nfs-client-provisioner
provisioner: nfs-dynamic-storage # < new provsioner name, must match deployment's env PROVISIONER_NAME'
parameters:
archiveOnDelete: "false"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: nfs-client-provisioner
# replace with namespace where provisioner is deployed
namespace: nfs-client-provisioner # < change namespace to nfs-client-provisioner
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: nfs-client-provisioner-runner
rules:
- apiGroups: [""]
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: run-nfs-client-provisioner
subjects:
- kind: ServiceAccount
name: nfs-client-provisioner
# replace with namespace where provisioner is deployed
namespace: nfs-client-provisioner # < change namespace to nfs-client-provisioner
roleRef:
kind: ClusterRole
name: nfs-client-provisioner-runner
apiGroup: rbac.authorization.k8s.io
---
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: leader-locking-nfs-client-provisioner
# replace with namespace where provisioner is deployed
namespace: nfs-client-provisioner # < change namespace to nfs-client-provisioner
rules:
- apiGroups: [""]
resources: ["endpoints"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: leader-locking-nfs-client-provisioner
# replace with namespace where provisioner is deployed
namespace: nfs-client-provisioner # < change namespace to nfs-client-provisioner
subjects:
- kind: ServiceAccount
name: nfs-client-provisioner
# replace with namespace where provisioner is deployed
namespace: nfs-client-provisioner # < change namespace to nfs-client-provisioner
roleRef:
kind: Role
name: leader-locking-nfs-client-provisioner
apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nfs-client-provisioner
labels:
app: nfs-client-provisioner
# replace with namespace where provisioner is deployed
namespace: nfs-client-provisioner # < change namespace to nfs-client-provisioner
spec:
replicas: 2
strategy:
type: Recreate
selector:
matchLabels:
app: nfs-client-provisioner
template:
metadata:
labels:
app: nfs-client-provisioner
spec:
topologySpreadConstraints: # <- 서로 다른 control-plane 노드에 Pod 배포하기 위한 설정
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: nfs-client-provisioner
serviceAccountName: nfs-client-provisioner
containers:
- name: nfs-client-provisioner
image: k8s.gcr.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2
volumeMounts:
- name: nfs-client-root
mountPath: /persistentvolumes
env:
- name: PROVISIONER_NAME
value: nfs-dynamic-storage # < new provsioner name
- name: NFS_SERVER
value: <nas-node-public-ip> # < IP address of your NAS server
- name: NFS_PATH
value: "/volume2/k8s-data" # < example value of your nfs share
volumes:
- name: nfs-client-root
nfs:
server: <nas-node-public-ip> # < IP address of your NAS server
path: "/volume2/k8s-data" # < example value of your nfs share

(6) StorageClass 테스트해보기

아래와 같이 PVC 리소스를 생성하면 자동으로 managed-nfs-storage 스토리지 클래스로 PVC 리소스가 만들어지며 이는 nfs-dynamic-storage provisioner에 의해 NAS 상에 PV로 생성됩니다.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-nfs-dynamic-nginx-1
annotations:
volume.beta.kubernetes.io/storage-class: "managed-nfs-storage" # < the dynamic nfs storage call we have created earlier
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Mi

아래 사진과 같이 PVC와 PV가 자동으로 생성되고 NAS 상에서도 폴더(production-pvc-nfs-dynamic-nginx-1-pvc-c3f…)가 자동으로 생성된 것을 볼 수 있습니다. 새로 생성된 폴더에도 index.html 파일을 배치해봅니다.

아까 만들었던 Nginx Deployment를 삭제하고 다시 한번 Nginx Deployment를 테스트용으로 띄워보겠습니다. 정상적으로 index.html 의 파일을 확인할 수 있다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-1
namespace: production
labels:
app: nginx-1
spec:
replicas: 1
selector:
matchLabels:
app: nginx-1
template:
metadata:
labels:
app: nginx-1
spec:
volumes:
- name: nginx-1-volume
persistentVolumeClaim:
claimName: pvc-nfs-dynamic-nginx-1
containers:
- image: nginx
name: nginx-1
imagePullPolicy: Always
resources:
limits:
memory: 512Mi
cpu: "1"
requests:
memory: 256Mi
cpu: "0.2"
volumeMounts:
- name: nginx-1-volume
mountPath: /usr/share/nginx/html

(7) PVC 삭제 후 PV 자동 삭제 테스트

StorageClass를 사용하지 않을 때는 PVC를 삭제해도 PV가 남아 있기 때문에 수동으로 PV를 관리해야 합니다. 하지만 StorageClass를 사용하면 PVC만 삭제해도 자동으로 PV도 함께 삭제되며 NAS 상에서도 해당되는 공유 폴더가 삭제되므로 관리가 무척 편리해집니다. 따라서 NAS를 PV 저장소로 활용할 때는 꼭 Provisioner와 StorageClass를 함께 활용하는 것을 추천드립니다. (단, 현재 PV의 저장 최대 용량에 대해 리소스 제한을 걸더라도 실제로는 적용되지 않는 알려진 이슈가 존재합니다)

한가지 추가 팁으로, PostgresDB과 같은 StatefulSet 리소스를 운영하는 경우에는 영속적으로 데이터를 남기고 싶을 수 있습니다. 즉, 실수로 PostgresDB PVC 리소스를 삭제하더라도 NAS 상에 원본 데이터가 자동으로 삭제되고 않고 유지되다가 추후에 동일한 설정으로 StatefulSet을 다시 만들면 NAS 공유 폴더에 남아 있던 기존 데이터에 재연결될 수 있었으면 했습니다.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: managed-nfs-storage-retain
metadata: nfs-client-provisioner
provisioner: nfs-dynamic-storage # < new provsioner name, must match deployment's env PROVISIONER_NAME'
parameters:
onDelete: "retain"
pathPattern: "${.PVC.namespace}-${.PVC.name}" # 기존 튜토리얼에 없는 path pattern을 추가함

이러한 상황을 위해 managed-nfs-storage-retain StorageClass도 자체적으로 제작 후 사용 중입니다. onDelete 파라미터 값을 retain으로 설정하면 자동으로 삭제되는 것을 예방할 수 있으며 pathPattern 을 {PVC namespace}-{PVC name} 으로 지정함으로써 같은 네임스페이스에서 같은 이름을 갖는 PVC는 항상 동일한 공유 폴더 경로를 바라보게 됩니다. 이를 통해 StatefulSet 리소스가 참조하는 PVC와 PV가 삭제 후 재생성되더라도 NAS 상의 기존 데이터와 다시 연결될 수 있습니다.

5. GPU 붙이기

1편 글에서 다루었듯이 본 프로젝트의 목표 중에는 다양한 ML 모델을 서빙할 수 있는 2대의 GPU 노드의 통합 관리가 있습니다. 일반적으로 GPU 서버에서 도커 이미지 가동 시에 nvidia-docker 런타임을 활용하면 도커 컨테이너 상에서 GPU를 사용하는데 무리가 없는데요. GPU 리소스를 사용하는 Pod를 쿠버네티스 상에서 운영하기 위해서는 우선 GPU 장비들을 커스텀 리소스를 통해 클러스터 내에 인식시켜야 합니다.

이때 Nvidia에서 제공하는 GPU Operator를 활용하면 클러스터 내의 모든 노드에 대하여 어떤 GPU 카드가 몇개씩 장착되어 있는지 자동으로 파악하며 커스텀 리소스로 등록시켜 줍니다. 본 챕터에서는 nvidia-container-runtime을 설치하고, RKE2의 기본 컨테이너 런타임을 nvidia-docker로 변경한 후에 GPU Operator를 설치해봅니다. 이후 실제 Pod에서 GPU 연산을 테스트해봅니다.

(1) nvidia-container-runtime 설치

공식 설치 가이드 링크를 따라서 gpu-operator를 설치하기 전에 containerd 기반의 nvidia-container-runtime을 GPU 장비가 장착된 모든 노드에서 설치합니다.

sudo apt-get update && sudo apt-get install -y nvidia-container-runtime

(2) RKE2 기본 컨테이너 런타임으로 nvidia-container-runtime 등록

대부분의 쿠버네티스 GPU 인식시키기 튜토리얼 글에서는 docker 런타임 설정 값을 nvidia-container로 변경하라고 안내되어 있는데요. 이 때문에 처음 RKE2 상에서 GPU Operator를 설치했을 때 GPU가 인식되지 않는 문제를 겪었고 해결하는데 상당한 애를 먹었습니다. 조사 결과 RKE2 기본적으로 docker가 아닌 containerd를 기반으로 컨테이너를 실행하기 때문에 docker 런타임 설정을 변경하는 것은 아무런 효과가 없으며 RKE2의 containerd의 설정 상에서 기본 런타임으로 nvidia-container-runtime을 등록시켜야 했습니다.

우선 기본 생성된 RKE2의 containerd 의 config 파일은 /var/lib/rancker/rke2/agent/etc/containerd/config.toml 경로 상에 존재하며 아래는 최초 설치 시 생성되는 값입니다.

[plugins.opt]
path = "/var/lib/rancher/rke2/agent/containerd"
[plugins.cri]
stream_server_address = "127.0.0.1"
stream_server_port = "10010"
enable_selinux = false
sandbox_image = "index.docker.io/rancher/pause:3.2"
[plugins.cri.containerd]
disable_snapshot_annotations = true
snapshotter = "overlayfs"
[plugins.cri.containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"

이때 위 Nvidia의 공식 가이드 상에 나와 있는 것처럼 몇몇 설정값을 추가해야 되는데요. 주의사항으로는 config.toml 파일 자체를 변경하는 것은 의미가 없으며 특이하게도 config.toml.tmpl이라는 이름의 파일을 신규 생성 후 설정 값을 저장해야 합니다. 그 이유는 rancher-agent의 재시작 과정에서 containerd의 설정값 override를 위해서 정확히 config.toml.tmpl이름의 파일명을 찾기 때문입니다. 따라서 /var/lib/rancker/rke2/agent/etc/containerd/config.toml.tmpl 위치에 아래 내용을 저장합니다. 굵은 이탤릭체로 강조된 부분이 새롭게 추가된 설정 항목입니다.

[plugins.opt]
path = "/var/lib/rancher/rke2/agent/containerd"
[plugins.cri]
stream_server_address = "127.0.0.1"
stream_server_port = "10010"
enable_selinux = false
sandbox_image = "index.docker.io/rancher/pause:3.2"
[plugins.cri.containerd]
disable_snapshot_annotations = true
snapshotter = "overlayfs"
default_runtime_name = "nvidia"
[plugins.cri.containerd.runtimes]
[plugins.cri.containerd.runtimes.nvidia]
privileged_without_host_devices = false
runtime_engine = ""
runtime_root = ""
runtime_type = "io.containerd.runc.v2"
[plugins.cri.containerd.runtimes.nvidia.options]
BinaryName = "/usr/bin/nvidia-container-runtime"
[plugins.cri.containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"

아래 명령어로 rancherd-agent를 재시작해주고 containerd도 재시작해줍니다다.

$ systemctl stop rke2-server.service
$ systemctl restart rke2-server.service
$ journalctl -eu rke2-server -f
$ sudo systemctl restart containerd
$ journalctl -eu containerd -f

(3) GPU Operator 설치

GPU Operator는 클러스터 내 모든 노드에 feature-discovery operator pod를 생성하여서 인식되는 GPU 종류과 개수를 노드 라벨로 자동 태깅해주는 역할을 합니다.

Helm을 통해서 손쉽게 설치가 가능하며 공식 설치 링크를 참조하여 아래 명령어로 gpu-operator helm 레포지토리를 등록합니다.

helm repo add nvidia https://nvidia.github.io/gpu-operator && helm repo updatekubectl create namespace gpu-operator

실제 Helm 차트 설치 시에 여러 옵션을 지원하는데 a6000aa6000b 노드에는 nvidia driver와 toolkit이 이미 설치된 상태여서 drvier.enabled=false, toolkit.enabled=false 로 지정했습니다. 더불어서 깃허브 이슈를 통해서 위에서 편집한 RKE2의 containerd config 파일 경로도 CONTAINERD_CONFIG옵션 값으로 지정해야 한다는 힌트를 얻을 수 있었습니다.

helm install --wait gpu-operator \\
nvidia/gpu-operator -n gpu-operator \\
--set driver.enabled=false \\
--set toolkit.enabled=false \\
--set operator.defaultRuntime=containerd \\
--set toolkit.env[0].name=CONTAINERD_CONFIG \\
--set toolkit.env[0].name=/var/lib/rancher/rke2/agent/etc/containerd/config.toml \\
--set toolkit.env[1].name=CONTAINERD_SOCKET \\
--set toolkit.env[1].value=/run/k3s/containerd/containerd.sock \\
--set toolkit.env[2].name=CONTAINERD_RUNTIME_CLASS \\
--set toolkit.env[2].value=nvidia \\
--set toolkit.env[3].name=CONTAINERD_SET_AS_DEFAULT \\
--set-string toolkit.env[3].value=true

위 내용을 아래와 같이 deploy_gpu.sh와 같은 배쉬 파일로 생성한 후에 실행시키면 터미널에서 복붙시 발생하는 에러를 해결할 수 있습니다.

nano deploy_gpu.sh
chmod +x deploy_gpu.sh
./deploy_gpu.sh

(4) GPU 인식 결과 확인하기

정상적으로 containerd의 runtime이 바뀌었다면 아래 사진처럼 nvidia.com/gpu 와 관련된 라벨들이 할당되고 GPU의 종류 및 개수가 확인이 가능합니다.

GPU Operator에 의해 자동 생성된 Node Label 모습

더불어서 kubectl 명령어를 통해서도 노드 별로 GPU 리소스가 정상적으로 등록되어 있는지 확인이 가능합니다.

kubectl get nodes "-o=custom-columns=NAME:.metadata.name,GPU:.status.allocatable.nvidia\\.com/gpu"NAME        GPU
a6000a 4
a6000b 4

단일 노드에 대한 nvidia/gpu 리소스 인식은 아래 명령어를 입력 후 Capacity 섹션에 nvidia/gpu 가 있는지 확인하면 됩니다.

kubectl describe node a6000a

(5) K8S 상에서 GPU 리소스 사용하는 Pod 실행 테스트

위 과정까지 무사히 완료되었다면 이제 Pod 상에서 GPU를 사용할 수 있습니다. 아래의 간단한 Pod yaml 파일을 통해서 GPU 상에서 벡터 덧셈 연산을 테스트 해볼 수 있습니다.

apiVersion: v1
kind: Pod
metadata:
name: cuda-vector-add
spec:
restartPolicy: OnFailure
containers:
- name: cuda-vector-add
# https://github.com/kubernetes/kubernetes/blob/v1.7.11/test/images/nvidia-cuda/Dockerfile
image: "k8s.gcr.io/cuda-vector-add:v0.1"
resources:
limits:
nvidia.com/gpu: 1 # requesting 1 GPU

Pod 상태가 Complete 되었다면 이제 GPU 리소스까지 사용이 가능한 온프레미스 쿠버네티스가 되었습니다. 한가지 한계로는 현재는 MIG 기능을 활성화하지 않는 이상 정수개 단위의 GPU 할당만 가능한데요. 아주 적은 GPU 리소스를 활용하는 모델이더라도 Pod 생성 시 최소 1개 이상의 GPU를 할당해야 돼서 불필요하게 과다한 자원이 배정된다는 단점이 존재합니다. 오토피디아에서는 BentoML을 사용하여 여러 ML 엔드포인트(~모델)가 통합된 하나의 도커 이미지를 빌드함으로써 이러한 한계를 우회하였습니다.

그러나 BentoML만의 단점들도 존재하여 Concurrent Model Serving을 지원하는 Triton Inference Server 도입을 검토 중에 있습니다. 추후에 기회가 된다면 ML 모델 서빙에 대한 내용도 블로그로 다뤄보고자 합니다.

마치며

긴 글을 시간 내어 읽어주셔서 감사합니다.

많은 우여곡절을 겪었지만 온프레미스 쿠버네티스 클러스터를 구축함으로써 오토피디아의 데이터 플랫폼과 ML API를 지탱하는 인프라 비용을 상당 부분 절약할 수 있었습니다. 더불어서 클라우드에서는 몇 번의 클릭만으로 활용할 수 있는 요소(로드밸런서, 공유 저장소, GPU 리소스)들을 직접 구성하는 과정을 통해 쿠버네티스의 작동 구조와 한계점에 대해 조금 더 깊은 이해를 갖게 되었습니다.

끝으로 오토피디아에서는 이러한 데이터 플랫폼 위에서 빠르게 데이터 기반의 제품 개선 사이클을 겪으며 운전자들이 겪는 다양한 차량 문제들을 함께, 하나씩, 쉽게 해결해 줄 수 있는 플랫폼을 만들어나갈 메이커 분들을 찾습니다. 공식 채용 페이지를 통해 오토피디아, 일하는 방식, 조직 문화, 채용 중인 포지션에 대해 더 자세한 정보를 확인하실 수 있습니다.

그럼, 다음 글에서 다시 만나요!

--

--