infra
Platform

모듈 맵

[Linux] 주기적 서버 헬스체크와 장애 데몬 자동 재시작 스크립트

0 / 37 완료

펼치기
0 / 37 완료0%

Linux · 32 / 37

[Linux] 주기적 서버 헬스체크와 장애 데몬 자동 재시작 스크립트

Prometheus 없이도 서버를 지킨다 — 경량 모니터링과 자동 복구 스크립트

🚨INCIDENT ALERT
HIGH

새벽 3시에 서비스 알람이 울립니다. 담당자가 일어나서 systemctl restart app을 치고 다시 잡니다. 한 달 뒤 같은 일이 반복됩니다. 자동 재시작 스크립트를 짜놨더니 이번엔 서비스가 계속 죽으면서 재시작을 무한 반복하고, 디스크가 로그로 가득 차버렸습니다. Prometheus 같은 모니터링 도구를 쓰기엔 환경 제약이 있을 때, 올바르게 설계된 헬스체크 스크립트가 새벽 호출을 막아주는 유일한 방어선이 됩니다.

헬스체크 & 자가복구 스크립트

Prometheus, Grafana, Datadog — 훌륭한 도구들이지만 모든 환경에서 쓸 수 있는 건 아닙니다. 사내 레거시 서버, 고객사 온프레미스 환경, 인터넷이 격리된 망분리 시스템에서는 경량 셸 스크립트가 유일한 모니터링 수단이 되기도 합니다. 이 챕터에서는 외부 의존성 없이 Bash만으로 서비스 상태를 감시하고, 이상이 감지되면 자동으로 복구하는 패턴을 배웁니다.

이번 챕터에서 배울 것
  • 1헬스체크 설계 원칙: 감지(Detection)와 복구(Recovery) 분리
  • 2종료 코드($?)와 curl/nc를 활용한 프로세스·포트·HTTP 상태 확인
  • 3디스크·메모리 임계값 모니터링 및 경고 스크립트 작성
  • 4systemctl OnFailure와 cron을 이용한 자동 재시작 패턴
  • 5멱등성 원칙 적용과 재시작 루프 방지 기법
실습 환경 준비
스크립트 실행 권한 부여
chmod +x /usr/local/bin/health-check.sh
cron 데몬 상태 확인
systemctl is-active cron || systemctl is-active crond
테스트용 nginx 서비스 준비

sudo apt install nginx 또는 sudo yum install nginx — 서비스 감지 실습에 사용

💡개념

헬스체크의 두 가지 축: 감지와 복구

헬스체크의 두 축 — 감지(Detection) + 자가복구(Recovery) 루프

새벽 3시에 서비스 알람이 울렸는데 담당자가 일어나서 systemctl restart app을 치고 자리로 돌아가는 상황을 반복하고 있다면, 자동화로 해결할 수 있습니다. 그런데 자동 재시작 스크립트를 짜놨더니 이번엔 서비스가 계속 죽고 재시작을 무한 반복하면서 디스크가 로그로 가득 찼습니다. 헬스체크 스크립트는 "감지"와 "복구"를 분리해서 설계해야 합니다. 감지는 현재 상태가 정상인지 아닌지를 판단하는 것이고, 복구는 그 판단 결과에 따라 어떤 행동을 취할지 결정하는 것입니다. 이 두 가지가 섞이면 디버깅이 어렵고, 복구 정책을 바꾸기도 어렵습니다.

헬스체크 스크립트를 설계할 때는 항상 두 가지 역할을 분리해서 생각해야 합니다.

감지(Detection) — 지금 무언가 잘못되었는가?

  • 프로세스가 실행 중인가? (systemctl is-active, pgrep)
  • 포트가 열려 있는가? (nc -zv, /dev/tcp)
  • HTTP 응답이 정상인가? (curl 상태코드)
  • 디스크/메모리가 임계값 이하인가? (df, free)

복구(Recovery) — 문제가 확인됐을 때 어떻게 되돌리는가?

  • 서비스 재시작 (systemctl restart)
  • 알림 발송 후 사람이 개입하도록 대기
  • 임시 파일 정리 후 재시작
  • 재시작 횟수 제한으로 루프 방지

이 둘을 하나의 스크립트에 뒤섞으면 나중에 디버깅이 매우 어려워집니다. 감지 로직은 항상 멱등(idempotent)하게, 즉 몇 번을 실행해도 동일한 결과를 반환하도록 작성해야 하고, 복구 액션은 부작용(side-effect)이 있으므로 조건을 엄격하게 걸어야 합니다.

실습 전 디렉토리와 예제 파일을 먼저 준비합니다.

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/linux/part6/exam_31 && cd /tmp/linux/part6/exam_31

# 헬스체크 대상 서비스 목록
cat > services.txt << 'EOF'
nginx Web Server :80
sshd SSH Server :22
EOF

# 기본 헬스체크 템플릿
cat > health_check.sh << 'EOF'
#!/bin/bash
FAILED=0
check_service() {
  local name="$1"
  if systemctl is-active --quiet "$name" 2>/dev/null; then
    echo "✓ $name: running"
  else
    echo "✗ $name: STOPPED"
    FAILED=$((FAILED+1))
  fi
}
check_port() {
  local name="$1" port="$2"
  if ss -tlnp 2>/dev/null | grep -q ":${port} "; then
    echo "✓ $name (port $port): listening"
  else
    echo "✗ $name (port $port): NOT listening"
    FAILED=$((FAILED+1))
  fi
}
echo "=== Health Check $(date) ==="
check_port "SSH" 22
check_service "cron"
echo "Failures: $FAILED"
exit $FAILED
EOF
chmod +x health_check.sh

이제 실습을 진행합니다.

$? — 종료 코드의 의미

Bash에서 모든 명령어는 종료 코드(exit code)를 반환합니다. 0은 성공, 그 외는 실패입니다.

서버 터미널
systemctl is-active nginx
echo $?   # 0 이면 active, 그 외면 inactive/failed

systemctl is-active는 특히 유용합니다. --quiet 플래그를 붙이면 출력 없이 종료 코드만 반환하므로 스크립트에서 바로 조건문에 사용할 수 있습니다.

로컬 터미널
if ! systemctl is-active --quiet nginx; then
    echo "nginx가 죽었습니다"
fi

상태코드 참조표

종료코드의미
0성공 / 서비스 active
1일반 오류
2잘못된 사용법(인자 오류)
3systemctl: unit not active
124timeout 명령어에 의한 강제 종료
126실행 권한 없음
127명령어를 찾을 수 없음

1. 단일 서비스 헬스체크 기초

가장 단순한 형태부터 시작합니다. nginx 하나를 감시하고, 죽어 있으면 재시작하는 스크립트입니다.

기본 서비스 감시 스크립트 작성

먼저 스크립트 파일을 만들고 실행 권한을 부여합니다.

로컬 터미널
sudo mkdir -p /opt/healthcheck
sudo tee /opt/healthcheck/check_nginx.sh > /dev/null << 'EOF'
#!/usr/bin/env bash
# check_nginx.sh — nginx 상태 감시 및 자동 재시작
# 작성일: 2026-03-26
# 실행 환경: Ubuntu 22.04 / RHEL 9

set -euo pipefail

SERVICE="nginx"
LOG_FILE="/var/log/healthcheck/nginx.log"
MAX_RESTART=3
RESTART_COUNT_FILE="/tmp/nginx_restart_count"

# 로그 디렉터리 생성
mkdir -p "$(dirname "$LOG_FILE")"

# 타임스탬프 함수
timestamp() {
    date '+%Y-%m-%d %H:%M:%S'
}

# 로그 함수
log() {
    local level="$1"
    shift
    echo "[$(timestamp)] [$level] $*" | tee -a "$LOG_FILE"
}

# 재시작 카운터 읽기
get_restart_count() {
    if [[ -f "$RESTART_COUNT_FILE" ]]; then
        cat "$RESTART_COUNT_FILE"
    else
        echo "0"
    fi
}

# 재시작 카운터 증가
increment_restart_count() {
    local count
    count=$(get_restart_count)
    echo $((count + 1)) > "$RESTART_COUNT_FILE"
}

# 재시작 카운터 초기화
reset_restart_count() {
    echo "0" > "$RESTART_COUNT_FILE"
}

# 메인 헬스체크
main() {
    log "INFO" "=== 헬스체크 시작: $SERVICE ==="

    if systemctl is-active --quiet "$SERVICE"; then
        log "INFO" "$SERVICE 정상 동작 중"
        reset_restart_count
        exit 0
    fi

    # 서비스가 비활성 상태
    log "WARN" "$SERVICE 비활성 감지"

    local count
    count=$(get_restart_count)

    if [[ "$count" -ge "$MAX_RESTART" ]]; then
        log "ERROR" "재시작 횟수($count)가 최대값($MAX_RESTART)을 초과했습니다. 수동 개입이 필요합니다."
        exit 1
    fi

    log "INFO" "재시작 시도 중... (시도 횟수: $((count + 1))/$MAX_RESTART)"
    increment_restart_count

    if systemctl restart "$SERVICE"; then
        log "INFO" "$SERVICE 재시작 성공"
    else
        log "ERROR" "$SERVICE 재시작 실패"
        exit 1
    fi
}

main "$@"
EOF

sudo chmod +x /opt/healthcheck/check_nginx.sh

스크립트를 실행해 봅니다.

로컬 터미널
sudo /opt/healthcheck/check_nginx.sh

nginx를 강제로 중지한 뒤 다시 실행해 자동 재시작이 되는지 확인합니다.

서버 터미널
sudo systemctl stop nginx
sudo /opt/healthcheck/check_nginx.sh
systemctl is-active nginx   # active 가 출력되어야 합니다
🔍실행 후 확인할 것
  • 스크립트 실행 후 /var/log/healthcheck/nginx.log 에 [INFO] 또는 [WARN] 로그가 기록된다
  • nginx 중지 후 스크립트 실행 시 '비활성 감지' 로그가 나오고, 이후 systemctl is-active nginx 가 active 를 반환한다
  • 재시작 카운터 파일 /tmp/nginx_restart_count 의 숫자가 재시작마다 증가한다
  • MAX_RESTART 초과 시 스크립트가 '수동 개입이 필요합니다' 메시지와 함께 exit 1 로 종료된다

2. 다중 서비스 배열 반복 처리

실제 운영 환경에서는 수십 개의 서비스를 동시에 감시해야 합니다. 서비스마다 스크립트를 따로 만드는 것은 유지보수 악몽입니다. 배열(array)과 반복문을 활용해 하나의 스크립트로 여러 서비스를 처리합니다.

다중 서비스 배열 반복 헬스체커
로컬 터미널
sudo tee /opt/healthcheck/check_services.sh > /dev/null << 'EOF'
#!/usr/bin/env bash
# check_services.sh — 다중 서비스 일괄 헬스체크
# 사용법: ./check_services.sh [--dry-run]

set -uo pipefail

# ──────────────────────────────────────────
# 설정 섹션 — 환경에 맞게 수정하세요
# ──────────────────────────────────────────
SERVICES=(
    "nginx"
    "postgresql"
    "redis"
    "docker"
)

LOG_FILE="/var/log/healthcheck/services.log"
DRY_RUN=false
FAILED_SERVICES=()

# ──────────────────────────────────────────
# 유틸리티 함수
# ──────────────────────────────────────────
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }

log() {
    local level="$1"; shift
    local msg="[$(timestamp)] [$level] $*"
    echo "$msg" | tee -a "$LOG_FILE"
}

# 인자 파싱
parse_args() {
    for arg in "$@"; do
        case "$arg" in
            --dry-run) DRY_RUN=true ;;
            *) log "WARN" "알 수 없는 인자: $arg" ;;
        esac
    done
}

# 단일 서비스 체크 및 복구
check_and_recover() {
    local service="$1"

    # 서비스 존재 여부 확인
    if ! systemctl list-units --type=service --all | grep -q "${service}.service"; then
        log "SKIP" "$service: 시스템에 등록되지 않은 서비스 (건너뜀)"
        return 0
    fi

    if systemctl is-active --quiet "$service"; then
        log "OK" "$service: 정상"
        return 0
    fi

    log "FAIL" "$service: 비활성 상태 감지"
    FAILED_SERVICES+=("$service")

    if [[ "$DRY_RUN" == "true" ]]; then
        log "INFO" "[DRY-RUN] $service 재시작을 시뮬레이션합니다 (실제 실행 안 함)"
        return 0
    fi

    log "INFO" "$service 재시작 시도..."
    if systemctl restart "$service" 2>>"$LOG_FILE"; then
        log "INFO" "$service 재시작 성공"
    else
        log "ERROR" "$service 재시작 실패 — 수동 점검 필요"
        return 1
    fi
}

# 요약 리포트 출력
print_summary() {
    log "INFO" "──────────────────────────────"
    log "INFO" "헬스체크 완료: 총 ${#SERVICES[@]}개 서비스 점검"

    if [[ ${#FAILED_SERVICES[@]} -eq 0 ]]; then
        log "INFO" "모든 서비스 정상"
    else
        log "WARN" "이상 감지 서비스: ${FAILED_SERVICES[*]}"
    fi
    log "INFO" "──────────────────────────────"
}

# ──────────────────────────────────────────
# 메인
# ──────────────────────────────────────────
main() {
    parse_args "$@"
    mkdir -p "$(dirname "$LOG_FILE")"

    [[ "$DRY_RUN" == "true" ]] && log "INFO" "=== DRY-RUN 모드 ==="
    log "INFO" "=== 다중 서비스 헬스체크 시작 ==="

    for service in "${SERVICES[@]}"; do
        check_and_recover "$service" || true
    done

    print_summary
}

main "$@"
EOF

sudo chmod +x /opt/healthcheck/check_services.sh

# 정상 실행 테스트
sudo /opt/healthcheck/check_services.sh --dry-run

배열에 서비스를 추가할 때는 SERVICES 섹션만 수정하면 됩니다. --dry-run 플래그로 실제 재시작 없이 어떤 서비스가 문제인지 먼저 파악할 수 있습니다.


3. 리소스 임계값 모니터링

서비스 프로세스가 살아있어도 디스크가 꽉 차거나 메모리가 부족하면 시스템이 정상적으로 동작하지 않습니다. dffree 출력을 awk로 파싱해 임계값을 체크합니다.

디스크 & 메모리 임계값 알림 스크립트
로컬 터미널
sudo tee /opt/healthcheck/check_resources.sh > /dev/null << 'EOF'
#!/usr/bin/env bash
# check_resources.sh — 디스크 및 메모리 임계값 모니터링

set -uo pipefail

# ──────────────────────────────────────────
# 임계값 설정 (퍼센트)
# ──────────────────────────────────────────
DISK_WARN_THRESHOLD=75
DISK_CRIT_THRESHOLD=90
MEM_WARN_THRESHOLD=80
MEM_CRIT_THRESHOLD=95

LOG_FILE="/var/log/healthcheck/resources.log"
ALERT_FLAG=false

timestamp() { date '+%Y-%m-%d %H:%M:%S'; }

log() {
    local level="$1"; shift
    echo "[$(timestamp)] [$level] $*" | tee -a "$LOG_FILE"
}

# ──────────────────────────────────────────
# 디스크 사용률 체크
# df 출력 예시:
# Filesystem     1K-blocks    Used Available Use% Mounted on
# /dev/sda1      41151808 8033792  31013264  21% /
# ──────────────────────────────────────────
check_disk() {
    log "INFO" "--- 디스크 사용률 점검 ---"

    # -x tmpfs: tmpfs(가상 파일시스템) 제외
    # NR>1: 헤더 행 건너뜀
    # $5: "Use%" 컬럼에서 숫자만 추출
    while IFS= read -r line; do
        local usage
        local mountpoint
        usage=$(echo "$line" | awk '{gsub(/%/, "", $5); print $5}')
        mountpoint=$(echo "$line" | awk '{print $6}')

        if [[ -z "$usage" || -z "$mountpoint" ]]; then
            continue
        fi

        if [[ "$usage" -ge "$DISK_CRIT_THRESHOLD" ]]; then
            log "CRIT" "디스크 임계값 초과: $mountpoint 사용률 ${usage}% (임계값: ${DISK_CRIT_THRESHOLD}%)"
            ALERT_FLAG=true
        elif [[ "$usage" -ge "$DISK_WARN_THRESHOLD" ]]; then
            log "WARN" "디스크 경고: $mountpoint 사용률 ${usage}% (경고값: ${DISK_WARN_THRESHOLD}%)"
            ALERT_FLAG=true
        else
            log "OK" "디스크 정상: $mountpoint 사용률 ${usage}%"
        fi
    done < <(df -h -x tmpfs -x devtmpfs | tail -n +2)
}

# ──────────────────────────────────────────
# 메모리 사용률 체크
# free -m 출력 예시:
#               total        used        free      shared  buff/cache   available
# Mem:           7822        3241         512         108        4068        4246
# ──────────────────────────────────────────
check_memory() {
    log "INFO" "--- 메모리 사용률 점검 ---"

    # total과 used를 읽어 퍼센트 계산
    # buff/cache는 실질적으로 사용 가능한 메모리이므로 available 기준으로 판단
    local mem_info
    mem_info=$(free -m | awk '/^Mem:/ {
        total=$2; available=$7;
        used=total-available;
        pct=int(used*100/total);
        print total, used, available, pct
    }')

    local total used available pct
    read -r total used available pct <<< "$mem_info"

    log "INFO" "메모리 총계: ${total}MB | 사용: ${used}MB | 가용: ${available}MB | 사용률: ${pct}%"

    if [[ "$pct" -ge "$MEM_CRIT_THRESHOLD" ]]; then
        log "CRIT" "메모리 임계값 초과: ${pct}% (임계값: ${MEM_CRIT_THRESHOLD}%)"
        ALERT_FLAG=true
    elif [[ "$pct" -ge "$MEM_WARN_THRESHOLD" ]]; then
        log "WARN" "메모리 경고: ${pct}% (경고값: ${MEM_WARN_THRESHOLD}%)"
        ALERT_FLAG=true
    else
        log "OK" "메모리 정상: ${pct}%"
    fi
}

main() {
    mkdir -p "$(dirname "$LOG_FILE")"
    log "INFO" "=== 리소스 헬스체크 시작 ==="
    check_disk
    check_memory
    log "INFO" "=== 리소스 헬스체크 완료 ==="

    # ALERT_FLAG가 true면 종료코드 1 반환 (cron에서 메일 알림 활용 가능)
    if [[ "$ALERT_FLAG" == "true" ]]; then
        exit 1
    fi
}

main "$@"
EOF

sudo chmod +x /opt/healthcheck/check_resources.sh
sudo /opt/healthcheck/check_resources.sh

awkgsub(/%/, "", $5)는 "Use%" 컬럼에서 % 기호를 제거해 숫자 비교가 가능하게 합니다. df-x tmpfs -x devtmpfs 옵션을 주면 항상 100%로 표시되는 가상 파일시스템을 걸러낼 수 있습니다.


4. HTTP & 포트 헬스체크

프로세스가 살아있다고 해서 서비스가 실제로 요청을 처리하는 것은 아닙니다. nginx가 실행 중이지만 502를 반환하고 있을 수도 있습니다. curl로 HTTP 상태코드를, nc로 포트 개방 여부를 직접 확인하는 방법을 살펴봅니다.

HTTP 상태코드 및 포트 확인 스크립트
로컬 터미널
sudo tee /opt/healthcheck/check_endpoints.sh > /dev/null << 'EOF'
#!/usr/bin/env bash
# check_endpoints.sh — HTTP 엔드포인트 및 TCP 포트 헬스체크

set -uo pipefail

LOG_FILE="/var/log/healthcheck/endpoints.log"
CURL_TIMEOUT=5       # 연결 타임아웃 (초)
CURL_MAX_TIME=10     # 전체 요청 최대 시간 (초)

# ──────────────────────────────────────────
# HTTP 엔드포인트 목록
# 형식: "URL 예상_상태코드"
# ──────────────────────────────────────────
HTTP_ENDPOINTS=(
    "http://localhost:80/health 200"
    "http://localhost:8080/api/health 200"
    "http://localhost:3000/ 200"
)

# ──────────────────────────────────────────
# TCP 포트 목록
# 형식: "호스트 포트 서비스명"
# ──────────────────────────────────────────
TCP_PORTS=(
    "localhost 5432 postgresql"
    "localhost 6379 redis"
    "localhost 27017 mongodb"
)

timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
log() { local l="$1"; shift; echo "[$(timestamp)] [$l] $*" | tee -a "$LOG_FILE"; }

# ──────────────────────────────────────────
# HTTP 헬스체크
# curl 옵션 설명:
#   -s          : 진행 표시 숨김 (silent)
#   -o /dev/null: 응답 본문 버림
#   -w "%{http_code}": HTTP 상태코드만 출력
#   --connect-timeout: TCP 연결 타임아웃
#   --max-time  : 전체 요청 타임아웃
# ──────────────────────────────────────────
check_http() {
    log "INFO" "--- HTTP 엔드포인트 점검 ---"

    for entry in "${HTTP_ENDPOINTS[@]}"; do
        local url expected_code
        url=$(echo "$entry" | awk '{print $1}')
        expected_code=$(echo "$entry" | awk '{print $2}')

        local actual_code
        # timeout 명령어로 curl 자체도 감싸서 확실한 타임아웃 보장
        actual_code=$(timeout "$CURL_MAX_TIME" curl \
            -s \
            -o /dev/null \
            -w "%{http_code}" \
            --connect-timeout "$CURL_TIMEOUT" \
            --max-time "$CURL_MAX_TIME" \
            "$url" 2>/dev/null) || actual_code="000"

        if [[ "$actual_code" == "$expected_code" ]]; then
            log "OK" "HTTP $url → $actual_code (예상: $expected_code)"
        elif [[ "$actual_code" == "000" ]]; then
            log "FAIL" "HTTP $url → 연결 실패 또는 타임아웃"
        else
            log "FAIL" "HTTP $url → $actual_code (예상: $expected_code)"
        fi
    done
}

# ──────────────────────────────────────────
# TCP 포트 헬스체크
# nc (netcat) 옵션:
#   -z : 포트 스캔만 수행 (데이터 전송 안 함)
#   -v : 상세 출력 (stderr로 나옴)
#   -w : 타임아웃 (초)
# 대안: bash /dev/tcp 활용 (nc 없는 환경)
# ──────────────────────────────────────────
check_ports() {
    log "INFO" "--- TCP 포트 점검 ---"

    for entry in "${TCP_PORTS[@]}"; do
        local host port service_name
        host=$(echo "$entry" | awk '{print $1}')
        port=$(echo "$entry" | awk '{print $2}')
        service_name=$(echo "$entry" | awk '{print $3}')

        # nc가 있는지 먼저 확인, 없으면 /dev/tcp 사용
        if command -v nc &>/dev/null; then
            if nc -z -w 3 "$host" "$port" 2>/dev/null; then
                log "OK" "PORT $service_name ($host:$port) → 열려 있음"
            else
                log "FAIL" "PORT $service_name ($host:$port) → 닫혀 있거나 연결 거부"
            fi
        else
            # nc가 없을 때 bash 내장 /dev/tcp 활용
            if timeout 3 bash -c "echo >/dev/tcp/$host/$port" 2>/dev/null; then
                log "OK" "PORT $service_name ($host:$port) → 열려 있음 (bash /dev/tcp)"
            else
                log "FAIL" "PORT $service_name ($host:$port) → 닫혀 있거나 연결 거부 (bash /dev/tcp)"
            fi
        fi
    done
}

main() {
    mkdir -p "$(dirname "$LOG_FILE")"
    log "INFO" "=== 엔드포인트 헬스체크 시작 ==="
    check_http
    check_ports
    log "INFO" "=== 엔드포인트 헬스체크 완료 ==="
}

main "$@"
EOF

sudo chmod +x /opt/healthcheck/check_endpoints.sh
sudo /opt/healthcheck/check_endpoints.sh

curl-w "%{http_code}" 옵션은 응답 본문 대신 HTTP 상태코드 숫자만 stdout으로 출력합니다. 이 값을 변수에 담아 비교하는 패턴은 웹 서비스 헬스체크의 핵심입니다. 연결 자체가 실패하면 000이 반환됩니다.


5. Slack Webhook 알림 연동

이상을 감지했을 때 단순히 로그 파일에 기록하는 것으로는 부족합니다. 운영자에게 즉각 알림을 보내야 합니다. Slack의 Incoming Webhook은 별도의 SDK 없이 curl 한 줄로 메시지를 전송할 수 있어 셸 스크립트와 궁합이 좋습니다.

Slack Webhook 알림 함수 구현
로컬 터미널
sudo tee /opt/healthcheck/notify.sh > /dev/null << 'EOF'
#!/usr/bin/env bash
# notify.sh — Slack Webhook 알림 모듈
# 다른 스크립트에서 source ./notify.sh 로 불러와 사용합니다

# ──────────────────────────────────────────
# 환경변수로 Webhook URL 설정
# export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../B.../..."
# 스크립트에 하드코딩하지 마세요!
# ──────────────────────────────────────────

SLACK_TIMEOUT=10
HOSTNAME_SHORT=$(hostname -s)

# ──────────────────────────────────────────
# Slack 메시지 전송 함수
# 인자:
#   $1 — 레벨 (OK / WARN / CRIT / INFO)
#   $2 — 서비스명 또는 체크 대상
#   $3 — 메시지 내용
# ──────────────────────────────────────────
slack_notify() {
    local level="$1"
    local target="$2"
    local message="$3"

    # Webhook URL이 설정되지 않으면 조용히 종료
    if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then
        echo "[NOTIFY] SLACK_WEBHOOK_URL 미설정 — 알림 생략" >&2
        return 0
    fi

    # 레벨별 이모지 및 색상 설정
    local emoji color
    case "$level" in
        OK)   emoji=":white_check_mark:"; color="#36a64f" ;;
        WARN) emoji=":warning:";          color="#ffcc00" ;;
        CRIT) emoji=":rotating_light:";   color="#ff0000" ;;
        *)    emoji=":information_source:"; color="#439fe0" ;;
    esac

    local ts
    ts=$(date '+%Y-%m-%d %H:%M:%S')

    # Slack Block Kit JSON 페이로드 구성
    # printf를 사용해 특수문자 이스케이프 문제 최소화
    local payload
    payload=$(printf '{
        "attachments": [{
            "color": "%s",
            "blocks": [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": "%s *[%s]* `%s` — %s"
                    }
                },
                {
                    "type": "context",
                    "elements": [{
                        "type": "mrkdwn",
                        "text": "호스트: `%s` | 시각: %s"
                    }]
                }
            ]
        }]
    }' "$color" "$emoji" "$level" "$target" "$message" "$HOSTNAME_SHORT" "$ts")

    # curl로 전송, 타임아웃 설정
    local http_code
    http_code=$(timeout "$SLACK_TIMEOUT" curl \
        -s \
        -o /dev/null \
        -w "%{http_code}" \
        -X POST \
        -H "Content-Type: application/json" \
        -d "$payload" \
        "${SLACK_WEBHOOK_URL}" 2>/dev/null) || http_code="000"

    if [[ "$http_code" == "200" ]]; then
        echo "[NOTIFY] Slack 알림 전송 성공 [$level] $target"
    else
        echo "[NOTIFY] Slack 알림 전송 실패 (HTTP $http_code)" >&2
    fi
}

# ──────────────────────────────────────────
# 사용 예시 (직접 실행 시 데모)
# ──────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    echo "=== Slack 알림 모듈 데모 ==="
    echo "SLACK_WEBHOOK_URL 환경변수를 설정한 뒤 아래 함수를 호출하세요:"
    echo '  slack_notify "CRIT" "nginx" "서비스 다운 — 자동 재시작 실패"'
    echo '  slack_notify "OK"   "nginx" "서비스 복구 완료"'
    echo ""

    # 실제 Webhook URL이 있으면 테스트 메시지 전송
    if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then
        slack_notify "INFO" "test" "헬스체크 시스템 알림 테스트 메시지입니다"
    fi
fi
EOF

sudo chmod +x /opt/healthcheck/notify.sh

notify.sh를 독립 모듈로 분리해두면, 다른 헬스체크 스크립트에서 source /opt/healthcheck/notify.sh로 불러와 slack_notify 함수를 바로 사용할 수 있습니다.

check_services.sh에 알림 연동하기:

로컬 터미널
# check_services.sh 상단에 추가
source /opt/healthcheck/notify.sh

# check_and_recover 함수 실패 시 알림 호출
if ! systemctl restart "$service" 2>>"$LOG_FILE"; then
    log "ERROR" "$service 재시작 실패"
    slack_notify "CRIT" "$service" "자동 재시작 실패 — 수동 점검 필요"
    return 1
fi

6. Cron 등록과 절대경로 문제

스크립트가 터미널에서는 잘 동작하는데 cron에서 실행하면 실패하는 경우가 매우 흔합니다. cron은 최소한의 환경변수만 가진 새 셸에서 실행되기 때문에 PATH가 달라집니다.

💡개념

Cron 환경과 절대경로 원칙

cron 최소 환경과 절대경로 원칙 — 쉘 vs cron 환경 비교

터미널에서 직접 실행하면 잘 되는 헬스체크 스크립트가 cron에서는 command not found로 조용히 실패하는 경우가 있습니다. python3/usr/bin/python3에 있어도 cron의 PATH에는 없어서 못 찾는 것입니다. ~/config.yaml로 된 경로도 cron 환경에서는 HOME이 다르게 설정되어 파일을 못 찾습니다. cron은 일반 로그인 세션과 전혀 다른 최소한의 환경으로 실행되기 때문에, 이 차이를 모르면 스크립트가 왜 cron에서만 실패하는지 원인을 찾기 어렵습니다.

cron 작업이 실행될 때의 환경은 일반 사용자 세션과 크게 다릅니다.

항목일반 터미널Cron 환경
$PATH/usr/local/bin:/usr/bin:/bin:.../usr/bin:/bin (최소)
$HOME/home/username/root 또는 /home/username
$USER로그인 사용자root 또는 cron 실행 사용자
.bashrc로드됨로드되지 않음
.bash_profile로드됨로드되지 않음

이 때문에 cron에서 실행되는 스크립트에는 모든 명령어를 절대경로로 작성해야 합니다.

서버 터미널
# 잘못된 예 — cron에서 실패할 수 있음
systemctl restart nginx
curl -s http://localhost/health

# 올바른 예 — 절대경로 명시
/usr/bin/systemctl restart nginx
/usr/bin/curl -s http://localhost/health

또는 스크립트 맨 위에서 PATH를 명시적으로 재정의합니다.

로컬 터미널
#!/usr/bin/env bash
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

Cron 표현식 빠른 참조

*  *  *  *  *  실행할 명령어
│  │  │  │  └─ 요일 (0=일, 7=일)
│  │  │  └──── 월 (1-12)
│  │  └──────── 일 (1-31)
│  └──────────── 시 (0-23)
└────────────────── 분 (0-59)
표현식의미
*/5 * * * *5분마다
0 * * * *매시간 정각
0 2 * * *매일 새벽 2시
0 2 * * 0매주 일요일 새벽 2시
*/1 * * * *1분마다 (최소 단위)

Cron보다 짧은 주기가 필요할 때

cron의 최소 단위는 1분입니다. 30초마다 실행하려면 다음 패턴을 씁니다.

로컬 터미널
# crontab -e
* * * * * /opt/healthcheck/check_services.sh
* * * * * sleep 30 && /opt/healthcheck/check_services.sh

또는 systemd timer를 활용하면 초 단위 정밀도로 스케줄링할 수 있습니다.

Cron 등록 및 로그 관리 설정
로컬 터미널
# 현재 crontab 확인
crontab -l

# crontab 편집
crontab -e

crontab에 다음 내용을 추가합니다:

CRON
# 환경변수 설정 (cron 전용)
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL

# 5분마다 서비스 헬스체크
*/5 * * * * /opt/healthcheck/check_services.sh >> /var/log/healthcheck/cron.log 2>&1

# 10분마다 리소스 체크
*/10 * * * * /opt/healthcheck/check_resources.sh >> /var/log/healthcheck/cron.log 2>&1

# 3분마다 HTTP 엔드포인트 체크
*/3 * * * * /opt/healthcheck/check_endpoints.sh >> /var/log/healthcheck/cron.log 2>&1

cron 로그가 무한히 커지는 걸 막기 위해 logrotate를 설정합니다:

로컬 터미널
sudo tee /etc/logrotate.d/healthcheck > /dev/null << 'EOF'
/var/log/healthcheck/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0640 root root
}
EOF

cron 실행 여부를 확인하려면 시스템 로그를 확인합니다:

로컬 터미널
# Ubuntu / Debian
grep CRON /var/log/syslog | tail -20

# RHEL / CentOS
grep cron /var/log/cron | tail -20

# systemd 기반
journalctl -u cron --since "1 hour ago"

트러블슈팅

증상: 터미널에서 직접 실행하면 정상 동작하지만 cron에서는 오류 없이 조용히 실패하거나 "command not found" 오류가 발생합니다.

원인 1 — PATH 불일치

cron은 /usr/bin:/bin 정도의 최소 PATH만 가집니다. systemctl, curl, nc 등은 /usr/sbin이나 /usr/local/bin에 있을 수 있습니다.

로컬 터미널
# 먼저 명령어의 실제 위치를 확인
which systemctl   # /usr/bin/systemctl
which curl        # /usr/bin/curl
which nc          # /usr/bin/nc 또는 /bin/nc

# crontab 상단에 PATH 명시
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

원인 2 — 상대경로 사용

스크립트 내부에서 source ./notify.sh처럼 상대경로를 쓰면, cron은 작업 디렉터리가 다르기 때문에 파일을 찾지 못합니다.

로컬 터미널
# 잘못된 예
source ./notify.sh
LOG_FILE="logs/healthcheck.log"

# 올바른 예
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/notify.sh"
LOG_FILE="/var/log/healthcheck/healthcheck.log"

원인 3 — 환경변수 미설정

SLACK_WEBHOOK_URL 같은 환경변수는 crontab 파일에서 직접 설정하거나, /etc/environment 또는 별도의 설정 파일을 source해야 합니다.

로컬 터미널
# /opt/healthcheck/env.conf
SLACK_WEBHOOK_URL="https://hooks.slack.com/..."

# crontab에서 source
*/5 * * * * . /opt/healthcheck/env.conf && /opt/healthcheck/check_services.sh

디버깅 방법 — cron 환경 그대로 시뮬레이션:

로컬 터미널
# cron이 사용하는 환경과 동일하게 실행
env -i HOME=/root SHELL=/bin/bash PATH=/usr/bin:/bin /opt/healthcheck/check_services.sh

증상: 헬스체크 스크립트가 5분마다 nginx를 재시작하는데, nginx가 실제로는 설정 파일 오류로 계속 시작 실패합니다. 재시작 시도가 무한 반복되고 로그가 폭증합니다.

원인 — 재시작 횟수 제한 미구현 또는 카운터 파일이 cron 실행 간에 초기화됨

앞서 작성한 check_nginx.shRESTART_COUNT_FILE=/tmp/nginx_restart_count를 보면, /tmp는 재부팅 시 초기화됩니다. 하지만 더 큰 문제는 서비스가 정상화되지 않아도 스크립트가 다음 실행 시 카운터를 초기화하는 경우입니다.

해결책 — 시간 기반 카운터와 backoff 전략:

로컬 터미널
# /opt/healthcheck/lib/restart_guard.sh
RESTART_LOG="/var/log/healthcheck/restart_history.log"
MAX_RESTARTS_PER_HOUR=3

# 최근 1시간 내 재시작 횟수 계산
count_recent_restarts() {
    local service="$1"
    local one_hour_ago
    one_hour_ago=$(date -d '1 hour ago' '+%s' 2>/dev/null || date -v-1H '+%s')

    if [[ ! -f "$RESTART_LOG" ]]; then
        echo "0"
        return
    fi

    awk -v svc="$service" -v cutoff="$one_hour_ago" '
        $3 == svc && $1 > cutoff { count++ }
        END { print count+0 }
    ' "$RESTART_LOG"
}

# 재시작 기록
record_restart() {
    local service="$1"
    echo "$(date '+%s') $(date '+%Y-%m-%d %H:%M:%S') $service" >> "$RESTART_LOG"
}

# 가드: 재시작 가능 여부 확인
can_restart() {
    local service="$1"
    local count
    count=$(count_recent_restarts "$service")

    if [[ "$count" -ge "$MAX_RESTARTS_PER_HOUR" ]]; then
        echo "false"
        return
    fi
    echo "true"
}

systemd의 재시작 제한과 연계:

systemd 자체도 재시작 제한 기능이 있습니다. /etc/systemd/system/nginx.service.d/restart-limit.conf를 만들어 두 겹으로 보호합니다.

INI
[Service]
# 10초 내에 5번 재시작 시도 후 포기
StartLimitIntervalSec=10
StartLimitBurst=5
Restart=on-failure
RestartSec=5

증상: check_endpoints.sh가 특정 시간대에 완료되지 않고 cron 프로세스가 쌓입니다. ps aux를 보면 같은 스크립트가 여러 개 실행 중입니다.

원인 — curl 타임아웃 미적용 또는 timeout 명령어 미사용

curl에 --connect-timeout--max-time을 지정했더라도 일부 엣지 케이스(TCP 연결은 됐지만 응답이 안 오는 경우)에서 행이 발생할 수 있습니다. 또한 스크립트 자체에 timeout 래퍼가 없으면 해당 cron 주기 내내 프로세스가 살아있게 됩니다.

해결책 1 — timeout 명령어로 스크립트 전체를 감쌈:

로컬 터미널
# crontab에서
*/3 * * * * timeout 120 /opt/healthcheck/check_endpoints.sh >> /var/log/healthcheck/cron.log 2>&1

timeout 120은 120초(2분) 후 스크립트를 강제 종료합니다. cron 주기가 3분이면 이전 실행이 반드시 2분 안에 종료되므로 프로세스 중첩이 방지됩니다.

해결책 2 — 잠금 파일(lock file)로 중복 실행 방지:

로컬 터미널
#!/usr/bin/env bash
LOCK_FILE="/tmp/check_endpoints.lock"

# 잠금 파일 존재 확인 (이미 실행 중인지 체크)
if [ -f "$LOCK_FILE" ]; then
    # 잠금 파일 내 PID가 실제 실행 중인지 확인
    OLD_PID=$(cat "$LOCK_FILE")
    if kill -0 "$OLD_PID" 2>/dev/null; then
        echo "이미 실행 중 (PID: $OLD_PID). 종료합니다."
        exit 0
    else
        echo "이전 잠금 파일 제거 (PID $OLD_PID 는 종료됨)"
        rm -f "$LOCK_FILE"
    fi
fi

# 현재 PID로 잠금 파일 생성
echo $$ > "$LOCK_FILE"

# 스크립트 종료 시 잠금 파일 자동 제거
trap 'rm -f "$LOCK_FILE"' EXIT

# ... 실제 작업 ...

해결책 3 — curl에 추가 옵션 적용:

로컬 또는 서버
curl \
    -s \
    -o /dev/null \
    -w "%{http_code}" \
    --connect-timeout 3 \
    --max-time 8 \
    --retry 0 \        # 재시도 비활성화 (헬스체크에서는 직접 관리)
    --no-keepalive \   # Keep-Alive 비활성화로 연결 빠르게 종료
    "$url"

실무 적용 패턴

💼
실무 맥락
현업 패턴

금융권이나 공공기관의 온프레미스 서버는 Prometheus 같은 외부 도구를 설치하기 위해 별도의 보안 심사를 받아야 하는 경우가 많습니다. 이런 환경에서 헬스체크 스크립트는 사실상 유일한 자동화 수단입니다.

실무에서 자주 마주치는 시나리오:

  1. Java 웹 애플리케이션 서버(Tomcat/JBoss) — 프로세스는 살아있지만 메모리 부족(OOM)으로 응답이 없는 상태. curl/health 엔드포인트를 직접 확인하는 방법만 신뢰할 수 있습니다.

  2. 야간 배치 작업 후 데몬 비활성화 — 야간 배치가 끝나면서 부산물로 웹 서버가 함께 내려가는 버그. cron으로 새벽 5시부터 30분마다 서비스 상태를 체크하고, 개발팀 Slack 채널로 알림을 보냅니다.

  3. 디스크 풀(full)로 인한 서비스 장애 예방 — 로그 파티션이 100%가 되면 nginx가 접근 로그를 쓰지 못해 502를 반환합니다. 디스크 임계값 알림으로 90%가 되는 시점에 미리 정리합니다.

로컬 터미널
# 디스크 정리 + 알림을 결합한 자동 복구 예시
check_and_cleanup_disk() {
    local usage
    usage=$(df -h / | awk 'NR==2 {gsub(/%/,""); print $5}')

    if [[ "$usage" -ge 90 ]]; then
        slack_notify "WARN" "disk" "루트 파티션 ${usage}% — 자동 정리 시작"

        # journald 로그 정리 (2주 이상 된 것)
        journalctl --vacuum-time=14d

        # 오래된 도커 이미지 정리 (도커 환경일 경우)
        if command -v docker &>/dev/null; then
            docker image prune -f --filter "until=720h"
        fi

        local usage_after
        usage_after=$(df -h / | awk 'NR==2 {gsub(/%/,""); print $5}')
        slack_notify "INFO" "disk" "정리 완료 — ${usage}% → ${usage_after}%"
    fi
}

운영 팁: 스크립트를 Git으로 관리하고, 변경 시마다 /opt/healthcheck/ 서버에 자동 배포하는 파이프라인을 구성하면 여러 서버에 동일한 스크립트를 일관되게 유지할 수 있습니다.

💼
실무 맥락
현업 패턴

Kubernetes나 ECS 같은 컨테이너 오케스트레이터는 자체 헬스체크(liveness probe, readiness probe)를 가지고 있지만, 외부에서 바라보는 시각이 필요할 때가 있습니다. 또한 서비스 메시(Istio 등)를 사용하더라도 사이드카 외부의 호스트 레벨 체크는 별도로 필요합니다.

실제 활용 예 — 다중 서비스 통합 헬스체크 대시보드:

로컬 터미널
#!/usr/bin/env bash
# dashboard.sh — 터미널 헬스 대시보드 (watch 명령어와 조합)
# 사용법: watch -n 5 /opt/healthcheck/dashboard.sh

export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

SERVICES=("nginx" "postgresql" "redis" "docker" "sshd")
HTTP_CHECKS=(
    "Gateway:http://localhost:8080/health"
    "Auth:http://localhost:3001/health"
    "Catalog:http://localhost:3002/health"
    "Cart:http://localhost:3003/health"
)

echo "========================================"
echo " 서버 헬스 대시보드 | $(hostname -s)"
echo " $(date '+%Y-%m-%d %H:%M:%S')"
echo "========================================"

echo ""
echo "[ SYSTEMD 서비스 ]"
for svc in "${SERVICES[@]}"; do
    if systemctl is-active --quiet "$svc" 2>/dev/null; then
        printf "  ${GREEN}%-20s ACTIVE${NC}\n" "$svc"
    else
        printf "  ${RED}%-20s INACTIVE${NC}\n" "$svc"
    fi
done

echo ""
echo "[ HTTP 엔드포인트 ]"
for entry in "${HTTP_CHECKS[@]}"; do
    name="${entry%%:*}"
    url="${entry#*:}"
    code=$(timeout 3 curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 "$url" 2>/dev/null || echo "000")
    if [[ "$code" == "200" ]]; then
        printf "  ${GREEN}%-20s %s${NC}\n" "$name" "$code"
    else
        printf "  ${RED}%-20s %s${NC}\n" "$name" "$code"
    fi
done

echo ""
echo "[ 리소스 ]"
disk=$(df -h / | awk 'NR==2 {print $5}')
mem=$(free -m | awk '/^Mem:/ {printf "%.0f%%", ($2-$7)/$2*100}')
load=$(uptime | awk -F'load average:' '{print $2}' | awk '{print $1}' | tr -d ',')
printf "  %-20s %s\n" "디스크 (/):" "$disk"
printf "  %-20s %s\n" "메모리 사용률:" "$mem"
printf "  %-20s %s\n" "1분 로드 평균:" "$load"

echo "========================================"
로컬 터미널
# 5초마다 갱신되는 라이브 대시보드
watch -n 5 /opt/healthcheck/dashboard.sh

이 대시보드는 Grafana가 없는 환경에서도 SSH 접속만으로 전체 서비스 상태를 한눈에 파악할 수 있게 해줍니다. CI/CD 파이프라인에서 배포 후 검증 단계에 통합해 모든 서비스가 200 응답을 반환할 때까지 대기하는 용도로도 활용됩니다.


멱등성 원칙과 안전한 스크립트 설계

💡개념

멱등성(Idempotency) — 몇 번을 실행해도 같은 결과

멱등성 개념 — 여러 번 실행해도 동일한 최종 상태

복구 스크립트를 두 번 실행했더니, 첫 번째는 서비스가 정상 복구됐고 두 번째는 이미 실행 중인 프로세스를 또 시작하려다 포트 충돌이 났습니다. 헬스체크 스크립트는 이상 감지 때마다 반복 실행되기 때문에, 이미 정상 상태인 서버에서 실행되더라도 부작용이 없어야 합니다. 이 성질을 멱등성이라 하며, 운영 자동화 스크립트 설계의 핵심 원칙입니다.

멱등성은 수학 개념에서 온 말이지만, 운영 자동화에서 핵심 원칙입니다. 같은 스크립트를 n번 실행해도 결과가 1번 실행한 것과 동일해야 한다는 뜻입니다.

멱등하지 않은 예 — 실행할 때마다 상태가 달라짐:

서버 터미널
# 나쁜 예: 실행할 때마다 재시작
systemctl restart nginx   # 정상이어도 무조건 재시작

멱등한 예 — 이상 있을 때만 동작:

로컬 터미널
# 좋은 예: 이상 상태일 때만 재시작
if ! systemctl is-active --quiet nginx; then
    systemctl restart nginx
fi

안전한 스크립트 작성 체크리스트

로컬 터미널
#!/usr/bin/env bash

# 1. 엄격한 오류 처리
set -euo pipefail
#   -e : 오류 발생 시 즉시 종료
#   -u : 미정의 변수 사용 시 오류
#   -o pipefail : 파이프 내 오류도 감지

# 2. 함수 내에서는 set -e가 의도대로 동작하지 않을 수 있으므로
#    명시적 || 처리
systemctl restart nginx || { echo "재시작 실패"; exit 1; }

# 3. 임시 파일은 trap으로 반드시 정리
TMPFILE=$(mktemp)
trap 'rm -f "$TMPFILE"' EXIT

# 4. 커맨드 치환에서 공백/개행 처리
# 안전한 방법: 큰따옴표로 감쌈
SERVICE_STATUS="$(systemctl is-active nginx)"

# 5. 숫자 비교는 -eq/-gt/-lt, 문자열 비교는 == 사용
if [[ "$count" -gt 0 ]]; then ...
if [[ "$status" == "active" ]]; then ...

# 6. 스크립트에 버전/날짜 주석
# version: 1.2.0
# last-modified: 2026-03-26

timeout 명령어 활용 패턴

외부 명령어가 얼마나 걸릴지 모를 때 timeout으로 상한을 설정합니다.

로컬 터미널
# 기본 사용법
timeout 10 curl http://localhost/health

# 종료 시그널 지정 (-k: KILL 시그널 추가 전송)
# 5초 후 TERM, 추가로 2초 후 KILL
timeout -k 2 5 some_slow_command

# 종료 코드 확인
if ! timeout 5 curl -s http://localhost/health > /dev/null; then
    if [[ $? -eq 124 ]]; then
        echo "타임아웃 (5초 초과)"
    else
        echo "연결 실패"
    fi
fi

전체 통합 스크립트

지금까지 배운 내용을 하나로 통합한 엔터프라이즈급 헬스체크 스크립트입니다.

로컬 터미널
sudo tee /opt/healthcheck/master_check.sh > /dev/null << 'MASTEREOF'
#!/usr/bin/env bash
# master_check.sh — 통합 헬스체크 & 자가복구 스크립트
# version: 2.0.0
# 사용법: ./master_check.sh [--dry-run] [--silent] [--report]

export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
set -uo pipefail

# ──────────────────────────────────────────────────────────
# 설정
# ──────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="/var/log/healthcheck"
LOG_FILE="$LOG_DIR/master.log"
LOCK_FILE="/tmp/master_check.lock"
RESTART_LOG="$LOG_DIR/restart_history.log"

SERVICES=("nginx" "postgresql" "redis")
HTTP_CHECKS=("http://localhost:80/health:200" "http://localhost:8080/api/health:200")
TCP_CHECKS=("localhost:5432:postgresql" "localhost:6379:redis")

DISK_CRIT=90
MEM_CRIT=95
MAX_RESTARTS_PER_HOUR=3
CURL_TIMEOUT=5

DRY_RUN=false
SILENT=false
REPORT=false

PASS_COUNT=0
FAIL_COUNT=0
SKIP_COUNT=0

# ──────────────────────────────────────────────────────────
# 초기화
# ──────────────────────────────────────────────────────────
init() {
    mkdir -p "$LOG_DIR"

    # 잠금 파일로 중복 실행 방지
    if [[ -f "$LOCK_FILE" ]]; then
        local old_pid
        old_pid=$(cat "$LOCK_FILE")
        if kill -0 "$old_pid" 2>/dev/null; then
            log "WARN" "이미 실행 중 (PID: $old_pid). 종료합니다."
            exit 0
        fi
        rm -f "$LOCK_FILE"
    fi
    echo $$ > "$LOCK_FILE"
    trap 'rm -f "$LOCK_FILE"' EXIT

    # 인자 파싱
    for arg in "$@"; do
        case "$arg" in
            --dry-run) DRY_RUN=true ;;
            --silent)  SILENT=true ;;
            --report)  REPORT=true ;;
        esac
    done
}

# ──────────────────────────────────────────────────────────
# 로깅
# ──────────────────────────────────────────────────────────
log() {
    local level="$1"; shift
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*"
    echo "$msg" >> "$LOG_FILE"
    [[ "$SILENT" == "false" ]] && echo "$msg"
}

# ──────────────────────────────────────────────────────────
# Slack 알림
# ──────────────────────────────────────────────────────────
notify() {
    local level="$1" target="$2" message="$3"
    [[ -z "${SLACK_WEBHOOK_URL:-}" ]] && return 0
    [[ "$DRY_RUN" == "true" ]] && { log "INFO" "[DRY-RUN] Slack 알림: [$level] $target — $message"; return 0; }

    local emoji
    case "$level" in OK) emoji=":white_check_mark:" ;; WARN) emoji=":warning:" ;; CRIT) emoji=":rotating_light:" ;; *) emoji=":info:" ;; esac

    timeout 10 curl -s -o /dev/null -X POST \
        -H "Content-Type: application/json" \
        -d "{\"text\":\"${emoji} *[$level]* \`$(hostname -s)\` | ${target}: ${message}\"}" \
        "${SLACK_WEBHOOK_URL}" 2>/dev/null || true
}

# ──────────────────────────────────────────────────────────
# 재시작 가드
# ──────────────────────────────────────────────────────────
can_restart() {
    local service="$1"
    local cutoff
    cutoff=$(date -d '1 hour ago' '+%s' 2>/dev/null || date -v-1H '+%s' 2>/dev/null || echo "0")
    local count=0

    if [[ -f "$RESTART_LOG" ]]; then
        count=$(awk -v svc="$service" -v t="$cutoff" '$3==svc && $1>t {c++} END{print c+0}' "$RESTART_LOG")
    fi

    [[ "$count" -lt "$MAX_RESTARTS_PER_HOUR" ]]
}

record_restart() {
    echo "$(date '+%s') $(date '+%Y-%m-%d %H:%M:%S') $1" >> "$RESTART_LOG"
}

# ──────────────────────────────────────────────────────────
# 체크 함수들
# ──────────────────────────────────────────────────────────
check_service() {
    local svc="$1"
    if systemctl is-active --quiet "$svc" 2>/dev/null; then
        log "OK" "SERVICE $svc: active"; ((PASS_COUNT++)); return 0
    fi

    log "FAIL" "SERVICE $svc: inactive"
    ((FAIL_COUNT++))

    if ! can_restart "$svc"; then
        log "ERROR" "$svc: 시간당 재시작 한도 초과 — 수동 점검 필요"
        notify "CRIT" "$svc" "재시작 한도 초과, 수동 점검 요망"
        return 1
    fi

    [[ "$DRY_RUN" == "true" ]] && { log "INFO" "[DRY-RUN] $svc 재시작 시뮬레이션"; return 0; }

    record_restart "$svc"
    if systemctl restart "$svc" 2>>"$LOG_FILE"; then
        log "INFO" "$svc 재시작 성공"
        notify "WARN" "$svc" "비활성 감지 → 자동 재시작 완료"
    else
        log "ERROR" "$svc 재시작 실패"
        notify "CRIT" "$svc" "재시작 실패 — 즉시 점검 필요"
        return 1
    fi
}

check_http() {
    local entry="$1"
    local url="${entry%%:*}"
    local expected="${entry##*:}"
    local code
    code=$(timeout "$CURL_TIMEOUT" curl -s -o /dev/null -w "%{http_code}" \
        --connect-timeout 3 --max-time "$CURL_TIMEOUT" "$url" 2>/dev/null) || code="000"

    if [[ "$code" == "$expected" ]]; then
        log "OK" "HTTP $url: $code"; ((PASS_COUNT++))
    else
        log "FAIL" "HTTP $url: $code (예상: $expected)"; ((FAIL_COUNT++))
        notify "WARN" "$url" "HTTP $code (예상: $expected)"
    fi
}

check_tcp() {
    local entry="$1"
    IFS=':' read -r host port name <<< "$entry"
    if timeout 3 bash -c "echo >/dev/tcp/$host/$port" 2>/dev/null; then
        log "OK" "TCP $name ($host:$port): open"; ((PASS_COUNT++))
    else
        log "FAIL" "TCP $name ($host:$port): closed"; ((FAIL_COUNT++))
        notify "WARN" "$name" "포트 $port 응답 없음"
    fi
}

check_disk() {
    while IFS= read -r line; do
        local usage mp
        usage=$(echo "$line" | awk '{gsub(/%/,"",$5); print $5}')
        mp=$(echo "$line" | awk '{print $6}')
        [[ -z "$usage" ]] && continue
        if [[ "$usage" -ge "$DISK_CRIT" ]]; then
            log "CRIT" "DISK $mp: ${usage}%"; ((FAIL_COUNT++))
            notify "CRIT" "disk:$mp" "${usage}% 사용 중"
        else
            log "OK" "DISK $mp: ${usage}%"; ((PASS_COUNT++))
        fi
    done < <(df -h -x tmpfs -x devtmpfs | tail -n +2)
}

check_memory() {
    local pct
    pct=$(free -m | awk '/^Mem:/ {printf "%.0f", ($2-$7)/$2*100}')
    if [[ "$pct" -ge "$MEM_CRIT" ]]; then
        log "CRIT" "MEMORY: ${pct}%"; ((FAIL_COUNT++))
        notify "CRIT" "memory" "${pct}% 사용 중"
    else
        log "OK" "MEMORY: ${pct}%"; ((PASS_COUNT++))
    fi
}

print_report() {
    [[ "$REPORT" == "false" ]] && return
    local total=$((PASS_COUNT + FAIL_COUNT + SKIP_COUNT))
    log "INFO" "=============================="
    log "INFO" "최종 리포트: 총 ${total}건 | 정상 ${PASS_COUNT} | 실패 ${FAIL_COUNT} | 건너뜀 ${SKIP_COUNT}"
    log "INFO" "=============================="
}

# ──────────────────────────────────────────────────────────
# 메인
# ──────────────────────────────────────────────────────────
main() {
    init "$@"
    log "INFO" "=== 통합 헬스체크 시작 (DRY_RUN=$DRY_RUN) ==="

    for svc in "${SERVICES[@]}"; do check_service "$svc" || true; done
    for ep  in "${HTTP_CHECKS[@]}"; do check_http "$ep"    || true; done
    for tcp in "${TCP_CHECKS[@]}";  do check_tcp "$tcp"    || true; done
    check_disk   || true
    check_memory || true

    print_report
    log "INFO" "=== 통합 헬스체크 완료 ==="
}

main "$@"
MASTEREOF

sudo chmod +x /opt/healthcheck/master_check.sh

# 테스트 실행
sudo /opt/healthcheck/master_check.sh --dry-run --report
💡개념

재시작 루프 방지 — 횟수 제한·쿨다운·systemd StartLimit 연계

재시작 루프 방지 — StartLimitIntervalSec·쿨다운 패턴

헬스체크 스크립트가 서비스를 자동으로 재시작할 때 재시작 루프에 빠지지 않도록 반드시 횟수와 쿨다운을 제한해야 합니다.

쉘 스크립트 레벨 횟수 제한:

로컬 터미널
# /opt/healthcheck/restart_nginx.sh
STATE_FILE="/tmp/nginx_restart_state"
MAX_RESTARTS=3
COOLDOWN_SECONDS=300  # 5분 이내 MAX_RESTARTS 초과 시 중단

NOW=$(date +%s)

# 상태 파일에서 재시작 이력 읽기
if [ -f "$STATE_FILE" ]; then
    source "$STATE_FILE"
else
    RESTART_COUNT=0
    WINDOW_START=$NOW
fi

# 5분 윈도우가 지났으면 카운터 초기화
if [ $((NOW - WINDOW_START)) -gt $COOLDOWN_SECONDS ]; then
    RESTART_COUNT=0
    WINDOW_START=$NOW
fi

# 최대 재시작 횟수 초과 시 알람만 발송하고 중단
if [ $RESTART_COUNT -ge $MAX_RESTARTS ]; then
    echo "[CRITICAL] nginx 5분 내 ${MAX_RESTARTS}회 재시작 실패 — 수동 개입 필요"
    # 알림 발송 (예: curl로 Slack/PagerDuty webhook)
    exit 1
fi

# 재시작 실행
sudo systemctl restart nginx
RESTART_COUNT=$((RESTART_COUNT + 1))

# 상태 저장
echo "RESTART_COUNT=$RESTART_COUNT" > "$STATE_FILE"
echo "WINDOW_START=$WINDOW_START" >> "$STATE_FILE"

systemd StartLimit 연계 (권장):

직접 재시작 루프를 제어하는 것보다 systemd의 StartLimit을 활용하면 더 안정적입니다.

INI
# /etc/systemd/system/nginx.service.d/restart-limit.conf
[Unit]
StartLimitIntervalSec=300   # 5분 안에
StartLimitBurst=3           # 3번 재시작 실패 시 → failed 상태로 전환

[Service]
Restart=on-failure
RestartSec=10               # 재시작 전 10초 대기
서버 터미널
# StartLimit 도달 후 수동 복구
sudo systemctl reset-failed nginx
sudo systemctl start nginx

쿨다운 없는 재시작 루프의 위험:

시나리오결과
DB 연결 실패 → 헬스체크 fail → 즉시 재시작 반복초당 수십 번 재시작, DB에 연결 폭탄
설정 파일 오류 → 서비스 시작 실패 → 즉시 재시작로그 폭발, 디스크 가득 참
네트워크 일시 단절 → 헬스체크 timeout → 재시작정상 서비스를 불필요하게 중단
💡개념

스크립트 동시 실행 방지 — flock(lockfile) + 타임아웃/재시도 정책

flock 뮤텍스 패턴 — 스크립트 동시 실행 방지

cron이 1분마다 헬스체크를 실행할 때, 이전 실행이 끝나기 전에 새 실행이 시작되면 동시에 서비스를 재시작하거나 상태 파일이 꼬일 수 있습니다.

flock으로 동시 실행 방지:

로컬 터미널
#!/bin/bash
# /opt/healthcheck/check_nginx.sh — flock으로 단일 실행 보장

LOCKFILE="/var/lock/check_nginx.lock"
TIMEOUT=30  # 30초 이내 락 획득 실패 시 종료

# flock: -n은 즉시 실패, -w는 대기 후 실패
exec 200>"$LOCKFILE"
if ! flock -w "$TIMEOUT" 200; then
    echo "[WARN] 이전 헬스체크가 아직 실행 중 — 이번 실행 건너뜀"
    exit 0
fi

# 락 획득 성공 — 실제 헬스체크 수행
if ! systemctl is-active --quiet nginx; then
    echo "[$(date)] nginx 중단 감지 — 재시작 시도"
    sudo systemctl restart nginx
fi
# flock은 스크립트 종료 시 자동 해제

curl 헬스체크 타임아웃 설정:

로컬 터미널
# 타임아웃 없는 curl은 영원히 hang할 수 있음
# --max-time: 전체 최대 시간, --connect-timeout: 연결 최대 시간
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
    --max-time 10 \
    --connect-timeout 3 \
    http://localhost/health)

if [ "$HTTP_CODE" != "200" ]; then
    echo "[WARN] 헬스체크 실패: HTTP $HTTP_CODE"
fi

재시도 정책 표준:

로컬 터미널
# 일시적 장애와 지속적 장애를 구분하는 재시도 패턴
MAX_RETRY=3
RETRY_INTERVAL=5

for i in $(seq 1 $MAX_RETRY); do
    if curl -sf --max-time 5 http://localhost/health > /dev/null; then
        exit 0  # 성공
    fi
    echo "[WARN] 시도 $i/$MAX_RETRY 실패"
    sleep $RETRY_INTERVAL
done

# MAX_RETRY 모두 실패 시에만 장애로 판단
echo "[ERROR] 헬스체크 ${MAX_RETRY}회 모두 실패 — 서비스 이상"

정리

이 챕터에서 배운 핵심 패턴을 요약합니다.

목적도구핵심 옵션
서비스 상태 확인systemctl is-active--quiet
HTTP 응답 코드 확인curl-s -o /dev/null -w "%{http_code}"
TCP 포트 확인nc 또는 /dev/tcp-z -w 3
디스크 사용률df | awk-h -x tmpfs
메모리 사용률free | awk-m
타임아웃 설정timeouttimeout 10 <명령어>
Slack 알림curl-X POST -H "Content-Type: application/json"
중복 실행 방지잠금 파일 + traptrap 'rm -f "$LOCK"' EXIT

기억해야 할 원칙 세 가지:

  1. 멱등성 — 몇 번을 실행해도 부작용이 없어야 합니다
  2. 절대경로 — cron 환경에서는 모든 명령어를 절대경로로 사용합니다
  3. 재시작 루프 방지 — 시간 기반 카운터로 무한 재시작을 차단합니다

다음 챕터에서는 systemd timer를 활용해 cron보다 정밀한 스케줄링과, 실패 시 자동 알림이 내장된 모니터링 유닛을 작성하는 방법을 배웁니다.

지식 확인

퀴즈 — 5문제

Q1

nginx를 재시작하는 헬스체크 스크립트를 작성 중입니다. systemctl restart nginx 실행 후 성공 여부에 따라 슬랙 알림을 다르게 보내려 합니다. 올바른 분기 패턴은?

Q2

curl로 HTTP 상태코드만 숫자로 출력하고 싶을 때 사용하는 옵션 조합은?

Q3

디스크 사용률이 80%를 초과했을 때 경고를 출력하는 스크립트에서, df 출력에서 사용률 숫자만 추출하는 올바른 파이프라인은?

Q4

systemd에서 서비스 실패 시 자동으로 다른 유닛을 실행하도록 지정하는 [Unit] 섹션 키는?

Q5

헬스체크 스크립트를 cron으로 5분마다 실행하려 할 때 올바른 crontab 표현은?

0 / 5 답변

🧪 실습으로 확인하기

새 서버 인수인계 — 처음 30분

초급

낯선 Linux 서버를 인수받았을 때 OS, 서비스, 로그를 빠르게 파악하는 루틴을 직접 수행한다.

30📋 3단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

linux고급 · 70
[Linux] sysctl 커널 파라미터 튜닝으로 대규모 트래픽 처리 성능 높이기
Linux 트랙 계속
docker입문 · 30
[Docker] 백엔드 개발자에게 Docker와 컨테이너 가상화가 필수인 이유
Docker 트랙 시작점