1: 실습 목표와 순서 (이미지 → compose → yaml)
간단한 실습을 진행하고자 했다. 일단 프로젝트 구조는 다음과 같았다.
.
└── work
└── docker
├── app
│ ├── Dockerfile
│ ├── app.py
│ ├── requirements.txt
│ └── templates
│ ├── dashboard.html
│ ├── login.html
│ └── register.html
└── db
├── Dockerfile
└── init.sql
나의 목표는
- docker-compose 파일 구성
- 빌드한 이미지를 구축해둔 kubernetes를 이용해 배포하기
를 진행하면서 이론으로 배운 사항들을 적용하는 실습해보기였다.
2: Dockerfile부터 다시 읽기
먼저 app과 db 폴더 안에 각각 위치한 Dockerfile 소스를 파악해보고자 했다.
- Dockerfile이 뭘 만들어낼지 미리 알아야 빌드가 실패했을 때 어디를 봐야 할 지 감이 옴
- 이 이미지가 어떤 환경변수를 기대하는지, 어떤 포트를 쓰는지 여기서 미리 파악해둬야 나중에
compose나k8s yaml에 그 값을 정확히 넣을 수 있음
2.1: 베이스 이미지 / 작업 디렉터리 / 실행 명령, 최소 세 가지
이미지를 생성하는 Dockerfile 에는 필수로 들어가야 하는 내용들이 있다. 예를 들면, 이 앱이 무슨 언어 또는 런타임에서 도는지, 뭘 설치해야 하는지 등등이다. 이 프로젝트의 app 폴더에 위치한 Dockerfile을 보면
# 베이스 이미지로 Python 3.8을 사용
FROM python:3.8
# 작업 디렉터리를 설정
WORKDIR /app
# 시스템 업데이트 및 필수 패키지 설치
RUN apt-get update && \
apt-get install -y \
build-essential \
default-libmysqlclient-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# 애플리케이션의 종속성 파일을 복사
COPY requirements.txt requirements.txt
# 종속성 설치
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션의 소스 코드 복사
COPY . .
# Flask 애플리케이션을 환경 변수로 설정
ENV FLASK_APP=app.py
# 컨테이너 실행할 때 Flask 애플리케이션을 시작
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]
build-essential,default-libmysqlclient-dev를apt로 설치하는 이유 : 이 앱이 나중에 연결될mysqlclient라는 파이썬 패키지를 사용하기 때문
\
2.2: requirements.txt 먼저 COPY하는 이유 (레이어 캐시)
requirements.txt만 먼저 복사하고pip install한 다음에 나머지를 복사하는 순서 → 그냥 한 번에COPY . .하고pip install해도 결과는 똑같을 텐데 왜?
도커의 대표적인 작동 습관 중 하나인데, Dockerfile 각 줄마다 레이어를 쌓으면서 캐시가 걸려있어서 위에서부터 어느 줄까지는 그대로 사용하고 바꾼 줄부터 새로 실행함. 만약에 COPY . . 를 먼저 실행하면 코드 한줄만 고쳐도 requirements.txt는 그대로인데도 그 COPY레이어가 통째로 바뀐걸로 인식 되고 pip install까지 다시 실행됨. 패키지 수십 개가 매번 재설치 되는 셈이라서, requirement.txt 만 먼저 복사해서 설치해두면 코드만 고쳤을 땐 그 위 레이어가 그대로 캐시됨. 그러면 pip install은 스킵되고 코드 복사만 다시 하게 됨. 빌드 속도가 꽤 크기 때문에!
2.3: mysql 공식 이미지의 docker-entrypoint-initdb.d
db쪽 dockerfile은 아래와 같이 구성되어있는데,
# Dockerfile
FROM mysql:5.7
# 환경 변수 설정
ENV MYSQL_ROOT_PASSWORD=
ENV MYSQL_DATABASE=
ENV MYSQL_USER=
ENV MYSQL_PASSWORD=
# 초기화 스크립트 복사
COPY init.sql /docker-entrypoint-initdb.d/
mysql:5.7 베이스에 ENV로 root 비밀번호, 데이터베이스 이름, 유저, 비밀번호를 박아두고, init.sql을 /docker-entrypoint-initdb.d/라는 특정 경로에 복사하고 있음
mysql 공식 이미지의 entrypoint 스크립트는 컨테이너가 처음 켜질 때(데이터 디렉터리가 비어있을 때만) 두 가지 일을 순서대로 함. 먼저 ENV로 받은 MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, MYSQL_USER 값으로 초기 계정과 데이터베이스를 만들고, 그다음에 /docker-entrypoint-initdb.d/ 폴더 안에 있는 .sql 파일들을 자동으로 순서대로 실행하는 거임. init.sql은 계정 만든 다음 단계, 테이블이나 초기 데이터를 넣는 역할이라고 보면 됨. 데이터 디렉터리가 비어있을 때 딱 한 번만 실행되는 거라서 나중에 컨테이너를 지웠다 다시 만들어도 볼륨에 데이터가 남아있으면 init.sql이 다시 안 돌아감 .
3: 로컬에서 먼저 검증하기: docker compose
- compose로 먼저 로컬에서 app이랑 db를 연결해보면 좋은 이유: app이 db에 접속할 때 필요한 정보들, 예를 들어 db 호스트 이름이 뭔지, 포트가 몇 번인지, 유저/비밀번호가 뭔지 이런 걸
app.py나requirements.txt어딘가에서 환경변수로 읽어오게 되어 있을 텐데 이게 실제로 맞게 연결되는지 compose에서 먼저 확인하면 문제가 생겼을 때 원인이 단순해짐 →app문제인지db문제인지만 보면 됨 - 확인을 안 하고 바로 쿠버네티스로 가면 나중에 파드가 안 뜨거나 연결이 안 될 때 원인이 yaml 설정 문제인지 애초에 앱-db 연결 정보 자체가 틀린 건지 구분하기가 더 어려워져서 되도록 먼저 확인하고 가는게 좋음
\
3.1: 서비스 이름이 곧 내부 DNS라는 것 (app.py가 하드코딩한 db-svc)
compose를 짜려면 먼저 app.py 안에서 MySQL(db)에 접속하는 코드 먼저 확인 해야함. 호스트 이름, 포트, 유저, 비밀번호, 데이터베이스 이름을 어디서 가져오는지 먼저 보는 거임. 하드코딩인지 환경변수로 받아오게 되어있는지부터 확인.
# MySQL 설정
app.config['MYSQL_HOST'] = 'db-svc'
app.config['MYSQL_USER'] =
app.config['MYSQL_PASSWORD'] =
app.config['MYSQL_DB'] =
이렇게 되어있었음. 그러니까 app.py 설정에 따라서 app 단에서는 무조건 db-svc 라고 하드코딩된 이름을 찾아가게 되어있음.
- docker compose는 같은 네트워크 안에 있는 서비스들끼리 서로를 이름으로 찾을 수 있게 해주는데 (e.g. 내부DNS) , 그래서 이 상황에서는
compose파일 안에 db 서비스를반드시 이 하드코딩 된 이름으로 연결해주어야 하는 것.k8s yaml에서Service이름 정할 때도 같은 원리!
3.2: 포트 매핑 이해하기 (컨테이너 내부 포트 vs 호스트 포트)
- 컨테이너 내부에서 앱이
5000포트를 쓰는 건 코드에 고정이 되어있음. 그건Dockerfile에서CMD가 정해놓은 값. 근데 이 컨테이너의5000번을 호스트 (내 로컬 컴퓨터)의 어느 포트에 연결할 지는 별개의 선택 →docker-compose의 포트 기입 때 헷갈렸던 점 - db도 마찬가지이긴 한데,
app이db에 접속할 때는 호스트 안 거치고compose가 만들어준 내부 네트워크로 바로db-svc:3306에 붙음. → 내가 직접MySQL클라이언트로db상태를 들여다보고 싶을 때만 별도의 호스트 포트를 지정해주면 됨. 그래서 포트 매핑 안 해도compose안에서는 잘 돌아감.
4: 쿠버네티스 언어로 번역하기
\
4.1: Pod 대신 Deployment를 쓰는 이유
pod 자체를 직접 만들 수도 있긴 한데 이 pod가 어떤 이유로 (메모리 초과나 노드 문제 등) 죽어버리면 이걸 관리해줄 수 있는 도구가 없음. 그래서 replicas라는 개수를 설정하는 오브젝트인 deployment를 사용할 거임. 여기서는 간단한 구조라 app 레플리카는 2개로 설정함. db 레플리카는 당연히 데이터니까 1개!
deployment기본 뼈대
apiVersion: apps/v1
kind: Deployment
metadata:
name: (이름)
spec:
replicas: (개수)
selector:
matchLabels:
app: (라벨값)
template:
metadata:
labels:
app: (라벨값, 위 selector랑 반드시 똑같아야 함)
spec:
containers:
- name: (컨테이너 이름)
image: (이미지 이름:태그)
ports:
- containerPort: (포트)
\
4.2: Service selector와 Pod label는 틀리지 않게 조심
containerPort는 이 컨테이너 안에서 앱이 실제로 어느 포트를 쓰고 있는지 알려주는 값. 이 값을 바꾼다고 해서 앱이 실제로 그 포트로 옮겨가는 게 아님. 앱은 여전히 Dockerfile의 CMD가 정한 대로 5000번에서만 듣고 있음. 여기 적는 숫자는 진짜 사실을 그대로 적어주는 자리지 아까 compose에서 했던 호스트포트:컨테이너포트처럼 다른 숫자로 바꿔치기(리매핑)하는 자리가 아님. 여기 6000 같은 엉뚱한 숫자를 적으면 Service의 targetPort가 실제 5000이 아닌 엉뚱한 곳을 가리키게 돼서 연결이 끊김.
\
4.3: ClusterIP / NodePort / LoadBalancer 용도
-
ClusterIP: 기본. 내부 통신.
-
NodePort: 외부통신.
- NodePort만 쓰면 접속 주소가 어느 노드의 IP:포트번호가됨. 노드가 여러 대면 그중 아무 노드 IP나 알아야 접속할 수 있음. 그 노드가 죽으면 다른 노드IP로 직접 바꿔서 접속해야 함.
- 지정하지 않으면 30000-32767 사이 숫자로 랜덤 배정.
- 개발 중 빠르게 확인하고 싶을 때, 또는 로드밸런서를 만들어줄 도구(클라우드나 MetalLB)가 없는 환경에서 사용하는 임시방편에 가까움
-
LoadBalancer: 외부통신.
- Nodeport 위에 한 겹 더 얹은 구조. LB 타입으로 만들면 쿠버네티스가 내부적으로 NodePort도 같이 자동으로 하나 열고, 그 NodePort로 트래픽 보내주는 실제 로드밸런서(여기서는 MetalLB)를 앞에 세워주는 것
- 실제 사용자가 접속하는 서비스 앞단, 운영환경에서 쓰는 정식 방법
\
5: apply 이후 상태 읽기
위 명령어로 apply 이후 확인하기
5.1: kubectl get pods / svc / endpoints 순서
kubectl get pods
여기서 app이랑 db 파드 둘 다 Running으로 뜨는지 봐야함. 혹시 CrashLoopBackOff나 Pending으로 떠 있으면 다시 확인.
kubectl get svc
여기서 app-svc의 EXTERNAL-IP 칸에 MetalLB가 준 실제 IP가 찍히는지 확인
kubectl get endpoints
app-svc랑 db-svc 둘 다 IP:포트 값이 채워져 있어야 함. 만약 여기가 텅 비어 있으면, 파드는 떠 있는데 Service가 못 찾고 있는 거라서 라벨이나 셀렉터를 다시 봐야 함
- EXTERNAL-IP는 이 Service 자체의 주소. 외부에서 이 문(Service)을 두드릴 때 쓰는 주소. MetalLB가 정상적으로 IP를 할당해줬다는 뜻
- ENDPOINTS는 완전히 다른 걸 보는데, 그 문 뒤에 실제로 응답할 준비가 된 Pod들이 누구누구인지, 그 Pod들의 실제 IP:포트 목록임. 문패는 잘 붙어 있는데 문 뒤에 아무도 없는 상태라서 실습 할 때는
<none>이 뜨는 상황이었고 이 상태면 밖에서192.168.56.200:5000으로 요청을 보내도 받아줄 사람이 없어서 연결이 안 되는 상황인데, 아직 파드가 만들어지는 상황이었음 (VM이라 느린 것으로 추정)
→ 실시간으로 지켜보면 천천히 만들어짐
kubectl get pods -w
6: 확인
\
6.1: 새로고침마다 바뀌는 Server Name, Service의 로드밸런싱
\
6.2: Deployment의 자기 치유
pod를 의도적으로 kill 해도 새로운 파드가 생성됨. 그동안 남은 하나의 파드가 실행되면서 새로고침도 정상 실행(서버 네임은 남은 하나 파드로 고정)
7. 회고
다 알고 있다고 생각한 내용이었는데 소스코드에서 이미지 빌드부터 배포까지 쭉 완성하려니 헷갈리는 부분들이 있어서 좀 당황했다. 구성하는 연습을 많이 해봐야겠구나…

