C++ 서버 배포: Docker 멀티스테이지, systemd, Kubernetes 무중단 배포, Prometheus 모니터링
들어가며: “로컬에선 되는데 서버에서 죽어요”
프로덕션 배포에서 겪는 문제
C++ 애플리케이션을 개발할 때는 로컬에서 잘 동작하는데, 서버에 배포하면 다음 문제들이 발생합니다.
- 라이브러리 버전 불일치: 로컬은 glibc 2.35인데 서버는 2.31이라 실행이 안 됩니다
- 메모리 한도 초과: 컨테이너 메모리 제한을 넘어 OOM Killer에 의해 강제 종료됩니다
- 재시작 실패: 프로세스가 죽었는데 자동으로 다시 띄우지 않습니다
- 로그 유실: stdout에만 출력해서 컨테이너 재시작 시 로그가 사라집니다
- 헬스 체크 부재: 트래픽이 죽은 Pod로 가서 502 에러가 발생합니다 이 글에서는 Docker 컨테이너화, systemd 전통 배포, Kubernetes 오케스트레이션, CI/CD 파이프라인, Prometheus 모니터링, 구조화된 로깅까지 프로덕션 배포의 전체 워크플로우를 실전 코드로 다룹니다.
목표:
- Multi-stage Docker 빌드로 최적화된 이미지 생성
- systemd로 VM/베어메탈 서버에 안정적 서비스 등록
- Kubernetes 배포 및 서비스 설정
- GitHub Actions CI/CD 파이프라인
- 무중단 배포 (Rolling Update, Blue-Green)
- Prometheus 메트릭 + Grafana 대시보드
- JSON 구조화 로깅 (spdlog) 요구 환경: Docker, Kubernetes (minikube 또는 클라우드), GitHub
glibc 불일치·OOM·배포 중 502: 실무 배포 문제
상황: Ubuntu 22.04에서 빌드한 바이너리를 CentOS 7(glibc 2.17)이나 Ubuntu 20.04(glibc 2.31) 서버에 복사해 실행하면 version 'GLIBC_2.34' not found 에러가 납니다.
원인: 빌드 환경과 런타임 환경의 glibc 버전 불일치
해결: Docker Multi-stage 빌드로 동일 베이스 이미지 사용, 또는 배포 대상 중 가장 오래된 배포판에서 빌드
이 에러는 링커가 빌드 당시 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 상태로 재시작됩니다.
원인: resources.limits.memory 미설정 또는 애플리케이션 메모리 누수
해결: 적절한 메모리 limit 설정, Valgrind/AddressSanitizer로 누수 검사, 메트릭 모니터링
상황: 새 버전 배포 시 5~10초 동안 502 Bad Gateway가 발생합니다.
원인: readinessProbe 미설정으로 트래픽이 아직 준비되지 않은 Pod로 전달
해결: readinessProbe 설정, maxUnavailable: 0 Rolling Update, Graceful Shutdown
상황: 장애 발생 시 원인 분석을 위해 로그를 찾는데, 컨테이너가 재시작되어 로그가 사라졌습니다.
원인: stdout만 사용하고 중앙 로그 수집 미설정
해결: JSON 구조화 로깅, 로그 볼륨 마운트, Loki/ELK 등 중앙 로그 수집
상황: scp로 바이너리를 복사한 뒤 systemctl restart 하는 과정에서 잘못된 버전을 배포했습니다.
원인: 수동 배포, 버전 관리 부재
해결: CI/CD 파이프라인으로 자동화, 이미지 태그에 Git SHA 사용
상황: 패치 후 서버를 재부팅했는데 C++ 앱이 자동으로 시작되지 않습니다.
원인: systemd 서비스 등록 안 됨 또는 enabled 설정 누락
해결: systemd unit 파일 작성, systemctl enable myapp 실행
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 등은 최종 이미지에 포함되지 않습니다.
# ========== 빌드 스테이지 ==========
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"]
최적화 포인트:
- Multi-stage로 빌드 도구 제외 (컴파일러·헤더·CMake가 최종 이미지에 들어가지 않음)
--no-install-recommends로 불필요한 패키지 제외- non-root 사용자로 실행 (보안)
- HEALTHCHECK로 컨테이너 헬스 자동 검사
런타임 스테이지에서 가장 자주 틀리는 부분은 공유 라이브러리 누락입니다. 빌드 스테이지에서는 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 환경에서만 의미가 있습니다.
Docker Compose: 로컬 프로덕션 시뮬레이션
로컬에서 여러 서비스(앱, Prometheus, Grafana)를 함께 띄워 테스트합니다. 최신 Docker Compose v2는 최상위 version: 키를 무시하고 경고만 출력하므로 지워도 됩니다. Compose 네트워크 안에서는 서비스 이름(myapp)이 곧 호스트명이라서, 아래 Prometheus 설정의 타깃도 localhost가 아니라 myapp:9090으로 적습니다. 여기서 localhost를 쓰면 Prometheus 컨테이너 자기 자신을 가리키게 되어 타깃이 계속 DOWN으로 보입니다.
# docker-compose.yml
version: '3.8'
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로 서비스를 등록합니다. 재부팅 후 자동 시작, 실패 시 재시작, 로그 통합 등이 가능합니다.
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: Pod가 죽었는지 확인, 실패 시 재시작readinessProbe: 트래픽 수신 준비 여부, 실패 시 Service에서 제외preStop: SIGTERM 전 5초 대기로 진행 중 요청 처리 완료resources: OOM 방지, 스케줄링 품질 보장
/health와 /ready를 나눈 데는 이유가 있습니다. 두 probe가 같은 엔드포인트를 보고 그 엔드포인트가 DB 연결까지 검사하면, DB가 잠깐 느려지는 순간 livenessProbe도 함께 실패해 모든 Pod가 동시에 재시작됩니다. 재시작한 Pod들은 한꺼번에 DB 연결을 다시 맺으려 하고, 그게 DB 부하를 더 키워 장애가 길어집니다. 그래서 liveness는 “프로세스가 응답할 수 있는가”만 보고, 외부 의존성 상태는 readiness에서만 확인해 트래픽을 잠시 빼는 쪽으로 처리하는 것이 안전합니다.
C++ 서버에서 특히 주의할 값은 initialDelaySeconds입니다. 시작할 때 큰 인덱스나 캐시를 메모리에 올리는 서버는 준비에 수십 초가 걸리기도 하는데, livenessProbe가 그보다 먼저 실패 판정을 내리면 Pod가 영원히 준비를 마치지 못한 채 재시작만 반복합니다(CrashLoopBackOff). 시작 시간이 들쭉날쭉하다면 initialDelaySeconds를 크게 잡기보다 startupProbe를 따로 두는 편이 낫습니다. startupProbe가 성공할 때까지는 liveness 검사가 시작되지 않기 때문입니다.
마지막으로 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
- name: metrics
protocol: TCP
port: 9090
targetPort: 9090
type: LoadBalancer
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는 기본값이 7자리 짧은 SHA라서, format=long을 빼면 레지스트리에는 a1b2c3d 태그가 올라가고 deploy 단계는 40자리 ${{ github.sha }} 이미지를 찾다가 ImagePullBackOff에 빠집니다. 빌드도 성공하고 푸시도 성공했는데 배포만 실패하므로, 로그를 꼼꼼히 비교하기 전까지는 원인이 잘 보이지 않습니다.
또 하나, 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 # 동시에 생성할 수 있는 추가 Pod 수
maxUnavailable: 0 # 업데이트 중 사용 불가능한 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에 머물러 배포가 멈춥니다. Blue-Green은 Service의 selector만 바꿔 한 번에 전환하므로 롤백도 selector를 되돌리면 끝나지만, 두 버전이 동시에 떠 있는 동안 자원이 두 배로 들고 DB 스키마가 두 버전 모두와 호환되어야 한다는 전제가 붙습니다. 위 예시가 동작하려면 두 Deployment의 Pod 라벨에 version: blue/version: green이 있어야 하고, Service selector에도 처음부터 version 키가 들어 있어야 합니다.
Graceful Shutdown (C++ 구현)
SIGTERM 수신 시 진행 중인 요청을 처리한 뒤 종료합니다. 시그널 핸들러 안에서는 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;
}
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);
}
};
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()) {
res.status = 200;
res.body = R"({"ready":true})";
} else {
res.status = 503;
res.body = R"({"ready":false})";
}
}
구조화된 로깅 (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를 지정해야 합니다. 참고로 spdlog에는 키-값 인자를 받는 API가 없고, 메시지는 fmt 형식 문자열({})로 구성합니다.
컨테이너 환경이라면 파일 로테이션보다 stdout 출력이 기본입니다. 쿠버네티스는 컨테이너의 stdout/stderr를 노드에 저장하고 수집기(Promtail, Fluent Bit)가 거기서 읽어 가므로, 컨테이너 안에 파일로만 쓰면 볼륨을 따로 붙이지 않는 한 Pod가 재시작될 때 로그가 사라집니다. 파일 싱크는 systemd·VM 배포에서 주로 의미가 있습니다.
Grafana 대시보드 메트릭
| 메트릭 | 설명 | 임계값 |
|---|---|---|
http_requests_total | 총 요청 수 | - |
http_request_duration_seconds | 요청 지연 시간 | p99 < 1s |
process_resident_memory_bytes | 메모리 사용량 | < limit의 80% |
process_cpu_seconds_total | CPU 사용량 | - |
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 초과 또는 메모리 누수 해결:
# resources 조정
resources:
requests:
memory: "256Mi" # 증가
limits:
memory: "512Mi" # 여유 있게
메모리 프로파일링: Valgrind, AddressSanitizer로 누수 검사
limit을 올리기 전에 kubectl describe pod의 Last State: Terminated, Reason: OOMKilled, Exit Code: 137을 확인하고, 메모리 그래프가 계단식으로 계속 오르는지(누수) 아니면 특정 요청에서 튀는지(일시적 버퍼)를 먼저 구분해야 합니다. 누수라면 limit을 올려도 재시작 주기만 늘어납니다. 또 glibc malloc은 스레드마다 arena를 만들기 때문에, 워커 스레드가 많은 C++ 서버는 누수가 없어도 RSS가 예상보다 커 보일 수 있습니다. 이 경우 MALLOC_ARENA_MAX 환경 변수로 arena 수를 줄이거나 jemalloc·tcmalloc으로 바꿔 비교해 보는 것이 일반적인 접근입니다.
”ImagePullBackOff”
원인: 레지스트리 인증 실패 또는 이미지 경로 오류 해결:
# 이미지 풀 테스트
docker pull myregistry/myapp:latest
# 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>
# livenessProbe initialDelaySeconds 증가 (앱 시작 시간 고려)
“502 Bad Gateway” (배포 중)
원인: readinessProbe 미설정으로 준비 안 된 Pod에 트래픽 전달 해결:
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 10 # 앱 초기화 시간에 맞게
periodSeconds: 5
“Permission denied” (Docker)
원인: root로 실행하거나 볼륨 권한 불일치 해결:
# Dockerfile에서 non-root 사용자
RUN useradd -r appuser
USER appuser
이미지 태깅·환경별 설정·리소스 제한
이미지 태깅
# ❌ 나쁜 예: latest만 사용
docker tag myapp:latest
# ✅ 좋은 예: Git SHA + 시맨틱 버전
docker tag myapp:$GIT_SHA
docker tag myapp:v1.2.3
환경별 설정
# .env.production
LOG_LEVEL=warn
METRICS_PORT=9090
DATABASE_URL=...
# Docker
docker run -e LOG_LEVEL=warn myapp
보안
- non-root 사용자: Docker, systemd 모두
- 최소 권한:
ProtectSystem,ReadWritePaths제한 - Secret 관리: Kubernetes Secret, Vault 사용
- 이미지 스캔: Trivy, Snyk로 취약점 검사
리소스 제한
# 항상 requests와 limits 설정
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
헬스 체크 계층화·메트릭 알림·배포 전략 선택
헬스 체크 계층화
// Liveness: 프로세스 생존
bool liveness() { return true; }
// Readiness: 트래픽 수신 가능 여부
bool readiness() {
return db_connected_ && cache_connected_ && !draining_;
}
Graceful Shutdown
- SIGTERM 수신
- 새 연결 거부
- 진행 중 요청 완료 대기 (타임아웃 30초)
- 리소스 정리 (DB 연결, 파일 핸들)
- 종료
메트릭 기반 알림
# Prometheus Alertmanager 규칙
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m])) > 0.05
for: 5m
annotations:
summary: "에러율 5% 초과"
rate(http_requests_total{status=~"5.."}[5m]) > 0.05처럼 분자만 쓰면 “초당 5xx가 0.05건 이상”이라는 뜻이 되어, 트래픽이 많은 서비스에서는 항상 울리고 적은 서비스에서는 절반이 실패해도 조용할 수 있습니다. 비율로 알림을 걸려면 위처럼 전체 요청 rate로 나눠야 합니다. 이 쿼리가 동작하려면 앞의 C++ 메트릭 코드에서 카운터에 status 라벨을 달아 응답 코드별로 증가시켜야 한다는 점도 함께 챙겨야 합니다(예제 코드는 method 라벨만 있습니다). 다만 라벨 값에 사용자 ID나 전체 URL 경로를 넣으면 시계열 수가 폭증하므로, 라벨은 상태 코드·메서드·라우트 템플릿처럼 값의 종류가 제한된 것만 씁니다.
배포 전략 선택
| 전략 | 장점 | 단점 | 적합 |
|---|---|---|---|
| Rolling | 무중단, 자동 | 롤백 수동 | 대부분 |
| Blue-Green | 즉시 전환/롤백 | 리소스 2배 | 중요 서비스 |
| Canary | 위험 최소화 | 복잡 | 대규모 |
배포 방식별 점검 항목
Docker
- Multi-stage Dockerfile 사용
- non-root 사용자로 실행
-
.dockerignore로 불필요 파일 제외 - 이미지 태그에 Git SHA 포함
- HEALTHCHECK 설정
Kubernetes
- livenessProbe 설정
- readinessProbe 설정
- resources requests/limits 설정
- RollingUpdate maxUnavailable: 0
- preStop lifecycle (Graceful Shutdown)
systemd
-
Restart=on-failure설정 -
systemctl enable실행 - 로그
journalctl확인
모니터링
- Prometheus 메트릭 노출 (/metrics)
- /health, /ready 엔드포인트 구현
- 구조화된 로깅 (JSON)
- Grafana 대시보드 구성
CI/CD
- PR 시 빌드·테스트
- main 푸시 시 이미지 빌드·푸시
- Sanitizer 테스트 포함
- 배포 자동화 (선택)
단계별 도구 요약
| 단계 | 도구 | 목적 |
|---|---|---|
| 빌드 | CMake + Docker Multi-stage | 재현 가능한 빌드, 이미지 최소화 |
| 배포 | Kubernetes / systemd | 오케스트레이션 / 전통 서버 |
| CI/CD | GitHub Actions | 자동 빌드·테스트·배포 |
| 모니터링 | Prometheus + Grafana | 메트릭 수집·시각화 |
| 로깅 | spdlog (JSON) | 구조화된 로그, 중앙 수집 연동 |
Docker, systemd, Kubernetes, CI/CD, Prometheus로 C++ 앱을 프로덕션에 안전하게 배포할 수 있습니다. 다음 글: C++ 프로파일러 비교: perf, gprof, Valgrind Callgrind, VTune, Tracy
자주 묻는 질문 (FAQ)
Q. 서버에서 version GLIBC_2.34 not found 에러로 실행이 안 되는 이유는 무엇인가요?
A. 바이너리를 빌드한 환경의 glibc가 실행 환경보다 새 버전이면, 바이너리가 실행 환경에 없는 새 심볼 버전을 참조하게 되어 로더가 실행을 거부합니다. 최신 배포판 이미지에서 빌드하고 오래된 서버나 런타임 이미지에서 실행할 때 자주 생깁니다. 빌드 이미지와 런타임 이미지의 베이스 배포판 버전을 맞추거나 배포 대상 중 가장 오래된 환경에서 빌드하는 것이 기본 해결책이고, 필요하다면 정적 링크도 검토할 수 있습니다.