Next.js 앱 배포를 위한 K8s 사전지식
시작하며
서버가 필요한 Next.js App을 배포하는 실습을 하기 위해 가장 많은 공부가 필요했던 것은 바로 Kubernetes였다.
Pod까지는 알겠는데, ReplicaSet은 뭐고, Deployment는 뭐고, Service는 또 뭐고, Ingress는 또 뭐야...?
다행히 subicura님의 쿠버네티스 안내서를 읽고 실습하면서, 어느정도 간단한 배포를 위한 감을 잡을 수 있었다. 해당 문서을 읽으면서 배운 내용을 본 포스트에 정리해보려고 한다.
Docker, Docker Compose, Kubernetes
K8s이야기를 하면 Docker 이야기가 항상 먼저 나온다. K8s를 쓰면서 Docker 이미지를 바탕으로 컨테이너를 생성하기도 하고, 컨테이너 런타임으로 Docker를 쓰기도 하기 때문이다.
Docker는 종종 쓸 일이 있었어서 간단하게만 알고 있었다. 내맘대로 요약하자면 서버의 포장이사를 위한 도구...? 서버의 일부를 야금야금 포장해서 이미지로 만든 뒤 다른 서버에 풀어놓을 수 있게(컨테이너) 만드는 도구라고 이해했다.
그렇다면 Docker Compose랑 Kubernetes는 뭐란 말인가?
Docker Compose는 컨테이너가 여러 개일 때 명세를 통해 그들을 다같이 실행하거나 관리할 수 있도록 도와주는 도구이다. 그리고 Kubernetes는 컨테이너가 실행되는 서버가 여러 대일 때 컨테이너들을 관리해주는 컨테이너 오케스트레이션 도구이다.
컨테이너 오케스트레이션
- 여러 개의 서버에 컨테이너를 배포하고 운영하면서, 서비스 간의 연결을 쉽게 해주는 것
- 애플리케이션을 적당한 서버에 알아서 배포해 줌
- 부하가 생기면 컨테이너를 늘리고 일부 서버에 장애가 발생하면 정상 동작중인 서버에 다시 띄워 장애를 방지
Kubernetes 기본 개념
K8s가 하는 일은 명령이 아니라 Spec을 활용한 선언으로 현재 상태(Current State)를 원하는 상태(Desired State)로 유지하는 것이다.
원하는 상태(Desired State)
- 얼마나 많은 웹서버가 떠있으면 좋은지, 몇번 포트로 서비스하기를 원하는 지
- 관리자가 바라는 환경
- 원하는 상태를 오브젝트로 관리함 (Pod, Depolyment, Service 등등)
Kubernetes 아키텍쳐
K8s는 수많은 서버를 마스터-노드 구조로 구성한다.
- 마스터: 전체 클러스터 관리
- 노드: 컨테이너가 배포됨
- 모든 명령은 마스터의 api 서버를 호출하고 노드는 마스터와 통신하면서 필요한 작업을 수행
마스터 서버
- 다양한 모듈이 확장성을 고려하여 기능별로 쪼개져 있음
- 마스터 서버가 죽으면 클러스터를 관리할 수 없기 때문에 3대를 구성하여 안정성 높임
- EKS에서는 마스터를 AWS에서 관리하여 마스터에 접근 불가
- Api 서버, 분산데이터 저장소(etcd), 스케줄러, 컨트롤러 등으로 구성
노드 서버
- 마스터 서버와 통신하면서 필요한 Pod를 생성하고 네트워크와 볼륨을 설정
- 실제 컨테이너들이 생성되는 곳으로 수백, 수천대로 확장가능
- 라벨을 붙여 사용목적을 정의할 수 있음
- kubelet, proxy 등으로 구성
오브젝트의 종류
Pod
Pod는 쿠버네티스 배포의 최소 단위로, 도커는 컨테이너를 만들지만 쿠버네티스는 컨테이너 대신 내부에 한 개 이상의 컨테이너를 가지는 Pod를 만든다.
kubectl run 으로 Pod를 생성하면 일어나는 일
- kubectl이 ApiServer로 요청보냄(POST)
- ApiServer는 권한 문제가 없으면 etcd에 Pod정보를 저장하고 응답반환
- etcd에 정보가 들어오면 그걸 바라보고 있던 scheduler가 할당되지 않은 Pod를 감지
- 적합한 노드 선택하고 Apiserver에 etcd 업데이트 요청
- ApiServer가 반영하면, etcd를 바라보고 있던 해당노드의 Kubelet이 이미지를 pull 받아서 실제 컨테이너 생성 요청
- CNI 호출해서 pod의 IP지정, 필요한 볼륨 세팅
다중 컨테이너
도커와 달리 Pod하나에 컨테이너 여러 개가 존재할 수 있으며, 컨테이너끼리 localhost로 소통한다.
apiVersion: v1
kind: Pod
metadata:
name: counter
labels:
app: counter
spec:
containers:
- name: app
image: ghcr.io/subicura/counter:latest
env:
- name: REDIS_HOST
value: "localhost"
- name: db
image: redis
Pod를 단독으로 사용하는 경우는 거의 없다. Deployment가 ReplicaSet을 이용하고, ReplicaSet이 Pod들을 관리한다.
ReplicaSet
ReplicaSet 동일한 Pod들의 매니저이다. 정해진 수만큼 Pod를 복제하고 관리하는 역할을 한다. Pod를 단독으로 만들었을 때와 달리, 서버 문제로 Pod가 사라지는 등의 문제가 있을 때 자동으로 복구될 수 있도록 한다. replicas의 수만 키우면 pod가 알아서 늘어나기 때문에 쉽게 스케일 아웃 할 수 있다.
ReplicaSet의 동작방식
- replicaSet.yaml을 apply
- ApiServer가 문제 없는지 확인해서 ReplicaSet 정보를 etcd에 영구 저장
- etcd의 변경사항을 ReplicaSet Controller(controller manager안에 있음)가 감지
- 조건을 만족하기 위해 Pod 생성/제거를 ApiServer에 요청
- scheduler가 Pod를 노드에 배치
- kubelet이 새 노드를 만들거나 삭제
쿠버네티스의 watch 방식
- polling 방식
- a가 b에게 주기적으로 물어봄
- 변경사항 있어?
- 변경사항 있어?
- 변경사항 있어?
- push 방식
- b가 변경사항이 생기면 a에게 알려줌
- etcd ↔ ApiServer : 폴링방식
- 여기서 푸시방식을 사용하지 않는 이유는, 캐싱을 이용해 최적화하여 폴링하는것이 더 효과적이기 때문
- ApiServer ↔ controller, scheduler, kubelet : 푸시방식
Deployment
Deployment는 ReplicaSet을 생성해서 관리하는 오브젝트로, ReplicaSet을 이용하여 Pod를 업데이트하고 이력을 관리해 롤백하거나 특정 버전으로 돌아갈 수 있다.
- 선언적 업데이트: 새 버전 배포 시 자동으로 새 ReplicaSet 생성
- 롤링 업데이트: 무중단 배포 (점진적으로 Pod 교체)
- 롤백: 이전 버전으로 쉽게 되돌리기
- 배포 히스토리 관리: 여러 ReplicaSet 버전 추적
Deployment의 이미지를 변경하면?
- 새로운 ReplicaSet 생성
- 새 ReplicaSet의 Pod 수를 점진적으로 증가
- 기존 ReplicaSet의 Pod 수를 점진적으로 감소
- 기존 ReplicaSet은 남아있음 (롤백 대비)
Deployment의 동작방식
- 새로운 Deployment가 apply 됨
- ApiServer가 etcd에 Deployment 저장
- Deployment Controller가 현재상태와 원하는 상태가 다른 것을 체크하여 ApiServer에 ReplicaSet 생성 요청
- ApiServer가 etcd에 ReplicaSet 반영
- ReplicaSet Controller가 현재상태와 원하는 상태가 다른 것을 체크하여 Pod 생성 요청
- ApiServer가 etcd에 Pod반영
- Scheduler가 Pod변경사항 확인하고 노드 할당
- 할당된 노드의 Kublet이 컨테이너 생성
Deployment의 배포전략
Deployment의 strategy는 기본적으로 RollingUpdate로, Pod를 하나씩 내렸다 하나씩 올리는 점진적 무중단 배포 방식이다. 다른 옵션으로는 모든 Pod를 한 번에 내렸다 새로 생성하는 Recreate가 있다.
Service
Service는 Pod에 접근할 수 있도록 도와주는 안정적인 네트워크 엔드포인트를 제공한다. Pod는 쉽게 제거되거나 새로 생성되는데, 그때마다 IP주소가 변경되면 직접 통신하기 어렵다. 이러한 상황애서 Service는 Pod로 연결할 수 있는 안정적인 IP를 제공하고, 똑같은 Pod가 여러 개일 때 어떤 Pod로 요청이 갈 지 분산해주는 로드 밸런싱 역할도 한다.
Service 생성 흐름
- 새로운 Service를 apply 하면, ApiServer가 etcd에 Service 저장
- Endpoint Controller가 서비스를 감지하고, 새로 등록된 서비스와 동일한 라벨을 가진 Pods를 검색한 뒤 새로운 Endpoint를 생성
- Kube-Proxy가 Endpoint의 생성을 감지하고 노드의 iptables나 ipvs 규칙을 설정
- CoreDNS가 Service를 감지하고 서비스 이름과 IP를 CoreDNS에 추가
- iptables: 커널 레벨의 네트워크 도구 (리눅스 패킷 필터링/방화벽 → 패킷이 적절한지 검사해서 내보냄)
- 내부에서 NAT(Network Address Translation)을 이용해 서비스로 들어온 패킷을 pod로 보내도록 설정
- 규칙이 많아지면 느려지는 성능이슈로, 대규모 클러스터에서는 ipvs를 사용하기도 함
- coreDNS: 클러스터 내부용 도메인 네임 서버
- IP 주소 대신 서비스 이름으로 요청 받을 수 있게함
iptables 설정으로 여러 IP에 트래픽을 전달하고, CoreDNS를 이용하여 IP 이름 대신 도메인 이름을 사용
NodePort
Service에 타입을 지정하지 않으면, 클러스터 내부에서만 접근 가능한 Cluster IP가 된다. NodePort 서비스는 클러스터 외부에서도 접근이 가능하다.
apiVersion: v1
kind: Service
metadata:
name: counter-np
spec:
type: NodePort
ports:
- port: 3000
protocol: TCP
nodePort: 31000
selector:
app: counter
tier: app
LoadBalancer
노드가 여러 개일 때는, 어떤 앱으로 가려면 어떤 노드로 가야하는지를 알아야한다. 또한 노드에 접근하기 위한 안정적인 IP와 트래픽 분산도 필요하다. Service의 타입으로 LoadBalancer를 만들면, 클라우드 환경 (AWS ELB/GCP LB)에서 로드 밸런서를 생성할 수 있고, 모든 노드에 NodePort가 자동할당되어 LB가 헬스체크를 통해 살아있는 노드로 트래픽을 분산해준다.
Ingress
클러스터 내부의 Pod들에게 트래픽을 라우팅해주는 Service와 달리, Ingress는 클러스터 외부에서 들어오는 요청을 적절한 Service로 라우팅해주는 역할을 한다. Service가 여러 개일때 각각에 로드 밸런서를 붙이면 비용이 많이들기 때문에, 하나의 Ingress에서 라우팅을 처리해주는 것이 좋다.
- 하나의 진입점(단일 IP/도메인)으로 여러 서비스를 관리
- URL 경로 기반 라우팅 (예:
/api→ API 서비스,/web→ 웹 서비스) - 도메인 기반 라우팅 (예:
api.example.com,www.example.com) - SSL/TLS 종료 처리
- 로드밸런싱, 인증 등의 기능 제공
마무리
이만큼 공부했으니 이제 나의 EC2에 블로그를 배포하러 가보자!