C++ 서버 배포: Docker 멀티스테이지, systemd, Kubernetes 무중단 배포, Prometheus 모니터링
C++ 서버는 로컬에서 잘 돌다가도 서버에 올리면 다른 이유로 죽습니다. 빌드 머신과 서버의 glibc 버전이 달라 아예 실행되지 않거나, 컨테이너 메모리 한도를 넘어 OOM Killer에 종료되거나, 죽은 프로세스를 아무도 다시 띄우지 않거나, 준비되지 않은 Pod로 트래픽이 들어가 배포 때마다 502가 나는 식입니다. 이 글은 Docker 멀티스테이지 빌드, systemd 서비스 등록, Kubernetes 배포와 무중단 업데이트, GitHub Actions 파이프라인, Prometheus 메트릭과 spdlog 로깅까지 배포 흐름을 순서대로 따라가면서, 각 단계에서 이런 문제가 어떻게 생기고 어떻게 막는지 설명합니다. 예제를 따라 하려면 Docker와 Kubernetes 클러스터(minikube 등), GitHub 저장소가 필요합니다.
배포에서 자주 만나는 문제
가장 먼저 부딪히는 것은 glibc 불일치입니다. Ubuntu 22.04에서 빌드한 바이너리를 CentOS 7(glibc 2.17)이나 Ubuntu 20.04(glibc 2.31) 서버에 복사해 실행하면 version 'GLIBC_2.34' not found 에러가 납니다. 링커가 빌드 당시 glibc의 가장 새 심볼 버전(예: GLIBC_2.34의 __libc_start_main)을 바이너리에 기록하기 때문입니다. 코드에서 새 함수를 쓰지 않았더라도 빌드 머신의 glibc가 새것이면 새 버전 태그가 붙습니다. 흔히 -static-libgcc -static-libstdc++를 해결책으로 드는데, 이 옵션은 libgcc와 libstdc++만 정적으로 넣을 뿐 glibc는 여전히 동적으로 링크하므로 GLIBC_x.xx not found는 그대로 남습니다(반대로 GLIBCXX_3.4.30 not found 같은 libstdc++ 버전 에러에는 효과가 있습니다). 바이너리가 요구하는 버전은 objdump -T myapp | grep GLIBC_ | sort -u로 배포 전에 확인할 수 있고, 근본적인 해결은 빌드 이미지와 런타임 이미지의 베이스를 맞추거나 배포 대상 중 가장 오래된 배포판에서 빌드하는 것입니다.
Kubernetes에서는 Pod가 주기적으로 OOMKilled 상태로 재시작되는 문제가 흔합니다. 메모리 limit이 실제 사용량보다 낮거나 누수가 있는 경우이며, 아래 트러블슈팅 절에서 둘을 구분하는 방법을 다룹니다. 새 버전을 배포할 때마다 몇 초씩 502가 나는 것은 대개 readinessProbe가 없어 아직 준비되지 않은 Pod에 트래픽이 들어가거나, 종료 중인 Pod가 엔드포인트에서 빠지기 전에 연결을 닫기 때문입니다.
운영 절차에서 생기는 문제도 있습니다. 장애 원인을 찾으려는데 컨테이너가 재시작되면서 로그가 사라졌다면 중앙 로그 수집이 없는 것이고, scp로 바이너리를 복사하고 systemctl restart를 하다가 엉뚱한 버전을 올렸다면 배포가 자동화되어 있지 않고 버전 추적이 안 되는 것입니다. 재부팅 후 앱이 자동으로 뜨지 않는 것은 systemd 유닛을 enable하지 않은 경우입니다.
Multi-stage Dockerfile로 컨테이너화
배포 아키텍처 개요
flowchart TB
subgraph Build[빌드 단계]
S1[소스 코드] --> S2[CMake 빌드]
S2 --> S3[바이너리 생성]
end
subgraph Docker[Docker 이미지]
S3 --> D1[Multi-stage 빌드]
D1 --> D2[런타임 이미지]
D2 --> D3[레지스트리 푸시]
end
subgraph Deploy[배포 옵션]
D3 --> K8s[Kubernetes]
D3 --> VM[VM + systemd]
end
Multi-stage Dockerfile
빌드 도구와 런타임을 분리하면 컴파일러, 헤더, CMake 같은 빌드 도구가 최종 이미지에 들어가지 않습니다. 런타임 스테이지는 --no-install-recommends로 꼭 필요한 패키지만 설치하고, root가 아닌 사용자로 실행합니다.
# ========== 빌드 스테이지 ==========
FROM ubuntu:22.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
build-essential \
cmake \
libboost-all-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
# Release 빌드로 성능 최적화
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && \
cmake --build build --parallel $(nproc)
# ========== 런타임 스테이지 ==========
FROM ubuntu:22.04
# 보안: root가 아닌 사용자로 실행
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
RUN apt-get update && apt-get install -y --no-install-recommends \
libboost-system1.74.0 \
libboost-thread1.74.0 \
libssl3 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/build/myapp /app/myapp
# 권한 설정
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080
# 헬스체크 (선택)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
CMD ["./myapp"]
런타임 스테이지에서 가장 자주 틀리는 부분은 공유 라이브러리 누락입니다. 빌드 스테이지에서는 libboost-all-dev가 모든 .so를 깔아 주지만, 런타임 스테이지에는 직접 나열한 패키지만 있습니다. 그래서 빌드는 성공했는데 컨테이너를 띄우면 error while loading shared libraries: libboost_filesystem.so.1.74.0: cannot open shared object file 같은 에러로 즉시 종료됩니다. 새 의존성을 추가할 때마다 런타임 이미지 안에서 ldd /app/myapp | grep "not found"를 한 번 돌려 보면 이런 누락을 배포 전에 잡을 수 있습니다. 위 예시의 libboost-system1.74.0 같은 패키지 이름은 Ubuntu 22.04 기준이라, 베이스 이미지를 올리면 버전 접미사도 함께 바뀐다는 점도 기억해야 합니다.
두 가지를 더 짚어 둡니다. 첫째, CMD는 JSON 배열(exec form)로 쓸 때 문자열마다 큰따옴표가 필요합니다. CMD [./myapp]처럼 따옴표를 빼면 Docker가 JSON으로 해석하지 못해 쉘 형식으로 취급하고, 결과적으로 /bin/sh -c "[./myapp]"이 실행되어 컨테이너가 바로 죽습니다. exec form을 써야 myapp이 PID 1이 되어 SIGTERM을 직접 받습니다. 둘째, HEALTHCHECK가 curl을 호출하므로 런타임 이미지에 curl을 설치해야 합니다. 설치하지 않으면 헬스체크가 계속 실패해 컨테이너가 unhealthy로 표시됩니다. 또한 Kubernetes는 Dockerfile의 HEALTHCHECK를 무시하고 Pod의 probe만 사용하므로, 이 설정은 docker run·Docker Compose 환경에서만 의미가 있습니다. 빌드 컨텍스트에 .git이나 로컬 빌드 산출물이 섞여 들어가지 않도록 .dockerignore도 함께 두는 편이 좋습니다.
Docker Compose: 로컬 프로덕션 시뮬레이션
로컬에서 여러 서비스(앱, Prometheus, Grafana)를 함께 띄워 테스트합니다. Docker Compose v2는 최상위 version: 키를 쓰지 않으므로(남겨 두면 무시하고 경고만 출력합니다) 아래 파일에서는 뺐습니다. Compose 네트워크 안에서는 서비스 이름(myapp)이 곧 호스트명이라서, 아래 Prometheus 설정의 타깃도 localhost가 아니라 myapp:9090으로 적습니다. 여기서 localhost를 쓰면 Prometheus 컨테이너 자기 자신을 가리키게 되어 타깃이 계속 DOWN으로 보입니다.
# docker-compose.yml
services:
myapp:
build: .
ports:
- "8080:8080"
environment:
- LOG_LEVEL=info
- METRICS_PORT=9090
volumes:
- ./logs:/app/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
ports:
- "9091:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
restart: unless-stopped
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana-data:/var/lib/grafana
depends_on:
- prometheus
restart: unless-stopped
volumes:
grafana-data:
Prometheus 설정 예시
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'myapp'
static_configs:
- targets: ['myapp:9090']
metrics_path: /metrics
이미지 빌드 및 푸시
# 이미지 빌드 (캐시 활용)
docker build -t myregistry/myapp:v1.0.0 .
# 이미지 크기 확인
docker images myregistry/myapp:v1.0.0
# 레지스트리 푸시
docker push myregistry/myapp:v1.0.0
systemd 전통 배포
Kubernetes를 사용하지 않는 VM 또는 베어메탈 서버에서는 systemd로 서비스를 등록합니다. 재부팅 후 자동 시작, 실패 시 재시작, journald 로그 통합을 한 번에 얻을 수 있습니다.
systemd Unit 파일
# /etc/systemd/system/myapp.service
[Unit]
Description=C++ MyApp Production Service
Documentation=https://github.com/yourorg/myapp
After=network-online.target
Wants=network-online.target
# 300초 안에 5번 넘게 재시작하면 포기 (systemd 230+에서는 [Unit]에 둠)
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=appuser
Group=appgroup
# 실행 경로
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/myapp
# 환경 변수 (민감 정보는 EnvironmentFile 사용)
Environment="LOG_LEVEL=info"
Environment="METRICS_PORT=9090"
# 재시작 정책
Restart=on-failure
RestartSec=5s
# 보안 강화
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp/logs /var/log/myapp
# Graceful Shutdown (SIGTERM 후 최대 30초 대기)
TimeoutStopSec=30
KillMode=mixed
# 로그
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
[Install]
WantedBy=multi-user.target
몇 줄은 이유를 알고 써야 합니다. After=network.target은 “네트워크 관리 서비스가 시작됐다” 정도의 의미라서, 서비스 시작 시점에 DB 호스트 이름 해석이나 원격 연결이 필요한 앱은 network-online.target을 Wants와 After 둘 다에 걸어야 부팅 직후 연결 실패를 피할 수 있습니다. Restart=on-failure는 비정상 종료(0이 아닌 종료 코드, 시그널에 의한 종료, 타임아웃)일 때만 다시 띄우고, 관리자가 systemctl stop으로 멈춘 경우에는 재시작하지 않습니다. 시작 직후 설정 오류로 바로 죽는 앱은 StartLimitBurst 한도를 금방 채워 start request repeated too quickly 상태가 되는데, 이때는 원인을 고친 뒤 systemctl reset-failed myapp으로 카운터를 비워야 다시 시작할 수 있습니다.
ProtectSystem=strict는 파일시스템 전체를 읽기 전용으로 마운트하므로, 로그나 임시 파일을 쓰는 경로를 ReadWritePaths에 빠짐없이 적어야 합니다. 이 목록에서 한 경로라도 빠지면 앱이 Read-only file system(EROFS) 에러를 내는데, 로컬에서는 재현되지 않아 처음 보면 원인을 찾기 어렵습니다. KillMode=mixed는 SIGTERM을 메인 프로세스에만 보내고, TimeoutStopSec이 지나면 남은 프로세스까지 SIGKILL로 정리합니다. 앱이 SIGTERM을 받아 30초 안에 스스로 종료하도록 구현해야 이 설정이 Graceful Shutdown으로 동작합니다.
systemd 등록 및 관리
# Unit 파일 복사
sudo cp myapp.service /etc/systemd/system/
# systemd 리로드
sudo systemctl daemon-reload
# 서비스 활성화 (부팅 시 자동 시작)
sudo systemctl enable myapp
# 서비스 시작
sudo systemctl start myapp
# 상태 확인
sudo systemctl status myapp
# 로그 확인 (최근 100줄부터 따라가기)
journalctl -u myapp -n 100 -f
# 재시작
sudo systemctl restart myapp
배포 스크립트 예시
#!/bin/bash
# deploy.sh - systemd 환경 배포
set -e
APP_NAME=myapp
INSTALL_DIR=/opt/myapp
SERVICE_USER=appuser
SERVICE_GROUP=appgroup
echo "=== 배포 시작 ==="
# 1. 새 바이너리 복사 + 이전 버전 백업
sudo cp ./build/myapp $INSTALL_DIR/myapp.new
sudo chown $SERVICE_USER:$SERVICE_GROUP $INSTALL_DIR/myapp.new
sudo chmod +x $INSTALL_DIR/myapp.new
sudo cp -p $INSTALL_DIR/myapp $INSTALL_DIR/myapp.prev
# 2. 교체 (같은 파일시스템 안의 mv는 원자적)
sudo mv $INSTALL_DIR/myapp.new $INSTALL_DIR/myapp
# 3. 서비스 재시작
sudo systemctl restart $APP_NAME
# 4. 헬스 체크
sleep 5
if curl -sf http://localhost:8080/health > /dev/null; then
echo "=== 배포 성공 ==="
else
echo "=== 배포 실패: 이전 버전으로 롤백 ==="
sudo mv $INSTALL_DIR/myapp.prev $INSTALL_DIR/myapp
sudo systemctl restart $APP_NAME
exit 1
fi
새 바이너리를 myapp.new로 먼저 복사한 뒤 mv로 바꾸는 이유가 있습니다. 실행 중인 바이너리 위에 cp로 직접 덮어쓰면 Text file busy 에러가 납니다. 반면 mv는 디렉터리 엔트리만 새 inode로 바꾸므로, 기존 프로세스는 옛 inode를 계속 쓰고 재시작한 프로세스만 새 파일을 엽니다. 롤백도 같은 원리로, 헬스 체크에 실패하면 백업해 둔 myapp.prev를 되돌린 뒤 다시 시작해야 실제로 이전 버전으로 돌아갑니다. 새 바이너리를 그대로 두고 restart만 다시 하는 것은 롤백이 아니라 같은 실패를 한 번 더 반복하는 것뿐입니다.
Deployment·Service·Secret으로 Kubernetes 배포
Deployment 설정
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-deployment
labels:
app: myapp
spec:
replicas: 3
selector:
matchLabels:
app: myapp
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myregistry/myapp:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
- name: metrics
containerPort: 9090
env:
- name: LOG_LEVEL
value: "info"
- name: METRICS_PORT
value: "9090"
resources:
requests:
memory: "128Mi"
cpu: "250m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
livenessProbe는 실패하면 컨테이너를 재시작하고, readinessProbe는 실패하면 Pod를 Service 엔드포인트에서 빼기만 합니다. preStop의 sleep 5는 종료되는 Pod가 엔드포인트 목록에서 빠지는 것이 kube-proxy와 Ingress에 반영될 시간을 벌어 주는 장치이고, resources는 스케줄러가 노드를 고르는 기준(requests)과 컨테이너가 넘을 수 없는 한도(limits)를 정합니다.
/health와 /ready를 나눈 데는 이유가 있습니다. 두 probe가 같은 엔드포인트를 보고 그 엔드포인트가 DB 연결까지 검사하면, DB가 잠깐 느려지는 순간 livenessProbe도 함께 실패해 모든 Pod가 동시에 재시작됩니다. 재시작한 Pod들은 한꺼번에 DB 연결을 다시 맺으려 하고, 그게 DB 부하를 더 키워 장애가 길어집니다. 그래서 liveness는 “프로세스가 응답할 수 있는가”만 보고, 외부 의존성 상태는 readiness에서만 확인해 트래픽을 잠시 빼는 쪽으로 처리하는 것이 안전합니다.
C++ 서버에서 특히 주의할 값은 initialDelaySeconds입니다. 시작할 때 큰 인덱스나 캐시를 메모리에 올리는 서버는 준비에 수십 초가 걸리기도 하는데, livenessProbe가 그보다 먼저 실패 판정을 내리면 Pod가 준비를 마치지 못한 채 재시작만 반복합니다(CrashLoopBackOff). 시작 시간이 들쭉날쭉하다면 initialDelaySeconds를 크게 잡기보다 startupProbe를 따로 두는 편이 낫습니다. startupProbe가 성공할 때까지는 liveness·readiness 검사가 시작되지 않기 때문입니다.
마지막으로 image: myregistry/myapp:latest와 imagePullPolicy: Always 조합은 예제를 단순하게 하려고 쓴 것입니다. latest 태그는 매니페스트 내용이 바뀌지 않기 때문에 kubectl apply를 다시 해도 롤링 업데이트가 일어나지 않고, 어떤 버전이 떠 있는지 추적하기도 어렵습니다. 실제 배포에서는 아래 CI/CD 절처럼 Git SHA 태그로 이미지를 바꿔 넣어야 합니다.
Service 설정
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: myapp-service
spec:
selector:
app: myapp
ports:
- name: http
protocol: TCP
port: 80
targetPort: 8080
type: LoadBalancer
메트릭 포트(9090)는 이 Service에 넣지 않았습니다. type: LoadBalancer인 Service에 포트를 추가하면 그 포트도 외부에 그대로 열리므로, 내부 상태를 드러내는 /metrics가 인터넷에 노출됩니다. Prometheus는 클러스터 안에서 Pod를 직접 스크레이프하거나, 메트릭 전용 ClusterIP Service를 따로 두고 읽게 하는 편이 안전합니다.
ConfigMap과 Secret
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: myapp-config
data:
LOG_LEVEL: "info"
METRICS_PORT: "9090"
---
# k8s/secret.yaml (base64 인코딩 필요)
apiVersion: v1
kind: Secret
metadata:
name: myapp-secret
type: Opaque
data:
API_KEY: <base64-encoded-value>
Secret의 data 값은 base64로 인코딩할 뿐 암호화가 아닙니다. 매니페스트를 Git에 그대로 커밋하면 누구나 base64 -d로 원문을 볼 수 있으므로, 실제 값은 kubectl create secret으로 클러스터에 직접 넣거나 Sealed Secrets, External Secrets Operator, Vault 같은 도구로 관리해야 합니다. 또 ConfigMap이나 Secret을 env로 주입한 경우, 값을 바꿔도 이미 떠 있는 Pod의 환경 변수는 바뀌지 않습니다. Pod를 재시작(kubectl rollout restart deployment/myapp-deployment)해야 반영된다는 점을 모르고 설정만 고친 뒤 한참 원인을 찾는 일이 자주 있습니다.
GitHub Actions CI/CD 파이프라인
GitHub Actions 워크플로우
# .github/workflows/deploy.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: |
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
- name: Run Tests
run: |
cd build
ctest --output-on-failure
- name: Run Sanitizers
run: |
cmake -B build-asan -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="-fsanitize=address,undefined"
cmake --build build-asan
cd build-asan && ctest
docker-build-push:
needs: build-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/myapp
tags: |
type=sha,prefix=,format=long
type=raw,value=latest
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: docker-build-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to Kubernetes
uses: azure/k8s-deploy@v4
with:
namespace: production
manifests: |
k8s/deployment.yaml
k8s/service.yaml
images: |
${{ env.REGISTRY }}/${{ github.repository }}/myapp:${{ github.sha }}
strategy: basic
이 파이프라인에서 가장 조심할 부분은 이미지 태그 형식이 두 곳에서 일치하는가입니다. docker/metadata-action의 type=sha는 기본값이 sha- 접두사가 붙은 7자리 짧은 SHA라서, prefix=와 format=long을 빼면 레지스트리에는 sha-a1b2c3d 태그가 올라가고 deploy 단계는 40자리 ${{ github.sha }} 이미지를 찾다가 ImagePullBackOff에 빠집니다. 빌드도 성공하고 푸시도 성공했는데 배포만 실패하므로, 로그를 꼼꼼히 비교하기 전까지는 원인이 잘 보이지 않습니다. 같은 이유로 저장소 이름에 대문자가 있으면 주의해야 합니다. GHCR 이미지 이름은 소문자여야 하는데, metadata-action은 이름을 소문자로 바꿔 주지만 deploy 단계의 ${{ github.repository }}는 그대로 들어가므로 두 값이 어긋날 수 있습니다.
또 하나, azure/k8s-deploy는 클러스터 접속 정보를 스스로 만들지 않습니다. 실제로 쓰려면 앞 단계에서 azure/k8s-set-context(또는 클라우드별 인증 액션)로 kubeconfig를 설정해야 하고, 위 예시는 그 단계를 생략한 골격입니다. Sanitizer 단계를 main 빌드와 같은 job에 둔 것은 의도적인 선택입니다. ASan/UBSan 빌드는 Release보다 느리지만, 메모리 오류가 프로덕션에 올라가 간헐적 크래시로 나타나는 것보다 PR 단계에서 몇 분 더 기다리는 편이 훨씬 비용이 적습니다.
Rolling Update·Blue-Green 무중단 배포
Rolling Update 전략
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 원하는 replicas보다 추가로 띄울 수 있는 Pod 수
maxUnavailable: 0 # 업데이트 중 Ready가 아니어도 되는 Pod 수 (0 = 용량 유지)
Blue-Green 배포
# Blue (현재) 버전
kubectl apply -f deployment-blue.yaml
# Green (새) 버전 배포
kubectl apply -f deployment-green.yaml
# 트래픽 전환
kubectl patch service myapp-service -p '{"spec":{"selector":{"version":"green"}}}'
# 검증 후 Blue 버전 제거
kubectl delete deployment myapp-blue
Rolling Update에서 maxUnavailable: 0, maxSurge: 1은 “새 Pod 하나를 먼저 띄우고, 그게 Ready가 되면 옛 Pod 하나를 내린다”는 뜻입니다. 용량이 줄지 않는 대신 배포 동안 Pod가 하나 더 떠야 하므로, 노드에 그만큼 여유 자원이 없으면 새 Pod가 Pending에 머물러 배포가 멈춥니다. 문제가 생기면 kubectl rollout undo로 이전 ReplicaSet으로 되돌릴 수 있지만, 이 역시 롤링 방식으로 진행되므로 즉시 전환되지는 않습니다.
Blue-Green은 Service의 selector만 바꿔 한 번에 전환하므로 롤백도 selector를 되돌리면 끝나지만, 두 버전이 동시에 떠 있는 동안 자원이 두 배로 들고 DB 스키마가 두 버전 모두와 호환되어야 한다는 전제가 붙습니다. 위 예시가 동작하려면 두 Deployment의 Pod 라벨에 version: blue/version: green이 있어야 하고, Service selector에도 처음부터 version 키가 들어 있어야 합니다. 일부 트래픽만 새 버전으로 보내 지표를 보며 비율을 늘리는 Canary 배포는 위험을 가장 잘 줄이지만, Ingress 컨트롤러나 서비스 메시의 가중치 라우팅, 또는 Argo Rollouts 같은 도구가 추가로 필요합니다.
Graceful Shutdown (C++ 구현)
SIGTERM을 받으면 새 연결을 거부하고, 진행 중인 요청을 마친 뒤, DB 연결 같은 리소스를 정리하고 종료해야 합니다. 시그널 핸들러 안에서는 async-signal-safe한 작업만 해야 하므로, 핸들러는 lock-free std::atomic<bool> 플래그만 바꾸고 실제 정리 작업은 메인 루프에서 합니다. 핸들러 안에서 로그를 쓰거나 mutex를 잡으면, 시그널이 마침 같은 mutex를 쥔 코드 중간에 들어왔을 때 데드락이 납니다. Boost.Asio를 쓴다면 boost::asio::signal_set으로 시그널을 이벤트 루프 안의 일반 콜백으로 받는 방법이 더 깔끔합니다.
#include <csignal>
#include <atomic>
#include <thread>
#include <chrono>
std::atomic<bool> g_running{true};
void signal_handler(int signum) {
if (signum == SIGTERM || signum == SIGINT) {
g_running = false;
}
}
int main() {
std::signal(SIGTERM, signal_handler);
std::signal(SIGINT, signal_handler);
// 서버 시작...
while (g_running) {
// 요청 처리
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// Graceful shutdown: 새 연결 거부, 기존 연결 완료 대기
// shutdown_server();
// wait_for_connections_to_close(30s);
return 0;
}
종료 대기 시간은 Kubernetes의 terminationGracePeriodSeconds(기본 30초)나 systemd의 TimeoutStopSec 안에 들어가야 합니다. preStop의 대기 시간도 이 안에 포함되므로, preStop 5초에 요청 정리 30초를 잡으면 마지막 몇 초는 SIGKILL로 잘립니다.
Prometheus 메트릭과 spdlog 구조화 로깅
Prometheus 메트릭 (C++)
#include <prometheus/counter.h>
#include <prometheus/exposer.h>
#include <prometheus/registry.h>
#include <prometheus/histogram.h>
class MetricsServer {
prometheus::Exposer exposer_{"0.0.0.0:9090"};
std::shared_ptr<prometheus::Registry> registry_;
prometheus::Counter* request_counter_;
prometheus::Histogram* request_latency_;
public:
MetricsServer() {
registry_ = std::make_shared<prometheus::Registry>();
exposer_.RegisterCollectable(registry_);
auto& counter_family = prometheus::BuildCounter()
.Name("http_requests_total")
.Help("Total HTTP requests")
.Register(*registry_);
request_counter_ = &counter_family.Add({{"method", "GET"}});
auto& histogram_family = prometheus::BuildHistogram()
.Name("http_request_duration_seconds")
.Help("Request latency")
.Register(*registry_);
request_latency_ = &histogram_family.Add(
{{"method", "GET"}},
prometheus::Histogram::BucketBoundaries{0.001, 0.01, 0.1, 0.5, 1.0}
);
}
void record_request(double latency_seconds) {
request_counter_->Increment();
request_latency_->Observe(latency_seconds);
}
};
히스토그램 버킷 경계는 서비스의 실제 지연 분포에 맞춰야 합니다. 대부분의 요청이 1~5ms에 몰리는 서비스에 위처럼 0.001, 0.01 경계만 있으면 그 구간 안의 차이가 모두 한 버킷에 들어가 histogram_quantile로 계산한 p99가 부정확해집니다.
HTTP 헬스/레디 엔드포인트
// /health - liveness: 프로세스가 살아있는지
// /ready - readiness: 트래픽 받을 준비가 됐는지 (DB 연결 등 확인)
void handle_health(Request& req, Response& res) {
res.status = 200;
res.body = R"({"status":"ok"})";
}
void handle_ready(Request& req, Response& res) {
if (db_connection_ok() && cache_connection_ok() && !draining_) {
res.status = 200;
res.body = R"({"ready":true})";
} else {
res.status = 503;
res.body = R"({"ready":false})";
}
}
draining_은 SIGTERM을 받은 뒤 true로 바꾸는 플래그입니다. 종료를 시작한 Pod가 /ready에 503을 돌려주면 엔드포인트에서 더 빨리 빠지므로, preStop 대기와 함께 쓰면 종료 중 들어오는 요청을 줄일 수 있습니다.
구조화된 로깅 (spdlog + JSON)
#include <spdlog/spdlog.h>
#include <spdlog/sinks/rotating_file_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
void setup_logging() {
// 콘솔 (개발용)
auto console_sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
console_sink->set_level(spdlog::level::info);
// 로테이팅 파일 (프로덕션)
auto file_sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
"logs/app.json", 1024 * 1024 * 10, 5 // 10MB, 5개 파일
);
file_sink->set_level(spdlog::level::debug);
auto logger = std::make_shared<spdlog::logger>("app",
spdlog::sinks_init_list{console_sink, file_sink});
logger->set_level(spdlog::level::info);
spdlog::set_default_logger(logger);
// JSON 포맷 (Loki, ELK 연동)
spdlog::set_pattern(
R"({"timestamp":"%Y-%m-%dT%H:%M:%S.%e%z","level":"%l","msg":"%v"})"
);
spdlog::info("Application started version={} environment={}",
"1.0.0", "production");
}
이 패턴 방식 JSON 로깅은 간단하지만 한계가 분명합니다. %v는 메시지를 이스케이프하지 않고 그대로 끼워 넣으므로, 메시지에 큰따옴표나 줄바꿈이 들어가면 그 줄은 유효하지 않은 JSON이 되어 Loki나 Elasticsearch 파서가 필드를 뽑지 못합니다. 예외 메시지나 사용자 입력을 로그에 남기는 서버라면 메시지를 직접 JSON으로 직렬화(예: nlohmann::json으로 만든 문자열을 %v로 출력)하는 편이 안전합니다. 또 spdlog는 기본적으로 로컬 시간을 쓰므로 타임스탬프에 Z를 하드코딩하면 UTC가 아닌 시각에 UTC 표시를 붙이게 됩니다. 위 코드처럼 %z로 오프셋을 찍거나 spdlog::pattern_time_type::utc를 지정해야 합니다. 그리고 logger->set_level(info)가 로거 단계에서 먼저 걸러 내므로, 파일 싱크를 debug로 둬도 debug 로그는 기록되지 않습니다. 싱크별 레벨이 의미가 있으려면 로거 레벨을 가장 낮은 싱크 레벨 이하로 내려야 합니다. 참고로 spdlog의 로깅 함수는 키-값 필드를 따로 받지 않고 fmt 형식 문자열({})로 메시지를 구성합니다.
컨테이너 환경이라면 파일 로테이션보다 stdout 출력이 기본입니다. 쿠버네티스는 컨테이너의 stdout/stderr를 노드에 저장하고 수집기(Promtail, Fluent Bit)가 거기서 읽어 가므로, 컨테이너 안에 파일로만 쓰면 볼륨을 따로 붙이지 않는 한 Pod가 재시작될 때 로그가 사라집니다. 파일 싱크는 systemd·VM 배포에서 주로 의미가 있습니다.
Grafana 대시보드 메트릭
아래 임계값은 출발점으로 삼을 만한 예시이고, 서비스의 SLO에 맞춰 조정해야 합니다.
| 메트릭 | 설명 | 예시 임계값 |
|---|---|---|
http_requests_total | 총 요청 수 | - |
http_request_duration_seconds | 요청 지연 시간 | p99 < 1s |
process_resident_memory_bytes | 메모리 사용량 | limit의 80% 미만 |
process_cpu_seconds_total | CPU 사용 시간 | - |
메트릭 기반 알림
# Prometheus 알림 규칙 (rule_files로 불러오는 파일의 groups 아래에 둠)
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m])) > 0.05
for: 5m
annotations:
summary: "에러율 5% 초과"
알림 규칙은 Prometheus 서버가 평가하고, Alertmanager는 그 결과를 받아 묶고 라우팅해 알림을 보내는 역할만 합니다. rate(http_requests_total{status=~"5.."}[5m]) > 0.05처럼 분자만 쓰면 “초당 5xx가 0.05건 이상”이라는 뜻이 되어, 트래픽이 많은 서비스에서는 항상 울리고 적은 서비스에서는 절반이 실패해도 조용할 수 있습니다. 비율로 알림을 걸려면 위처럼 전체 요청 rate로 나눠야 합니다. 이 쿼리가 동작하려면 앞의 C++ 메트릭 코드에서 카운터에 status 라벨을 달아 응답 코드별로 증가시켜야 한다는 점도 함께 챙겨야 합니다(예제 코드는 method 라벨만 있습니다). 다만 라벨 값에 사용자 ID나 전체 URL 경로를 넣으면 시계열 수가 폭증하므로, 라벨은 상태 코드·메서드·라우트 템플릿처럼 값의 종류가 제한된 것만 씁니다.
GLIBC not found, OOMKilled, CrashLoopBackOff: 배포 에러 해결
”version ‘GLIBC_2.34’ not found”
빌드 환경의 glibc가 런타임 환경보다 새 버전이라 생기는 에러이므로, 빌드 이미지와 런타임 이미지의 베이스를 맞춥니다.
# Dockerfile에서 동일 베이스 이미지 사용
FROM ubuntu:22.04 AS builder
# ...
FROM ubuntu:22.04 # 런타임도 22.04
libstdc++ 버전 에러(GLIBCXX_3.4.xx not found)라면 libstdc++·libgcc만 정적으로 넣는 방법도 있습니다. 이 옵션은 glibc 자체는 정적으로 넣지 않으므로 GLIBC_2.34 에러에는 효과가 없습니다(glibc 완전 정적 링크는 NSS·dlopen 동작에 제약이 있어 권장하지 않습니다).
# CMakeLists.txt
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -static-libgcc -static-libstdc++")
”OOMKilled” (Kubernetes)
컨테이너가 메모리 limit을 넘으면 커널 OOM Killer가 프로세스를 종료합니다. limit을 올리기 전에 kubectl describe pod의 Last State: Terminated, Reason: OOMKilled, Exit Code: 137을 확인하고, 메모리 그래프가 계단식으로 계속 오르는지(누수) 아니면 특정 요청에서 튀는지(일시적 버퍼)를 먼저 구분해야 합니다. 누수라면 limit을 올려도 재시작 주기만 늘어나므로 Valgrind나 AddressSanitizer로 원인을 찾아야 합니다. 일시적 사용량이 원인이라면 그때 limit을 조정합니다.
resources:
requests:
memory: "256Mi"
limits:
memory: "512Mi"
또 glibc malloc은 스레드마다 arena를 만들기 때문에, 워커 스레드가 많은 C++ 서버는 누수가 없어도 RSS가 예상보다 커 보일 수 있습니다. 이 경우 MALLOC_ARENA_MAX 환경 변수로 arena 수를 줄이거나 jemalloc·tcmalloc으로 바꿔 비교해 보는 것이 일반적인 접근입니다.
”ImagePullBackOff”
레지스트리 인증 실패나 이미지 경로·태그 오류가 원인입니다. 같은 이미지를 로컬에서 docker pull해 보고, 프라이빗 레지스트리라면 pull용 Secret을 만들어 Pod 스펙의 imagePullSecrets에 지정합니다.
# 이미지 풀 테스트
docker pull myregistry/myapp:<tag>
# Kubernetes Secret 생성 (프라이빗 레지스트리)
kubectl create secret docker-registry regcred \
--docker-server=myregistry \
--docker-username=user \
--docker-password=pass
”CrashLoopBackOff”
컨테이너가 시작 직후 반복해서 종료되는 상태로, 앱 크래시, 잘못된 설정, 시작이 끝나기 전에 실패하는 livenessProbe가 흔한 원인입니다. 직전 컨테이너의 로그와 이벤트부터 봅니다.
# 직전에 죽은 컨테이너의 로그
kubectl logs <pod-name> --previous
# 이벤트와 종료 사유 확인
kubectl describe pod <pod-name>
로그에 아무것도 없이 probe 실패 이벤트만 있다면 시작 시간이 probe보다 긴 것이므로 startupProbe를 추가합니다.
”502 Bad Gateway” (배포 중)
준비되지 않은 Pod에 트래픽이 들어가거나 종료 중인 Pod가 연결을 먼저 닫는 경우입니다. readinessProbe를 앱 초기화 시간에 맞추고, 앞에서 본 preStop 대기와 Graceful Shutdown을 함께 적용합니다.
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 10 # 앱 초기화 시간에 맞게
periodSeconds: 5
”Permission denied” (Docker)
non-root 사용자로 실행하는 컨테이너가 root 소유의 디렉터리나 호스트에서 마운트한 볼륨에 쓰려고 할 때 납니다. Dockerfile에서 앱이 쓰는 디렉터리의 소유자를 실행 사용자로 바꾸고, 바인드 마운트라면 호스트 디렉터리의 UID/GID를 컨테이너 사용자와 맞춥니다(Kubernetes에서는 securityContext.fsGroup).
RUN useradd -r appuser && mkdir -p /app/logs && chown -R appuser /app
USER appuser
이미지 태깅·환경별 설정·보안
이미지 태깅
latest만 쓰면 어떤 커밋이 떠 있는지 알 수 없고 롤백할 대상도 특정할 수 없습니다. 이미지마다 Git SHA 태그를 붙이고, 릴리스에는 시맨틱 버전 태그를 함께 붙입니다.
docker tag myapp:build myregistry/myapp:$GIT_SHA
docker tag myapp:build myregistry/myapp:v1.2.3
환경별 설정
설정은 이미지에 굽지 않고 환경 변수로 주입해 같은 이미지를 스테이징과 프로덕션에 그대로 씁니다.
# .env.production
LOG_LEVEL=warn
METRICS_PORT=9090
DATABASE_URL=...
# Docker
docker run --env-file .env.production myapp
보안
Docker와 systemd 모두 non-root 사용자로 실행하고, systemd에서는 ProtectSystem과 ReadWritePaths로 쓰기 가능한 경로를 최소화합니다. 비밀 값은 이미지나 매니페스트에 넣지 않고 Kubernetes Secret을 외부 비밀 저장소(Vault 등)와 연동해 관리하며, Trivy 같은 스캐너로 이미지의 알려진 취약점을 CI에서 검사합니다. requests와 limits도 항상 지정해, 한 Pod가 노드 자원을 독점하거나 스케줄러가 과도하게 Pod를 몰아 넣는 일을 막습니다.