전체 프로젝트는 Retro-pong-with-k8s-and-aws에서 확인할 수 있습니다.
이전 글에서 kompose 를 이용해 docker-compose를 kubernetes로 변환했다. 하지만 공부하는 입장에서 너무 간단하게 넘어간 점과 저번에 활용한 프로젝트가 42 oauth로만 접근이 가능하다는 점에서 변환할 (같이 공부한 다른 팀의) 프로젝트로 변경하고 직접 kubernetes로 변환해보기로 했다.
이번 프로젝트는 내가 만든 프로젝트가 아니기에 프론트, 백의 소스코드를 직접 건들기가 어렵다. 그렇기에 현재 구조를 그대로 이어가기 위해 한 부분만 제외하고 노력을 많이 했다. 변경한 파트는 저번 글과 같은 이유로 nginx와 frontend를 합친 부분이다. 이번 프로젝트 역시 npm build 후, dist 폴더만 만들고 종료되는데 이 구조를 수정했다.
또한 배포되는 모든 deployment , service , persistent volume claim 은 app 이란 namespace에 배포한다. persistent volume 은 namespace에 국한되지 않는 전역 속성이기에 따로 namespace를 지정하지 않았다. replicas 설정은 로컬에서도 가볍게 돌아가도록 1로 설정했다.
위와 같이 변경했기 때문에 변환할 부분은 db , frontend(+nginx) , backend 3개이다. 파트 별로 변환과정을 작성하고 볼륨은 따로 빼서 작성할 예정이다.
# docker-compose.yml 중 db 부분
db:
build:
context: ./postgres
container_name: db
env_file:
- ./postgres/.env
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- network
우선 기존 db 부분이다. postgres 이미지를 그대로 가져왔기에 크게 환경변수와 볼륨만 신경쓰면 된다. 그래서 간단하게 작성했다.
먼저 configMap.yaml 부분이다.
# configMap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
labels:
app: db-env
name: db-env
namespace: app
data: # 환경 변수의 키와 값
POSTGRES_DB: "database"
POSTGRES_USER: "user"
POSTGRES_PASSWORD: "password"
kubernetes는 환경 변수를 deployment 에서 적용할 수 있다. 이때 .env 파일처럼 키-값의 데이터를 configMap 형식으로 저장할 수 있다. 그래서 우선 아래와 같이 configMap.yaml 파일을 만들었다.
그 후, deployment.yaml 는 아래와 같다.
# deployment.yaml 중 db 부분
apiVersion: apps/v1
kind: Deployment
metadata:
name: db
namespace: app # 이름공간을 설정하면 배포할 때 이름공간을 명시하지 않아도 자동으로 배포
spec:
replicas: 1
selector:
matchLabels:
app: db
template:
metadata:
labels:
app: db
spec:
volumes: # pod 레벨
- name: db-data
persistentVolumeClaim:
claimName: db-data
containers:
- env: # 환경변수 설정
- name: POSTGRES_DB
valueFrom:
configMapKeyRef:
key: POSTGRES_DB
name: db-env
- name: POSTGRES_USER
valueFrom:
configMapKeyRef:
key: POSTGRES_USER
name: db-env
- name: POSTGRES_PASSWORD
valueFrom:
configMapKeyRef:
key: POSTGRES_PASSWORD
name: db-env
image: postgres
name: db
volumeMounts: # container 레벨
- mountPath: /var/lib/postgresql/data
name: db-data
restartPolicy: Always
deployment 이름과 namespace를 각각 db와 app으로 설정하고 이 값을 식별하기 위해 app: db 레이블을 설정한다. 그리고 먼저 작성한 configMap 의 값을 사용하여 환경 변수를 설정한다.
service.yaml 은 다른 컨테이너와 통신할 수 있도록 postgres의 기본 port만 설정했다.
# service.yaml 중 db 부분
apiVersion: v1
kind: Service
metadata: # 서비스 자체 이름
name: db
namespace: app
spec:
selector: # 연결될 pod 식별
app: db
ports:
- port: 5432 # postgres default port
docker-compose.yml 의 backend 부분은 아래와 같다.
# docker-compose.yml 중 backend 부분
backend:
image: backend:transcendence
container_name: backend
build: ./backend
ports:
- '8000:8000'
networks:
- network
volumes:
- backend-media:/app/media
backend 역시 환경변수가 있다. 하지만 기존 프로젝트에서 환경변수를 docker build 시, 사용하기에 docker 이미지를 사용하는 이 프로젝트에서는 환경변수를 지정해도 의미가 없었다. 그렇기에 환경변수 중 url 관련 부분만 변경하여 docker image를 다시 build하고 이를 docker hub에 올려서 사용했다.
먼저 deployment.yaml 은 아래와 같다.
# deployment.yaml 중 backend 부분
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: app
spec:
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
volumes:
- name: back-data
persistentVolumeClaim:
claimName: back-data
containers:
- image: kmj951015/retro-pong-backend:1.0.1
name: backend
volumeMounts:
- mountPath: /app/media
name: back-data
ports:
- containerPort: 8000 # pprt 명시
protocol: TCP # 통신 방법 명시(기본이 TCP라 생략 가능)
restartPolicy: Always
살펴보면 전반적으로 db에서 사용한 설정들을 사용한 것을 볼 수 있다. 차이는 container 부분에 ports가 추가된 것뿐이다. 사실 db는 기본 포트가 정해져 있기에 설정을 생략해도 된다. 하지만 명시해주는 것이 함께 개발하는 개발자들이 쉽게 이해할 수 있기에 명시하는 것을 권장한다. 또한 backend 이미지의 경우 커스텀 이미지이기에 포트를 명시하는 것이 개발의 혼란을 막을 수 있다.
service.yaml 역시 다른 부분은 ports 부분 밖에 없다. 또한 다른 값도 생략이 가능하지만 커스텀 이미지를 사용하기에 명시적으로 표시했다.
# service.yaml 중 backend 부분
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: app
spec:
selector:
app: backend
ports:
- name: "8000" # 여러 포트를 구분하기 위해 사용하는 이름(선택 사항)
port: 8000 # 서비스가 클러스터 내부에 노출하는 포트
targetPort: 8000 # 실제 파드/컨테이너 포트(생략 시, port와 동일값 설정)
외부 요청에 따른 트래픽 흐름 예시
외부 요청 → Service (port: 8000) → Pod (targetPort: 8000) → Container (containerPort: 8000)
docker-compose.yml 중 frontend와 nginx 부분은 아래와 같다.
# docker-compose.yml 중 frontend와 nginx 부분
frontend:
image: frontend:transcendence
container_name: frontend
build: ./frontend
ports:
- '3000'
volumes:
- frontend-data:/app/dist
networks:
- network
nginx:
image: nginx:transcendence
container_name: nginx
build: ./nginx
ports:
- '443:443'
networks:
- network
volumes:
- frontend-data:/usr/share/nginx/html
- backend-media:/app/media
'전환을 시작하기 전에' 에서 이야기했듯이 frontend는 nginx와 합쳐서 전환을 진행했다. 컨테이너를 합친 부분은 생략한다. 또한 frontend 역시 환경변수를 build 과정에서 사용하기에 url 부분만 수정하여 docker image를 다시 build하여 사용하였다.
frontend를 job으로 만들지 않고 nginx와 합친 이유
기존 프로젝트에선 frontend가 실행되고 바로 종료되기에 이를
job으로 처리하는 시도도 했었다. 하지만 frontend 컨테이너에서 생성되는 dist 폴더를 로컬에 마운트 시키는 등의 방법을 도입해야 했는데 frontend와 nginx 간 강한 의존성과 추후 aws 배포 과정에서 구조를 더욱 복잡하게 만든는 것을 방지하기 위해 두 컨테이너를 합치게 되었다.
deployment.yaml 는 설정하는 필드가 backend와 동일하다.
# deployment.yaml 중 frontend 부분
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: app
spec:
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
volumes:
- name: back-data
persistentVolumeClaim:
claimName: back-data
containers:
- image: kmj951015/retro-pong-frontend:1.0.1
name: frontend
volumeMounts:
- mountPath: /app/media
name: back-data
ports:
- containerPort: 443
protocol: TCP
restartPolicy: Always
service.yaml 역시 backend와 거의 동일하고 ports의 type만 다르다.
# service.yaml 중 frontend 부분
apiVersion: v1
kind: Service
metadata:
name: frontend
namespace: app
spec:
selector:
app: frontend
ports:
- name: "443" # port를 식별하는 이름
port: 443 # 서비스가 클러스터 내부에 노출하는 포트
targetPort: 443 # 실제 파드/컨테이너의 포트
nodePort: 30443 # 노드의 포트
type: NodePort
기본적으로 외부에서 파드 내부 컨테이너에 직접 접근할 수 없기 때문에 port-forward를 해주거나 NodePort로 접근 가능한 포트를 열어주어야 한다. 나는 열리는 포트를 고정하기 위해 ports의 타입을 NodePort로 설정하고 기억하기 쉽도록 30443(NodePort는 30000~32767 범위 내의 값이어야 한다)으로 설정했다.
마지막으로 볼륨을 설정할 차례다. 쿠버네티스는 도커와 다르게 볼륨을 설정하기 위해 PersistentVolume(pv) 과 PersistentVolumeClaim(pvc) 그리고 StorageClass(sc) 을 기본적으로 사용한다. 간단하게 pv는 클러스터의 물리적 스토리지, sc는 pv를 동적으로 프로비저닝하기 위한 템플릿, pvc는 스토리지 요구사항을 정의한 것이다. 이 개념이 쉽게 와닿지 않기에 공식 문서와 여러 다른 글의 개념을 참고하면 좋다.
아래는 docker-compose.yml 에서 볼륨과 관련한 설정만 적은 것이다.
services:
db:
volumes:
- postgres-data:/var/lib/postgresql/data
backend:
volumes:
- backend-media:/app/media
frontend:
volumes:
- frontend-data:/app/dist
nginx:
volumes:
- frontend-data:/usr/share/nginx/html
- backend-media:/app/media
volumes:
frontend-data:
backend-media:
postgres-data:
postgres-data는 db만 사용하고 frontend-data는 frontend와 nginx가, backend-data는 backend와 nginx가 사용하는 것을 볼 수 있다. 이때 앞서 말했듯 frontend의 볼륨은 npm build의 결과물인 dist 폴더를 nginx에 옮기는 것이 주된 목적이기에 frontend와 nginx를 합치면서 볼륨의 필요성이 사라졌다. 따라서 쿠버네티스에서는 backend-data와 postgres-data만 정의한다.
먼저 persistentVolume.yaml 중 pv와 sc 정의 부분을 먼저 살펴보면 다음과 같다.
# persistentVolume.yaml 중 pv와 sc 부분
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: back-shared-storage
provisioner: kubernetes.io/no-provisioner # 동적 프로비저닝 안 함. 주로 로컬 볼륨에서 사용
volumeBindingMode: WaitForFirstConsumer # pvc가 파드에 의해 실행 될 때까지 바인딩 보류
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: back-pv
spec:
capacity:
storage: 2Gi
volumeMode: Filesystem
accessModes:
- ReadWriteMany # 여러 파드에서 동시에 읽기, 쓰기 가능
persistentVolumeReclaimPolicy: Retain # pvc가 삭제되어도 데이터 보존
storageClassName: back-shared-storage
local:
path: DIR/data/media
nodeAffinity: # 특정 노드만 사용 가능
required: # 조건을 만족하는 노드에서만 스케줄링 되도록 설정
nodeSelectorTerms: # 여러 조건을 Or 그룹으로 묶어놓은 것(즉 하나만 만족하면 스케줄링 됨)
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- docker-desktop
sc는 로컬에서 사용하기 위해 동적 프로비저닝을 안 하도록 만들었다. back-pv는 frontend와 backend가 모두 접근할 수 있어야 하므로 동시에 읽고 쓸 수 있는 ReadWriteMany 옵션을 설정했으며 DIR 경로는 Makefile에서 실행환경에 맞춰서 수정되도록 설정했다.
pvc는 아래와 같이 db-data와 back-data를 설정했다.
# persistentVolume.yaml 중 pvc 부분
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-data
namespace: app
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: back-data
namespace: app
spec:
accessModes:
- ReadWriteMany # backend and frontend
resources:
requests:
storage: 1Gi
storageClassName: back-shared-storage
db-data의 pv를 따로 설정하지 않은 것은 db만 접근하면 되기에 자동으로 생성되는 pv를 사용해도 되기 때문이다. 하지만 실제 배포 시, db 데이터 역시 백업이 필요하기에 그에 맞춰서 설정한 pv가 필요할 수도 있다.
back-data는 생성한 pv가 있기에 같은 sc를 사용하게 해서 back-pv를 사용하도록 만들었다. 또한 accessModes 역시 pv와 마찬가지로 여러 파드에서 접근할 수 있도록 설정했다. storageClassName을 설정하지 않으면 기본 StroageClass를 선택하며 쿠버네티스가 여러 옵션을 확인해서 알맞다고 판단되는 pv에 자동 할당시킨다.
용량은 돌아가는 것을 확인할 수 있을정도로 작게 설정했다. 실제는 동적으로 설정하거나 훨씬 큰 용량을 주는 것이 안전하다.
편하게 명령어를 사용하기 위해 Makefile을 사용했다.
PROJECT_DIR := $(PWD)
NAMESPACE = app
FLAG = -f
.PHONY: local create_namespacess clean_local fclean_local
local: create_namespace
kubectl apply $(FLAG) k8s/local/configMap.yaml
sed "s|DIR|$(PROJECT_DIR)|g" k8s/local/persistentVolume.yaml | kubectl apply $(FLAG) -
kubectl apply $(FLAG) k8s/local/deployment.yaml
kubectl apply $(FLAG) k8s/local/service.yaml
create_namespace:
@if ! kubectl get namespace $(NAMESPACE) > /dev/null 2>&1; then \
kubectl create namespace $(NAMESPACE); \
fi
clean_local:
kubectl delete $(FLAG) k8s/local/configMap.yaml
kubectl delete $(FLAG) k8s/local/deployment.yaml
kubectl delete $(FLAG) k8s/local/service.yaml
fclean_local: clean_local
kubectl delete $(FLAG) k8s/local/persistentVolume.yaml
kubectl delete namespace $(NAMESPACE)
# 프로젝트에서 필요한 초기 데이터
rm -rf data/media
cp -r media data/
배포 부분을 보면 DIR을 현재 경로로 바꾸는 것을 볼 수 있다. 또한 create_namespace 명령으로 app namespace를 먼저 생성하고 배포하도록 만들었다.

docker-desktop의 kubernets 설정을 키고 위 사진과 같이 make local 을 실행하면 된다. pod가 모두 Running 상태가 되면 https://127.0.0.1:30443 으로 접속할 수 있다. 이때 인증서 문제로 주의 페이지가 나오는데 무시하고 들어가면 아래와 같은 페이지를 볼 수 있다.

전반적으로 docker-compose 파일을 보고 kubernetes에 특성에 맞춰서 설정해주면 어렵지 않게 진행할 수 있지만 문제는 볼륨이었다. 볼륨을 사용하는 방식이 차이가 있기에 이를 적절하게 바꿔주는 것이 어려웠다. 특히 db 볼륨 같은 경우 로컬에 완전 마운트 된 것이 아니기에 완전하게 했다고 할 수는 없다고 생각한다. 웹 사이트가 동작하는 최소 목표만 달성했다.
또한 backend, frontend 모두 내가 참여한 것이 아니기 때문에 문제가 발생했을 때 어떤 파트에서 발생한 것인지, 찾지 못한 에러인지 내가 잘못 설정한 것인지 구분하는 것이 처음 접해보는 문제라 신선했다.
다음 글에서는 전환한 파일을 일부 수정하고 eks를 사용하여 aws에 배포하는 과정을 정리할 예정이다.