여러 개의 컨테이너들로 구성된 서비스가 있다고 가정하자. 이런 구성은 마이크로서비스 아키텍처에서 아주 일반적인 구성이다.
보통 컨테이너는 이미지의 크기를 최소화하여 기동 시간을 아주 짧게 만들 필요가 있고, 실제로도 그렇게 만들려는 경향이 있다. 컨테이너 이미지가 너무 커지면 기동이 느려지고 요청에 대한 응답을 처리할 수 있을 때까지 시간이 상당히 많이 걸리므로 다른 컨테이너가 접근하려다가 실패하는 경우가 발생할 수 있다.
이런 실패 사례의 대표적인 경우가 DB를 컨테이너로 만들 때 생길 수 있다. 이미 만들어져 있는 DB라면 실행되자마자 정상 동작하겠지만, 컨테이너로 만드는 경우에는 보통 초기화 스크립트를 실행하는 작업에 시간이 많이 소요될 수 있다. 이럴 때 backend 컨테이너에서 DB에 접근을 시도하면 접속 오류가 발생하면서 실패하게 된다.
DB 기동이 완료된 후에 접근을 시도하면 정상적으로 접속되므로 일정 시간 대기하는 방법도 있다. 그러나 대기 시간이 얼마나 오래 걸릴지 예측하기도 어렵고 이걸 docker 명령이나 docker-compose 설정, K8s 설정으로 적용하려면 어렵다. 일정 시간만큼 대기하는 방법은 바람직하지 않으며 컨테이너 사이의 의존성을 체크하는 방법을 이용하는 게 바람직하다.
backend도 기동 시에 부하가 큰 작업을 수행하거나, Spring 애플리케이션인 경우, 시작하자마자 서비스를 제대로 수행할 수 없기 때문에 frontend 입장에서는 요청을 보내지 않고 대기해야 한다.
Dockerfile에 HEALTHCHECK라는 명령이 있어서 이걸 활용하면 된다.
HEALTHCHECK --interval=1s --timeout=5s CMD nc -z 다른_컨테이너_이름 포트번호 || exit 1
CMD ["uvicorn", "main:app", ...옵션리스트...]
1초마다 nc를 이용해서 다른 컨테이너의 특정 포트로 접속시도를 반복하다가 성공하면 CMD uvicorn 명령을 실행하는 구조로 되어 있다. nc 대신에 ping이나 curl을 사용하는 것도 가능하다. 보통 컨테이너 이미지를 작게 유지하기 위해서 이런 명령들이 기본 설치되어 있지 않을 가능성이 높으므로 HEALTHCHECK 전에 명령이 포함된 패키지를 설치해주는 게 필요하다.
RUN apt-get update && apt-get install -y curl
다만, nc 명령을 이용한 방식은 MySQL과 같은 DB에는 적합하지가 않은데, DB의 경우에는 포트가 빨리 열리고 실제 쿼리 수행을 정상적으로 할 수 있을 때까지는 좀 더 기다려야 할 수 있기 때문이다. 다음과 같이 mysql 클라이언트를 이용하여 "SELECT 1" 쿼리를 서버에 던져서 정상 수행하는지 확인하는 방식을 이용하자.
HEALTHCHECK --interval=5s --timeout=5s CMD mysql -h 다른_컨테이너_이름 -u DB계정 -p패스워드 -e "SELECT 1" || exit 1
CMD ["uvicorn", "main:app", ...옵션리스트...]
마찬가지로 미리 mysql-client 패키지를 설치해야 한다.
RUN apt-get update && apt-get install -y default-mysql-client
https://docs.docker.com/engine/reference/builder/#healthcheck
Dockerfile의 HEALTHCHECK와 1:1로 대응되는 healthcheck 구문이 있다.
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pyour_password"]
interval: 10s
timeout: 5s
retries: 3
명령행을 여러 토큰으로 나누지 않고 한꺼번에 작성하려면 CMD 대신 CMD-SHELL을 이용하면 된다.
test: ["CMD-SHELL", "curl -f http://localhost || exit 1"]
docker-compose는 서비스 사이에 의존성을 지정할 수도 있다. depends_on 기능을 사용하면 되는데, 다음 예제를 살펴보자.
services:
web:
build: .
depends_on:
- db
- redis
redis:
image: redis
db:
image: postgres
이 예제에서 web 서비스는 redis 서비스와 db 서비스에 의존하기 때문에 두 서비스가 먼저 실행된 후에 web 서비스가 실행된다.
https://docs.docker.com/compose/compose-file/compose-file-v2/#healthcheck
https://docs.docker.com/compose/compose-file/05-services/#depends_on
컨테이너가 서비스 요청을 받을 준비가 되었는지 탐침할 수 있도록 외부에 제공하는 기능이 있는데, 이게 readinessProbe이다.
spec:
containers:
- name: goproxy
image: registry.k8s.io/goproxy:0.1
ports:
- containerPort: 8080
readinessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
liveness(생존상태)와 readiness(준비상태)를 탐침해보는 기능이 있는데, 서비스가 정상 동작하는 것은 liveness보다는 readiness에 가깝고 보통 두 가지를 같이 사용하는 것을 권장한다. startupProbe도 있는데, 실제 서비스가 정상 동작하려면 readinessProbe를 마련하는 게 더 중요하다.
readinessProbe 하위의 주요 기능으로 httpGet, tcpSocket, exec 등의 탐침 수단이 외부에 제공된다. 이름에서 유추할 수 있는 것처럼 HTTP 요청을 해보는 방식, TCP로 특정 IP와 포트에 접속해보는 방식, 명령을 실행시키는 방식이 있다. 컨테이너 자기 자신의 체크 방법을 마련해두고 외부에 제공하는 것임에 유의해야 한다. kubelet이 클라이언트가 되어 탐침을 하게 된다.
readinessProbe:
httpGet:
path: /healthz
port: 80
initialDelaySeconds: 15
periodSeconds: 10
서비스에서 /healthz 엔드포인트를 노출시켜서 외부에서 이 엔드포인트를 이용해 서비스 준비 상태를 확인할 수 있도록 제공해야 한다. 이 엔드포인트에서 200 OK를 반환하면 준비 상태로 간주된다. (정확히는 200 이상, 400 미만의 코드로, 일정 시간 내에 반환해야 성공으로 인정)
readinessProbe:
tcpSocket:
host: example.com
port: 80
initialDelaySeconds: 15
periodSeconds: 10
마찬가지로 TCP 소켓을 열어서 특정 IP와 포트번호에 대해 연결이 정상적으로 수행되면 준비 상태로 간주한다.
readinessProbe:
exec:
command:
- sh
- -c
- mysqladmin ping -h mysql-container -u root -p'your_password'
initialDelaySeconds: 15
periodSeconds: 10
HEALTHCHECK처럼 명령을 실행해서 그 결과가 정상인지 확인하는 방법도 있는데, exec의 command를 이용하면 된다.
단순히 query를 받아서 수행할 준비가 되었는지를 확인하는 수준이라면 mysqladmin ping 명령을 이용할 수도 있고,
command:
- sh
- -c
- mysql -h mysql-container -u root -p'your_password' -e 'SELECT 1;'
를 대신 사용해도 된다.
readinessProbe를 선언했다면 클라이언트가 되는 컨테이너 입장에서 의존성에 따른 순서를 지정해서 pod가 병렬 실행되지 않고 순차 실행되도록 지정할 수 있다. initContainers 기능을 이용하면 된다.
spec:
containers:
- name: myapp-container
image: busybox:1.28
command: ['sh', '-c', 'echo The app is running! && sleep 3600']
initContainers:
- name: init-myservice
image: busybox:1.28
command: ['sh', '-c', "until nslookup myservice.$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace).svc.cluster.local; do echo waiting for myservice; sleep 2; done"]
- name: init-mydb
image: busybox:1.28
command: ['sh', '-c', "until nslookup mydb.$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace).svc.cluster.local; do echo waiting for mydb; sleep 2; done"]
initContainers는 메인 서비스 컨테이너가 실행되기 전에 먼저 실행되어야 할 작업을 정의하는 것이다. 이 기능을 이용하면 메인 서비스가 실행되기 전에 shell 명령을 이용해서 준비될 때까지 대기하게 만들 수 있다.
예제를 보면, busybox 이미지(busybox란 최소한의 명령으로 구성된 도구 세트)의 shell 명령을 이용해서 먼저 실행되어야 할 서비스들이 DNS 조회가 될 때까지 대기하도록 하고 있다. 이러면 DNS 조회가 성공한 후에 initContainers로 지정된 컨테이너들이 종료하고 메인 서비스 컨테이너가 실행된다.
nslookup myservice.도메인
nslookup mydb.도메인
이 명령들을 각각 컨테이너에서 실행해보고 정상적인 응답을 받을 때까지 무한 대기하는 방식이다. 위의 예제는 initContainers에 두 개의 컨테이너가 선언되었지만 실제로는 하나의 컨테이너에서 두 개의 nslookup을 수행하는 방법도 가능하다.
그리고 nslookup 대신 앞에서 봤던 nc나 ping, curl 등의 다른 도구를 이용하는 것도 가능하다.
https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
https://kubernetes.io/docs/concepts/workloads/pods/init-containers/
위에서 소개한 docker, docker-compose, k8s 환경에서의 서비스 준비 상태 확인 방법 중 한 가지를 선택해서 적용할 필요가 있다. 여기에 서비스 로직 차원의 안정성 보완 방법을 제시하자면, 외부 의존성에 대한 접속이 실패한 경우 일정 횟수만큼 자동으로 재시도하는 로직을 추가하는 것이다. 예외 처리를 통해 몇 번 재시도함으로써 서비스 안정성을 높일 수 있다.
Python의 MySQL 접속하는 코드를 재시도 로직을 추가하면 다음과 같다. while loop와 try ... except를 조합하여 최대 재시도 횟수까지 재시도를 하는 방식이다. DB에 의존하는 백엔드 서비스라면 이런 안전장치를 두는 것이 바람직하다.
import pymysql
import time
def connect_to_mysql(host, user, password, database, max_retries=3, retry_delay=5):
retry_count = 0
is_connected = False
while retry_count < max_retries:
try:
# MySQL에 연결
connection = pymysql.connect(host=host, user=user, password=password, database=database)
# 연결이 성공한 경우
print("MySQL에 성공적으로 연결되었습니다.")
is_connected = True
return connection
except pymysql.Error as e:
print(f"MySQL 연결 오류: {e}")
retry_count += 1
if retry_count < max_retries:
time.sleep(retry_delay)
else:
print(f"최대 재시도 횟수({max_retries})를 초과했습니다. 연결 실패.")
break
Java도 비슷하게 작성하면 된다.
Spring 애플리케이션의 경우 Retry 기능을 활용하면 된다. @Retryable 애노테이션을 사용하자.
커넥션 풀을 이용하는 경우에는 커넥션 풀이 리소스 관리를 위해 자동으로 재접속 시도를 하기 때문에 굳이 응용 로직으로 작성할 필요가 없다. 정교한 재접속 기능을 가지고 있는 건 아니지만 외부 의존성(DB나 다른 API 서버)에 연결할 때 가급적 커넥션 풀을 이용하도록 하자.