infra
Platform

모듈 맵

[Linux] Ansible과 스크립트로 여러 대의 리눅스 서버 한 번에 구축하기

0 / 37 완료

펼치기
0 / 37 완료0%

Linux · 36 / 37

[Linux] Ansible과 스크립트로 여러 대의 리눅스 서버 한 번에 구축하기

Shell과 Python으로 다수 서버를 코드로 관리 — 멱등성과 예외처리 완전 가이드

🚨INCIDENT ALERT
HIGH

신규 서비스를 배포하기 위해 웹 서버 10대에 nginx, 방화벽 규칙, 앱 설정을 하나하나 SSH로 접속해 적용했습니다. 3시간 뒤, 서버 한 대가 패키지 설치 중에 실패했는데 어느 서버인지 기억이 나지 않습니다. 나중에 같은 작업을 다시 실행했더니 이미 처리된 서버에서 "이미 존재한다"는 에러가 발생합니다. 자동화 스크립트가 멱등성을 갖추고 있었다면, 몇 번을 다시 실행해도 안전하게 원하는 상태로 맞춰줬을 겁니다.

인프라 자동화 스크립트

운영 환경에서 수십 대, 수백 대의 서버를 사람이 직접 하나씩 관리하는 것은 불가능합니다. 실수가 발생하고, 시간이 낭비되며, 무엇보다 반복 작업은 엔지니어의 집중력을 소모시킵니다. 이 챕터에서는 Shell 스크립트와 Python을 활용해 다수 서버를 코드로 관리하는 방법을 체계적으로 학습합니다.

핵심은 두 가지입니다. 멱등성(Idempotency) — 같은 스크립트를 몇 번 실행해도 결과가 동일해야 한다는 원칙, 그리고 견고한 예외처리 — 어떤 상황에서도 시스템이 일관된 상태를 유지하도록 보장하는 기법입니다.


이번 챕터에서 배울 것
  • 1멱등성(Idempotency) 원칙과 상태 확인 후 조건부 실행 패턴
  • 2Bash 예외처리: set -euo pipefail, trap ERR, 롤백 설계
  • 3Ansible 인벤토리·플레이북·롤 구조와 ad-hoc 명령
  • 4ansible-vault로 민감 정보 AES256 암호화 관리
  • 5Python paramiko/fabric을 이용한 다중 서버 병렬 자동화
실습 환경 준비
Ansible 설치
sudo apt install -y ansible # Ubuntu/Debian pip3 install ansible # pip 방식
SSH 키 기반 인증 준비 (패스워드 없이 접속)
ssh-keygen -t ed25519 && ssh-copy-id user@target-host
Python 자동화 라이브러리 설치
pip3 install paramiko fabric

1. 멱등성(Idempotency) 원칙

💡개념

멱등성이란 무엇인가

멱등성(Idempotency) — 자동화 스크립트의 핵심 원칙과 패턴

배포 스크립트를 실수로 두 번 실행했더니 같은 서비스가 두 번 시작되어 포트 충돌이 났습니다. 또는 패키지 설치 스크립트를 여러 서버에 순차적으로 돌리다가 중간에 실패해서 처음부터 다시 돌렸는데, 이미 처리된 서버에서 "이미 존재한다"는 에러가 터집니다. 멱등성은 "몇 번 실행해도 처음 한 번 실행한 것과 같은 결과"를 보장하는 성질입니다. Ansible, Terraform 같은 도구가 이 원칙을 설계의 핵심으로 삼는 이유는, 자동화가 실패했을 때 안전하게 재실행할 수 있어야 하기 때문입니다.

**멱등성(Idempotency)**은 수학과 컴퓨터 과학에서 동일한 연산을 여러 번 적용해도 결과가 처음 적용했을 때와 같다는 성질입니다. 인프라 자동화에서 이 원칙은 매우 중요합니다.

f(f(x)) = f(x)

예를 들어, "nginx를 설치하라"는 명령이 멱등적이라면:

  • 처음 실행: nginx 패키지를 설치함
  • 두 번째 실행: 이미 설치되어 있음을 확인하고 아무것도 하지 않음
  • 세 번째 실행: 동일하게 아무것도 하지 않음

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

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/linux/part6/exam_322/{scripts,configs,logs,inventory}
cd /tmp/linux/part6/exam_32

# 서버 인벤토리 파일
cat > inventory/servers.txt << 'EOF'
# 형식: hostname ip role
web-01 192.168.1.10 web
web-02 192.168.1.11 web
app-01 192.168.1.20 app
db-01  192.168.1.30 db
EOF

# 자동화 스크립트 기본 틀
cat > scripts/deploy.sh << 'EOF'
#!/bin/bash
set -euo pipefail

APP_DIR="/tmp/linux/part6/exam_32"
LOG_FILE="$APP_DIR/logs/deploy_$(date +%Y%m%d_%H%M%S).log"
VERSION="${1:-latest}"

log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG_FILE"; }

log "배포 시작: version=$VERSION"
log "환경 확인..."
log "배포 완료"
EOF
chmod +x scripts/deploy.sh

이제 실습을 진행합니다.

멱등성이 없는 스크립트의 문제:

멱등성 없이 작성한 스크립트는 두 번째 실행에서 예기치 않은 부작용이 생깁니다. 아래가 전형적인 실수 패턴입니다.

로컬 터미널
# 위험! 실행할 때마다 사용자가 중복 생성됨
useradd deploy-user
echo "deploy ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

# 위험! 실행할 때마다 같은 줄이 중복 추가됨
echo "export PATH=$PATH:/opt/tools/bin" >> ~/.bashrc

멱등적인 스크립트로 개선:

로컬 터미널
# 사용자가 없을 때만 생성
if ! id "deploy-user" &>/dev/null; then
    useradd -m -s /bin/bash deploy-user
    echo "[INFO] deploy-user 생성 완료"
else
    echo "[INFO] deploy-user 이미 존재함 — 건너뜀"
fi

# 줄이 없을 때만 추가
BASHRC_LINE='export PATH=$PATH:/opt/tools/bin'
if ! grep -qF "$BASHRC_LINE" ~/.bashrc; then
    echo "$BASHRC_LINE" >> ~/.bashrc
    echo "[INFO] PATH 설정 추가 완료"
else
    echo "[INFO] PATH 설정 이미 존재함 — 건너뜀"
fi

멱등성 구현 패턴:

패턴설명예시
존재 확인 후 생성자원이 없을 때만 생성id user, [ -f file ]
내용 확인 후 추가내용이 없을 때만 추가grep -qF "line" file
버전 확인 후 설치버전이 다를 때만 설치dpkg -l package
상태 확인 후 실행상태가 다를 때만 실행systemctl is-active svc

멱등성은 Ansible, Terraform, Chef 같은 모든 현대 인프라 도구의 핵심 설계 원칙입니다. 직접 스크립트를 작성할 때도 이 원칙을 철저히 지켜야 합니다.


2. 견고한 Shell 스크립트 기초

2.1 set -euo pipefail

모든 프로덕션 Shell 스크립트는 다음 세 가지 옵션으로 시작해야 합니다.

로컬 터미널
#!/usr/bin/env bash
set -euo pipefail

각 옵션이 하는 일을 이해합시다.

로컬 터미널
#!/usr/bin/env bash
# set -e  : 명령이 0이 아닌 종료 코드를 반환하면 즉시 스크립트 종료
# set -u  : 정의되지 않은 변수를 참조하면 즉시 오류 발생
# set -o pipefail : 파이프 중간의 명령이 실패해도 전체 파이프를 실패로 간주

set -euo pipefail

# -e 예시: 이 명령이 실패하면 스크립트가 즉시 종료됨
# cp nonexistent.txt /tmp/  # 이 줄에서 종료

# -u 예시: UNDEFINED_VAR가 없으면 즉시 오류
# echo "$UNDEFINED_VAR"  # 오류: unbound variable

# -o pipefail 예시:
# cat /nonexistent | grep pattern
# cat이 실패해도 grep이 성공하면 -e만으로는 잡히지 않음
# pipefail이 있어야 파이프 전체가 실패로 처리됨

set -e의 함정과 예외 처리:

로컬 터미널
#!/usr/bin/env bash
set -euo pipefail

# 실패해도 괜찮은 명령은 || true 또는 || : 를 사용
grep -q "pattern" file.txt || true

# 조건문 안에서는 -e가 적용되지 않음 (의도된 동작)
if grep -q "already_installed" /var/log/install.log; then
    echo "이미 설치됨"
fi

# 변수에 기본값 설정 (-u와 함께 사용)
CONFIG_FILE="${CONFIG_FILE:-/etc/myapp/config.yml}"
LOG_LEVEL="${LOG_LEVEL:-INFO}"
MAX_RETRIES="${MAX_RETRIES:-3}"

2.2 trap으로 예외 상황 처리

trap은 Shell 스크립트에서 신호(Signal)나 종료 이벤트를 가로채어 특정 함수를 실행하게 해주는 메커니즘입니다.

로컬 터미널
#!/usr/bin/env bash
set -euo pipefail

# ================================================================
# trap 예외처리 완전 예제
# ================================================================

# 전역 변수
SCRIPT_NAME="$(basename "$0")"
LOG_FILE="/var/log/infra-automation/${SCRIPT_NAME%.sh}.log"
LOCK_FILE="/tmp/${SCRIPT_NAME%.sh}.lock"
TEMP_DIR=""

# 로그 함수
log_info()  { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]  $*" | tee -a "$LOG_FILE"; }
log_warn()  { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN]  $*" | tee -a "$LOG_FILE"; }
log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" | tee -a "$LOG_FILE" >&2; }

# EXIT trap: 스크립트가 어떤 방식으로든 종료될 때 실행
cleanup() {
    local exit_code=$?
    log_info "정리 작업 시작 (종료 코드: $exit_code)"

    # 임시 디렉토리 삭제
    if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
        rm -rf "$TEMP_DIR"
        log_info "임시 디렉토리 삭제: $TEMP_DIR"
    fi

    # 락 파일 삭제
    if [[ -f "$LOCK_FILE" ]]; then
        rm -f "$LOCK_FILE"
        log_info "락 파일 삭제: $LOCK_FILE"
    fi

    if [[ $exit_code -eq 0 ]]; then
        log_info "스크립트 정상 완료"
    else
        log_error "스크립트 비정상 종료 (코드: $exit_code)"
    fi
}

# ERR trap: 명령 실패 시 (set -e와 함께 사용)
on_error() {
    local exit_code=$?
    local line_number="${BASH_LINENO[0]}"
    local command="${BASH_COMMAND}"
    log_error "명령 실패: '${command}' (라인 ${line_number}, 종료 코드: ${exit_code})"
    # ERR trap 후 EXIT trap도 자동으로 실행됨
}

# INT trap: Ctrl+C 시 실행
on_interrupt() {
    log_warn "사용자 중단 신호 수신 (SIGINT)"
    exit 130  # 이후 EXIT trap 실행됨
}

# trap 등록 (cleanup은 항상 마지막에 실행)
trap cleanup EXIT
trap on_error ERR
trap on_interrupt INT TERM

# ================================================================
# 메인 로직
# ================================================================

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

    log_info "스크립트 시작: $SCRIPT_NAME"

    # 임시 디렉토리 생성 (cleanup에서 자동 삭제됨)
    TEMP_DIR="$(mktemp -d)"
    log_info "임시 디렉토리 생성: $TEMP_DIR"

    # 실제 작업 수행
    log_info "작업 수행 중..."
    # ... 여기에 실제 로직 ...

    log_info "모든 작업 완료"
}

main "$@"

3. 함수 라이브러리와 소스 포함

3.1 lib.sh 함수 라이브러리

여러 스크립트에서 공통으로 사용하는 함수들을 별도 파일로 분리하면 코드 재사용성과 유지보수성이 크게 향상됩니다.

로컬 터미널
# /opt/infra-scripts/lib/lib.sh
# 공통 함수 라이브러리 — 직접 실행하지 말고 source로 포함

# 이미 소스된 경우 중복 로딩 방지
[[ -n "${_LIB_LOADED:-}" ]] && return 0
readonly _LIB_LOADED=1

# ================================================================
# 색상 상수
# ================================================================
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m'  # No Color

# ================================================================
# 로깅 함수
# ================================================================
LOG_FILE="${LOG_FILE:-/tmp/infra-automation.log}"

log_info() {
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]  $*"
    echo -e "${GREEN}${msg}${NC}" | tee -a "$LOG_FILE"
}

log_warn() {
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [WARN]  $*"
    echo -e "${YELLOW}${msg}${NC}" | tee -a "$LOG_FILE"
}

log_error() {
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*"
    echo -e "${RED}${msg}${NC}" | tee -a "$LOG_FILE" >&2
}

log_step() {
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [STEP]  >>> $*"
    echo -e "${BLUE}${msg}${NC}" | tee -a "$LOG_FILE"
}

# ================================================================
# 시스템 유틸리티 함수
# ================================================================

# root 권한 확인
require_root() {
    if [[ $EUID -ne 0 ]]; then
        log_error "이 스크립트는 root 권한으로 실행해야 합니다"
        exit 1
    fi
}

# 명령어 존재 확인
require_command() {
    local cmd="$1"
    if ! command -v "$cmd" &>/dev/null; then
        log_error "필수 명령어를 찾을 수 없음: $cmd"
        exit 1
    fi
}

# 재시도 함수
retry() {
    local max_attempts="${1:-3}"
    local delay="${2:-5}"
    local cmd=("${@:3}")
    local attempt=1

    while [[ $attempt -le $max_attempts ]]; do
        if "${cmd[@]}"; then
            return 0
        fi
        log_warn "시도 $attempt/$max_attempts 실패. ${delay}초 후 재시도..."
        sleep "$delay"
        ((attempt++))
    done

    log_error "최대 시도 횟수($max_attempts)를 초과했습니다: ${cmd[*]}"
    return 1
}

# 디스크 사용량 확인 (퍼센트 반환)
get_disk_usage() {
    local mount_point="${1:-/}"
    df -h "$mount_point" | awk 'NR==2 {gsub(/%/, ""); print $5}'
}

# 프로세스 실행 여부 확인
is_running() {
    local process_name="$1"
    pgrep -x "$process_name" &>/dev/null
}

# 포트 사용 여부 확인
is_port_open() {
    local host="$1"
    local port="$2"
    local timeout="${3:-3}"
    timeout "$timeout" bash -c "echo >/dev/tcp/$host/$port" 2>/dev/null
}

lib.sh를 사용하는 스크립트:

로컬 터미널
#!/usr/bin/env bash
set -euo pipefail

# 스크립트 위치 기준으로 lib.sh 로드
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/lib.sh"

# 이제 lib.sh의 모든 함수를 사용할 수 있음
require_root
require_command "ssh"
require_command "parallel"

log_step "서버 점검 시작"
log_info "디스크 사용량: $(get_disk_usage /)%"

4. 락 파일로 중복 실행 방지

락 파일 구현으로 중복 실행 방지하기

자동화 스크립트가 cron으로 실행될 때, 이전 실행이 아직 진행 중인데 새 실행이 시작되면 충돌이 발생할 수 있습니다. 락 파일로 이를 방지합니다.

기본 락 파일 패턴:

로컬 터미널
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_NAME="$(basename "$0" .sh)"
LOCK_FILE="/tmp/${SCRIPT_NAME}.lock"

# 락 획득 함수
acquire_lock() {
    # set -C (noclobber): 파일이 이미 있으면 리다이렉션 실패
    # 이것이 원자적 락 획득의 핵심
    if ( set -C; echo $$ > "$LOCK_FILE" ) 2>/dev/null; then
        echo "[INFO] 락 획득 성공 (PID: $$)"
        return 0
    else
        local existing_pid
        existing_pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "unknown")

        # 기존 프로세스가 실제로 살아있는지 확인
        if [[ "$existing_pid" != "unknown" ]] && kill -0 "$existing_pid" 2>/dev/null; then
            echo "[ERROR] 다른 인스턴스가 실행 중입니다 (PID: $existing_pid)"
            return 1
        else
            # 죽은 프로세스의 락 파일 — 강제 획득
            echo "[WARN] 오래된 락 파일 발견. 강제 제거 후 획득합니다."
            rm -f "$LOCK_FILE"
            echo $$ > "$LOCK_FILE"
            return 0
        fi
    fi
}

# 락 해제 함수
release_lock() {
    if [[ -f "$LOCK_FILE" ]]; then
        local lock_pid
        lock_pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "")
        if [[ "$lock_pid" == "$$" ]]; then
            rm -f "$LOCK_FILE"
            echo "[INFO] 락 해제 완료"
        fi
    fi
}

# EXIT trap에 락 해제 등록
trap release_lock EXIT

# 락 획득 시도
if ! acquire_lock; then
    exit 1
fi

echo "[INFO] 작업 시작..."
# 실제 작업 수행
sleep 5
echo "[INFO] 작업 완료"

flock을 사용한 더 강력한 락 구현:

로컬 터미널
#!/usr/bin/env bash
set -euo pipefail

LOCK_FILE="/tmp/my-automation.lock"

# flock 방식: 파일 디스크립터 기반 락
exec 200>"$LOCK_FILE"

if ! flock -n 200; then
    echo "[ERROR] 이미 실행 중인 인스턴스가 있습니다"
    exit 1
fi

echo $$ >&200
echo "[INFO] 락 획득 완료. 작업 시작..."

# 스크립트가 종료되면 flock이 자동으로 락을 해제함
# (파일 디스크립터가 닫힐 때)

실제 운영에서는 flock 방식을 더 많이 사용합니다. 프로세스가 강제 종료되더라도 OS가 파일 디스크립터를 닫으면서 락이 자동으로 해제되기 때문입니다.

🔍실행 후 확인할 것
  • 스크립트를 두 번 실행했을 때 두 번째 실행이 '이미 실행 중' 메시지와 함께 종료된다
  • /tmp/*.lock 파일이 스크립트 종료 후 자동으로 삭제된다
  • set -euo pipefail 적용 후 존재하지 않는 변수 사용 시 즉시 오류와 함께 스크립트가 종료된다
  • 멱등성 검사 추가 후 같은 스크립트를 3번 실행해도 'already installed' 또는 'ok' 상태로 표시된다

5. SSH 다수 서버 원격 명령 실행

SSH로 다수 서버에 명령 병렬 실행하기

5.1 서버 목록 파일 구성

서버 목록은 별도 파일로 관리합니다. 주석과 빈 줄을 지원하는 형식이 실용적입니다.

# /etc/infra/servers/production.txt
# 형식: hostname [태그1,태그2,...]
# 빈 줄과 # 주석은 무시됨

# 웹 서버
web-01.prod.example.com  web,frontend
web-02.prod.example.com  web,frontend
web-03.prod.example.com  web,frontend

# 앱 서버
app-01.prod.example.com  app,backend
app-02.prod.example.com  app,backend

# DB 서버 (주의: 직접 접근 제한)
db-01.prod.example.com   db,database
db-02.prod.example.com   db,database

# 캐시 서버
cache-01.prod.example.com  cache,redis
로컬 터미널
# 서버 목록 파일에서 호스트명만 추출하는 함수
get_servers() {
    local server_file="${1:?서버 목록 파일 경로 필요}"
    local tag_filter="${2:-}"

    if [[ ! -f "$server_file" ]]; then
        echo "[ERROR] 서버 목록 파일 없음: $server_file" >&2
        return 1
    fi

    if [[ -n "$tag_filter" ]]; then
        # 특정 태그를 가진 서버만 추출
        grep -v '^\s*#' "$server_file" | \
            grep -v '^\s*$' | \
            awk -v tag="$tag_filter" '$2 ~ tag {print $1}'
    else
        # 모든 서버 추출
        grep -v '^\s*#' "$server_file" | \
            grep -v '^\s*$' | \
            awk '{print $1}'
    fi
}

5.2 순차 SSH 실행

로컬 터미널
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/lib.sh"

SERVER_FILE="/etc/infra/servers/production.txt"
SSH_USER="${SSH_USER:-deploy}"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/infra_ed25519}"
SSH_OPTS="-i $SSH_KEY -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o BatchMode=yes"

# SSH 명령 실행 함수
ssh_exec() {
    local host="$1"
    local cmd="$2"
    local timeout="${3:-30}"

    log_info "[$host] 명령 실행: $cmd"

    local output exit_code
    if output=$(timeout "$timeout" ssh $SSH_OPTS "${SSH_USER}@${host}" "$cmd" 2>&1); then
        exit_code=0
        log_info "[$host] 성공: $output"
    else
        exit_code=$?
        log_error "[$host] 실패 (코드: $exit_code): $output"
    fi

    return $exit_code
}

# 순차 실행
run_sequential() {
    local server_file="$1"
    local cmd="$2"
    local failed_servers=()

    while IFS= read -r host; do
        [[ -z "$host" ]] && continue
        if ! ssh_exec "$host" "$cmd"; then
            failed_servers+=("$host")
        fi
    done < <(get_servers "$server_file")

    if [[ ${#failed_servers[@]} -gt 0 ]]; then
        log_error "실패한 서버: ${failed_servers[*]}"
        return 1
    fi

    log_info "모든 서버에서 명령 완료"
}

run_sequential "$SERVER_FILE" "uptime"

6. parallel과 xargs로 병렬 실행

GNU parallel과 xargs로 병렬 SSH 실행 구현하기

순차 실행은 서버가 많아질수록 시간이 선형으로 증가합니다. 50대 서버에 각각 5초가 걸리는 작업을 순차 실행하면 250초가 필요합니다. 병렬 실행으로 이를 획기적으로 단축할 수 있습니다.

6.1 xargs를 사용한 병렬 실행

로컬 터미널
#!/usr/bin/env bash
set -euo pipefail

SSH_USER="deploy"
SSH_KEY="$HOME/.ssh/infra_ed25519"
PARALLEL_JOBS=10  # 동시 실행 수

run_parallel_xargs() {
    local server_file="$1"
    local cmd="$2"

    log_info "병렬 실행 시작 (동시 $PARALLEL_JOBS개)"

    # xargs -P: 병렬 프로세스 수
    # xargs -I{}: 플레이스홀더
    get_servers "$server_file" | \
        xargs -P "$PARALLEL_JOBS" -I{} \
        ssh -i "$SSH_KEY" \
            -o StrictHostKeyChecking=no \
            -o ConnectTimeout=10 \
            -o BatchMode=yes \
            "${SSH_USER}@{}" "$cmd" 2>&1 | \
        while IFS= read -r line; do
            echo "[$(date '+%H:%M:%S')] $line"
        done
}

run_parallel_xargs "/etc/infra/servers/production.txt" "df -h /"

6.2 GNU parallel을 사용한 고급 병렬 실행

로컬 터미널
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/lib.sh"

SERVER_FILE="/etc/infra/servers/production.txt"
SSH_USER="deploy"
SSH_KEY="$HOME/.ssh/infra_ed25519"
RESULTS_DIR="/tmp/ssh-results-$(date +%Y%m%d-%H%M%S)"
PARALLEL_JOBS=20

run_parallel_gnu() {
    local cmd="$1"
    local tag_filter="${2:-}"

    require_command "parallel"
    mkdir -p "$RESULTS_DIR"

    log_step "GNU parallel 병렬 실행 시작"
    log_info "동시 실행: $PARALLEL_JOBS개"
    log_info "결과 저장: $RESULTS_DIR"

    get_servers "$SERVER_FILE" "$tag_filter" | \
        parallel \
            --jobs "$PARALLEL_JOBS" \
            --timeout 60 \
            --results "$RESULTS_DIR" \
            --joblog "$RESULTS_DIR/joblog.txt" \
            --tag \
            ssh \
                -i "$SSH_KEY" \
                -o StrictHostKeyChecking=no \
                -o ConnectTimeout=10 \
                -o BatchMode=yes \
                "${SSH_USER}@{}" "'$cmd'"

    log_info "병렬 실행 완료. 결과 분석 중..."
    analyze_results
}

analyze_results() {
    local success=0
    local failed=0
    local failed_hosts=()

    # joblog 파싱: 실패한 작업 찾기
    if [[ -f "$RESULTS_DIR/joblog.txt" ]]; then
        while IFS=$'\t' read -r seq host starttime runtime sendsize receivesize exitcode signal command; do
            [[ "$seq" == "Seq" ]] && continue  # 헤더 스킵
            if [[ "$exitcode" == "0" ]]; then
                ((success++))
            else
                ((failed++))
                # 호스트명 추출
                local host_name
                host_name=$(echo "$command" | grep -oP '[\w.-]+\.example\.com' || echo "unknown")
                failed_hosts+=("$host_name")
            fi
        done < "$RESULTS_DIR/joblog.txt"
    fi

    log_info "성공: $success대"
    if [[ $failed -gt 0 ]]; then
        log_error "실패: $failed대 — ${failed_hosts[*]}"
    fi
}

# 실행
run_parallel_gnu "uptime && df -h / | tail -1" "web"

7. 50대 서버 디스크 일괄 점검 스크립트

50대 서버 디스크 사용량 일괄 점검 스크립트 작성하기

실제 운영에서 자주 필요한 50대 서버의 디스크 사용량을 병렬로 수집하고 임계값 초과 서버를 리포트하는 완성형 스크립트입니다.

로컬 터미널
#!/usr/bin/env bash
# disk-check-all.sh — 다수 서버 디스크 사용량 일괄 점검
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/lib.sh"

# ================================================================
# 설정
# ================================================================
SERVER_FILE="${SERVER_FILE:-/etc/infra/servers/production.txt}"
SSH_USER="${SSH_USER:-deploy}"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/infra_ed25519}"
PARALLEL_JOBS="${PARALLEL_JOBS:-20}"
DISK_WARN_THRESHOLD="${DISK_WARN_THRESHOLD:-80}"   # % 경고 임계값
DISK_CRIT_THRESHOLD="${DISK_CRIT_THRESHOLD:-90}"   # % 위험 임계값
REPORT_FILE="/tmp/disk-report-$(date +%Y%m%d-%H%M%S).txt"
LOCK_FILE="/tmp/disk-check-all.lock"

# ================================================================
# 초기화
# ================================================================
trap cleanup EXIT
trap on_error ERR

cleanup() {
    [[ -f "$LOCK_FILE" ]] && rm -f "$LOCK_FILE"
}

on_error() {
    log_error "오류 발생 (라인 ${BASH_LINENO[0]}): ${BASH_COMMAND}"
}

acquire_lock() {
    if ! ( set -C; echo $$ > "$LOCK_FILE" ) 2>/dev/null; then
        log_error "이미 실행 중입니다 (PID: $(cat "$LOCK_FILE" 2>/dev/null))"
        exit 1
    fi
}

# ================================================================
# 단일 서버 디스크 점검 함수
# ================================================================
check_disk_single() {
    local host="$1"
    local ssh_opts="-i $SSH_KEY -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o BatchMode=yes"

    # 모든 마운트 포인트의 디스크 정보 수집
    local disk_info
    if disk_info=$(timeout 15 ssh $ssh_opts "${SSH_USER}@${host}" \
        "df -h --output=target,size,used,avail,pcent | grep -v 'tmpfs\|devtmpfs\|Filesystem'" 2>/dev/null); then

        # 각 줄 파싱
        while IFS= read -r line; do
            [[ -z "$line" ]] && continue

            local mount size used avail pcent
            read -r mount size used avail pcent <<< "$line"

            # 퍼센트 숫자만 추출
            local usage_num
            usage_num="${pcent//%/}"

            # 임계값 초과 여부 판단
            local status="OK"
            if [[ "$usage_num" -ge "$DISK_CRIT_THRESHOLD" ]] 2>/dev/null; then
                status="CRITICAL"
            elif [[ "$usage_num" -ge "$DISK_WARN_THRESHOLD" ]] 2>/dev/null; then
                status="WARNING"
            fi

            # 결과 출력 (탭 구분)
            echo -e "${host}\t${mount}\t${size}\t${used}\t${avail}\t${pcent}\t${status}"
        done <<< "$disk_info"
    else
        echo -e "${host}\tERROR\t-\t-\t-\t-\tCONNECT_FAILED"
    fi
}

export -f check_disk_single
export SSH_USER SSH_KEY DISK_WARN_THRESHOLD DISK_CRIT_THRESHOLD

# ================================================================
# 메인 실행
# ================================================================
main() {
    require_command "parallel"
    acquire_lock

    log_step "디스크 점검 시작"
    log_info "서버 목록: $SERVER_FILE"
    log_info "경고 임계값: ${DISK_WARN_THRESHOLD}%, 위험 임계값: ${DISK_CRIT_THRESHOLD}%"

    local server_count
    server_count=$(get_servers "$SERVER_FILE" | wc -l)
    log_info "대상 서버: ${server_count}대"

    # 결과 파일 헤더
    {
        echo "=================================================================================================="
        echo "디스크 사용량 점검 보고서"
        echo "실행 시각: $(date '+%Y-%m-%d %H:%M:%S')"
        echo "대상 서버: ${server_count}대"
        echo "=================================================================================================="
        printf "%-30s %-15s %6s %6s %6s %6s %10s\n" \
            "호스트" "마운트" "전체" "사용" "여유" "사용률" "상태"
        echo "--------------------------------------------------------------------------------------------------"
    } > "$REPORT_FILE"

    # 병렬 실행
    local raw_results
    raw_results=$(get_servers "$SERVER_FILE" | \
        parallel \
            --jobs "$PARALLEL_JOBS" \
            --timeout 30 \
            --keep-order \
            check_disk_single {})

    # 결과 파싱 및 집계
    local warn_count=0 crit_count=0 error_count=0 ok_count=0

    while IFS=$'\t' read -r host mount size used avail pcent status; do
        printf "%-30s %-15s %6s %6s %6s %6s %10s\n" \
            "$host" "$mount" "$size" "$used" "$avail" "$pcent" "$status" \
            >> "$REPORT_FILE"

        case "$status" in
            "OK")           ((ok_count++)) ;;
            "WARNING")      ((warn_count++)) ;;
            "CRITICAL")     ((crit_count++)) ;;
            "CONNECT_FAILED") ((error_count++)) ;;
        esac
    done <<< "$raw_results"

    # 요약
    {
        echo "--------------------------------------------------------------------------------------------------"
        echo "요약:"
        echo "  정상(OK):        ${ok_count}건"
        echo "  경고(WARNING):   ${warn_count}건"
        echo "  위험(CRITICAL):  ${crit_count}건"
        echo "  접속 실패:       ${error_count}대"
        echo "=================================================================================================="
    } >> "$REPORT_FILE"

    cat "$REPORT_FILE"
    log_info "보고서 저장: $REPORT_FILE"

    # 위험 항목이 있으면 비정상 종료
    if [[ $crit_count -gt 0 ]]; then
        log_error "위험 임계값 초과 항목 발견: ${crit_count}건"
        exit 2
    elif [[ $warn_count -gt 0 ]]; then
        log_warn "경고 임계값 초과 항목 발견: ${warn_count}건"
        exit 1
    fi

    log_info "모든 서버 디스크 정상"
}

main "$@"

이 스크립트를 cron에 등록하여 매일 오전 9시에 실행하고, 이메일로 보고서를 전송할 수 있습니다.

로컬 터미널
# /etc/cron.d/disk-check
0 9 * * * deploy /opt/infra-scripts/disk-check-all.sh 2>&1 | mail -s "[인프라] 디스크 점검 보고서" ops-team@example.com

8. 서버 초기화 자동화 스크립트

신규 서버 초기화 자동화 스크립트 작성하기

새 서버가 추가될 때마다 동일한 초기 설정을 멱등적으로 적용하는 스크립트입니다. 이 스크립트는 서버에서 직접 실행됩니다.

로컬 터미널
#!/usr/bin/env bash
# server-init.sh — 신규 서버 초기화 자동화 (멱등적)
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/lib.sh"

require_root

# ================================================================
# 패키지 설치 (멱등적)
# ================================================================
install_packages() {
    log_step "필수 패키지 설치"

    local packages=(
        "vim" "curl" "wget" "git" "htop" "iotop"
        "netstat" "tcpdump" "strace" "lsof"
        "jq" "python3" "python3-pip"
        "ntp" "chrony" "fail2ban"
    )

    # 패키지 관리자 감지
    if command -v apt-get &>/dev/null; then
        apt-get update -qq
        for pkg in "${packages[@]}"; do
            if ! dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"; then
                apt-get install -y -qq "$pkg"
                log_info "설치: $pkg"
            else
                log_info "이미 설치됨: $pkg — 건너뜀"
            fi
        done
    elif command -v yum &>/dev/null; then
        for pkg in "${packages[@]}"; do
            if ! rpm -q "$pkg" &>/dev/null; then
                yum install -y -q "$pkg"
                log_info "설치: $pkg"
            else
                log_info "이미 설치됨: $pkg — 건너뜀"
            fi
        done
    else
        log_error "지원되지 않는 패키지 관리자"
        return 1
    fi
}

# ================================================================
# 배포 사용자 생성 (멱등적)
# ================================================================
setup_deploy_user() {
    log_step "deploy 사용자 설정"
    local deploy_user="deploy"
    local deploy_home="/home/${deploy_user}"
    local ssh_pub_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... deploy@infra"

    # 사용자 생성
    if ! id "$deploy_user" &>/dev/null; then
        useradd -m -s /bin/bash -G sudo "$deploy_user"
        log_info "사용자 생성: $deploy_user"
    else
        log_info "사용자 존재: $deploy_user — 건너뜀"
    fi

    # SSH 디렉토리 설정
    local ssh_dir="${deploy_home}/.ssh"
    if [[ ! -d "$ssh_dir" ]]; then
        mkdir -p "$ssh_dir"
        chmod 700 "$ssh_dir"
        chown "${deploy_user}:${deploy_user}" "$ssh_dir"
    fi

    # authorized_keys에 공개키 추가 (멱등적)
    local auth_keys="${ssh_dir}/authorized_keys"
    if ! grep -qF "$ssh_pub_key" "$auth_keys" 2>/dev/null; then
        echo "$ssh_pub_key" >> "$auth_keys"
        chmod 600 "$auth_keys"
        chown "${deploy_user}:${deploy_user}" "$auth_keys"
        log_info "SSH 공개키 추가 완료"
    else
        log_info "SSH 공개키 이미 존재 — 건너뜀"
    fi

    # sudo 권한 (비밀번호 없이 특정 명령만 허용)
    local sudoers_file="/etc/sudoers.d/${deploy_user}"
    if [[ ! -f "$sudoers_file" ]]; then
        cat > "$sudoers_file" << 'EOF'
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl, /usr/bin/journalctl, /usr/bin/apt-get
EOF
        chmod 440 "$sudoers_file"
        log_info "sudo 권한 설정 완료"
    fi
}

# ================================================================
# 시스템 설정 (멱등적)
# ================================================================
configure_system() {
    log_step "시스템 설정"

    # 타임존 설정
    local desired_tz="Asia/Seoul"
    local current_tz
    current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "")
    if [[ "$current_tz" != "$desired_tz" ]]; then
        timedatectl set-timezone "$desired_tz"
        log_info "타임존 설정: $desired_tz"
    else
        log_info "타임존 이미 설정됨: $desired_tz — 건너뜀"
    fi

    # 스왑 설정 (없을 때만)
    if ! swapon --show | grep -q .; then
        fallocate -l 2G /swapfile
        chmod 600 /swapfile
        mkswap /swapfile
        swapon /swapfile
        echo '/swapfile none swap sw 0 0' >> /etc/fstab
        log_info "스왑 2GB 생성 및 활성화"
    else
        log_info "스왑 이미 설정됨 — 건너뜀"
    fi

    # sysctl 커널 파라미터 (멱등적)
    local sysctl_conf="/etc/sysctl.d/99-infra.conf"
    if [[ ! -f "$sysctl_conf" ]]; then
        cat > "$sysctl_conf" << 'EOF'
# 인프라 플랫폼 최적화 설정
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65535
vm.swappiness = 10
fs.file-max = 1000000
EOF
        sysctl -p "$sysctl_conf" &>/dev/null
        log_info "커널 파라미터 설정 완료"
    else
        log_info "커널 파라미터 이미 설정됨 — 건너뜀"
    fi
}

# ================================================================
# 보안 설정 (멱등적)
# ================================================================
configure_security() {
    log_step "보안 설정"

    # fail2ban 활성화
    if systemctl is-active --quiet fail2ban; then
        log_info "fail2ban 이미 실행 중 — 건너뜀"
    else
        systemctl enable --now fail2ban
        log_info "fail2ban 활성화 완료"
    fi

    # SSH 보안 강화
    local sshd_config="/etc/ssh/sshd_config"
    local changed=false

    set_sshd_option() {
        local option="$1"
        local value="$2"
        if ! grep -qP "^\s*${option}\s+${value}" "$sshd_config"; then
            sed -i "s/^#*\s*${option}.*/${option} ${value}/" "$sshd_config"
            log_info "SSH 설정 변경: $option $value"
            changed=true
        fi
    }

    set_sshd_option "PermitRootLogin" "no"
    set_sshd_option "PasswordAuthentication" "no"
    set_sshd_option "X11Forwarding" "no"
    set_sshd_option "MaxAuthTries" "3"

    if [[ "$changed" == "true" ]]; then
        sshd -t && systemctl reload sshd
        log_info "SSH 데몬 재시작 완료"
    fi
}

# ================================================================
# 초기화 완료 마커
# ================================================================
mark_initialized() {
    local marker="/etc/infra-initialized"
    local version="1.0.0"

    if [[ ! -f "$marker" ]]; then
        cat > "$marker" << EOF
initialized_at=$(date '+%Y-%m-%d %H:%M:%S')
initialized_by=$(hostname)
script_version=$version
EOF
        log_info "초기화 마커 생성: $marker"
    else
        log_info "서버 이미 초기화됨 $(grep initialized_at "$marker" | cut -d= -f2)"
    fi
}

# ================================================================
# 메인
# ================================================================
main() {
    log_step "서버 초기화 시작: $(hostname)"
    log_info "OS: $(. /etc/os-release && echo "$PRETTY_NAME")"

    install_packages
    setup_deploy_user
    configure_system
    configure_security
    mark_initialized

    log_step "서버 초기화 완료: $(hostname)"
}

main "$@"

9. Python으로 고급 자동화

💡개념

Python 자동화의 장점과 도구

Python 자동화 도구 선택 가이드 — subprocess·paramiko·fabric·ansible

서버 50대의 설정 상태를 JSON으로 수집해 특정 조건에 맞는 서버만 필터링한 뒤 업데이트하는 작업을 Shell로 짜면 jq, awk, sed를 섞어가며 파싱하다 복잡해집니다. 예외 처리 한 줄 추가하는 것도 부담스러워지고, 코드 자체가 읽기 어려워집니다. 자동화가 일정 수준 이상 복잡해지면 Python으로 작성하는 것이 유지보수 면에서 훨씬 낫습니다.

Shell 스크립트는 간단한 자동화에 적합하지만, 복잡한 로직, 구조화된 데이터 처리, 더 강력한 에러 핸들링이 필요할 때는 Python이 더 적합합니다.

Shell vs Python 선택 기준:

상황권장이유
명령어 파이프라인 조합Shell자연스러운 문법
JSON/YAML 파싱Python내장 라이브러리
복잡한 비즈니스 로직Python가독성, 테스트 용이
병렬 처리 (고급)Pythonthreading, asyncio
SSH 자동화Pythonparamiko 라이브러리
간단한 파일 조작Shell짧고 빠름
재사용 가능한 도구Python모듈화, 패키지화

Python 주요 자동화 라이브러리:

Python
# subprocess: 외부 명령 실행
import subprocess

# paramiko: SSH 연결 및 SFTP
import paramiko

# concurrent.futures: 멀티스레딩/멀티프로세싱
from concurrent.futures import ThreadPoolExecutor, as_completed

# PyYAML: YAML 설정 파일 읽기
import yaml

# fabric: 고수준 SSH 자동화 (paramiko 기반)
from fabric import Connection

Python으로 자동화 도구를 만들면 단위 테스트 작성, 타입 힌팅, 풍부한 로깅, 예외 계층 구조 활용 등 소프트웨어 엔지니어링의 좋은 관행을 모두 적용할 수 있습니다.

9.1 Python subprocess로 명령 실행

Python
#!/usr/bin/env python3
"""infra_runner.py — Python subprocess 기반 인프라 자동화"""

import subprocess
import logging
import sys
from typing import Optional, Tuple

# 구조화된 로깅 설정
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[
        logging.StreamHandler(sys.stdout),
        logging.FileHandler("/var/log/infra-automation/runner.log"),
    ],
)
logger = logging.getLogger(__name__)


def run_command(
    cmd: list[str],
    timeout: int = 30,
    capture_output: bool = True,
    check: bool = True,
) -> Tuple[int, str, str]:
    """
    외부 명령을 실행하고 결과를 반환합니다.

    Returns:
        (returncode, stdout, stderr) 튜플
    """
    logger.debug(f"명령 실행: {' '.join(cmd)}")

    try:
        result = subprocess.run(
            cmd,
            capture_output=capture_output,
            text=True,
            timeout=timeout,
            check=check,  # True면 비정상 종료 시 CalledProcessError 발생
        )
        return result.returncode, result.stdout.strip(), result.stderr.strip()

    except subprocess.TimeoutExpired:
        logger.error(f"명령 타임아웃 ({timeout}s): {' '.join(cmd)}")
        raise
    except subprocess.CalledProcessError as e:
        logger.error(f"명령 실패 (코드 {e.returncode}): {e.stderr}")
        raise


def run_ssh_command(
    host: str,
    command: str,
    user: str = "deploy",
    key_file: str = "~/.ssh/infra_ed25519",
    timeout: int = 30,
) -> Tuple[bool, str]:
    """SSH로 원격 명령을 실행합니다."""

    ssh_cmd = [
        "ssh",
        "-i", key_file,
        "-o", "StrictHostKeyChecking=no",
        "-o", "ConnectTimeout=10",
        "-o", "BatchMode=yes",
        f"{user}@{host}",
        command,
    ]

    try:
        returncode, stdout, stderr = run_command(ssh_cmd, timeout=timeout, check=False)
        success = returncode == 0
        output = stdout if success else stderr
        return success, output
    except subprocess.TimeoutExpired:
        return False, f"타임아웃 ({timeout}s)"

9.2 paramiko로 SSH 연결

Python
#!/usr/bin/env python3
"""paramiko_automation.py — paramiko 기반 SSH 자동화"""

import paramiko
import logging
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)


class SSHClient:
    """paramiko SSH 클라이언트 래퍼"""

    def __init__(
        self,
        host: str,
        user: str = "deploy",
        key_file: str = "~/.ssh/infra_ed25519",
        port: int = 22,
        timeout: int = 10,
    ):
        self.host = host
        self.user = user
        self.key_file = str(Path(key_file).expanduser())
        self.port = port
        self.timeout = timeout
        self._client: Optional[paramiko.SSHClient] = None

    def __enter__(self):
        self.connect()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.disconnect()

    def connect(self):
        """SSH 연결을 맺습니다."""
        self._client = paramiko.SSHClient()
        self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

        try:
            self._client.connect(
                hostname=self.host,
                port=self.port,
                username=self.user,
                key_filename=self.key_file,
                timeout=self.timeout,
                auth_timeout=self.timeout,
            )
            logger.info(f"[{self.host}] SSH 연결 성공")
        except paramiko.AuthenticationException:
            logger.error(f"[{self.host}] 인증 실패")
            raise
        except paramiko.NoValidConnectionsError as e:
            logger.error(f"[{self.host}] 연결 실패: {e}")
            raise

    def disconnect(self):
        """SSH 연결을 종료합니다."""
        if self._client:
            self._client.close()
            self._client = None

    def execute(self, command: str, timeout: int = 30) -> tuple[int, str, str]:
        """원격 명령을 실행하고 (returncode, stdout, stderr)를 반환합니다."""
        if not self._client:
            raise RuntimeError("SSH 연결이 없습니다")

        _, stdout, stderr = self._client.exec_command(command, timeout=timeout)
        exit_code = stdout.channel.recv_exit_status()

        return (
            exit_code,
            stdout.read().decode("utf-8", errors="replace").strip(),
            stderr.read().decode("utf-8", errors="replace").strip(),
        )

    def upload_file(self, local_path: str, remote_path: str):
        """SFTP로 파일을 업로드합니다."""
        with self._client.open_sftp() as sftp:
            sftp.put(local_path, remote_path)
            logger.info(f"[{self.host}] 파일 업로드: {local_path} → {remote_path}")

9.3 멀티스레딩으로 병렬 SSH 실행

Python
#!/usr/bin/env python3
"""parallel_ssh.py — ThreadPoolExecutor 기반 병렬 SSH"""

import concurrent.futures
import logging
from dataclasses import dataclass, field
from typing import Callable

logger = logging.getLogger(__name__)


@dataclass
class ServerResult:
    host: str
    success: bool
    output: str = ""
    error: str = ""
    duration_sec: float = 0.0


def parallel_ssh_exec(
    hosts: list[str],
    command: str,
    max_workers: int = 20,
    ssh_user: str = "deploy",
    ssh_key: str = "~/.ssh/infra_ed25519",
    timeout: int = 30,
) -> list[ServerResult]:
    """
    다수 서버에 SSH 명령을 병렬로 실행합니다.

    Returns:
        ServerResult 객체 리스트
    """
    import time

    def execute_on_host(host: str) -> ServerResult:
        start = time.time()
        try:
            with SSHClient(host, user=ssh_user, key_file=ssh_key) as client:
                exit_code, stdout, stderr = client.execute(command, timeout=timeout)
                duration = time.time() - start
                success = exit_code == 0
                return ServerResult(
                    host=host,
                    success=success,
                    output=stdout,
                    error=stderr if not success else "",
                    duration_sec=round(duration, 2),
                )
        except Exception as e:
            return ServerResult(
                host=host,
                success=False,
                error=str(e),
                duration_sec=round(time.time() - start, 2),
            )

    results = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_host = {executor.submit(execute_on_host, host): host for host in hosts}

        for future in concurrent.futures.as_completed(future_to_host):
            result = future.result()
            results.append(result)

            status = "OK" if result.success else "FAIL"
            logger.info(
                f"[{result.host}] {status} ({result.duration_sec}s): "
                f"{result.output[:80] if result.output else result.error[:80]}"
            )

    return results


def print_summary(results: list[ServerResult]):
    """실행 결과 요약을 출력합니다."""
    success = [r for r in results if r.success]
    failed = [r for r in results if not r.success]

    print(f"\n{'=' * 60}")
    print(f"실행 완료: 성공 {len(success)}대 / 실패 {len(failed)}대")
    if failed:
        print("\n실패한 서버:")
        for r in failed:
            print(f"  - {r.host}: {r.error}")
    print(f"{'=' * 60}\n")

9.4 YAML 설정 파일 읽기

Python
#!/usr/bin/env python3
"""config_loader.py — YAML 기반 인프라 설정 로더"""

import yaml
from pathlib import Path
from dataclasses import dataclass


@dataclass
class ServerGroup:
    name: str
    hosts: list[str]
    tags: list[str]
    ssh_user: str = "deploy"


@dataclass
class InfraConfig:
    server_groups: dict[str, ServerGroup]
    ssh_key: str
    parallel_jobs: int
    disk_warn_threshold: int
    disk_crit_threshold: int


def load_config(config_path: str) -> InfraConfig:
    """YAML 설정 파일을 로드합니다."""
    path = Path(config_path)
    if not path.exists():
        raise FileNotFoundError(f"설정 파일 없음: {config_path}")

    with open(path, encoding="utf-8") as f:
        raw = yaml.safe_load(f)

    groups = {}
    for group_name, group_data in raw.get("server_groups", {}).items():
        groups[group_name] = ServerGroup(
            name=group_name,
            hosts=group_data.get("hosts", []),
            tags=group_data.get("tags", []),
            ssh_user=group_data.get("ssh_user", "deploy"),
        )

    return InfraConfig(
        server_groups=groups,
        ssh_key=raw.get("ssh_key", "~/.ssh/infra_ed25519"),
        parallel_jobs=raw.get("parallel_jobs", 20),
        disk_warn_threshold=raw.get("thresholds", {}).get("disk_warn", 80),
        disk_crit_threshold=raw.get("thresholds", {}).get("disk_crit", 90),
    )

YAML 설정 파일 예시:

YAML
# /etc/infra/automation.yml
ssh_key: ~/.ssh/infra_ed25519
parallel_jobs: 20

thresholds:
  disk_warn: 80
  disk_crit: 90

server_groups:
  web:
    tags: [web, frontend]
    hosts:
      - web-01.prod.example.com
      - web-02.prod.example.com
      - web-03.prod.example.com

  app:
    tags: [app, backend]
    hosts:
      - app-01.prod.example.com
      - app-02.prod.example.com

  db:
    tags: [db, database]
    ssh_user: dba
    hosts:
      - db-01.prod.example.com
      - db-02.prod.example.com

10. 트러블슈팅

증상: set -e 설정 후 스크립트가 중간에 갑자기 종료되는데 원인을 알 수 없습니다.

원인: set -e는 종료 코드가 0이 아닌 모든 명령에서 즉시 종료합니다. grep이 패턴을 찾지 못할 때 종료 코드 1을 반환하거나, test 명령이 거짓일 때 종료 코드 1을 반환하는 등 의도한 "실패"에서도 종료가 발생합니다.

디버깅 방법:

로컬 터미널
# 1. -x 옵션으로 실행된 명령을 추적
bash -x your-script.sh 2>&1 | head -50

# 2. ERR trap에서 스택 트레이스 출력
on_error() {
    echo "[ERROR] 오류 발생:"
    echo "  명령: ${BASH_COMMAND}"
    echo "  라인: ${BASH_LINENO[0]}"
    echo "  스택:"
    local i
    for (( i=0; i<${#FUNCNAME[@]}; i++ )); do
        echo "    $i: ${FUNCNAME[$i]:-main} (${BASH_SOURCE[$i]:-?}:${BASH_LINENO[$i]:-?})"
    done
}
trap on_error ERR

해결 방법:

로컬 터미널
# 방법 1: 실패해도 되는 명령에 || true 추가
grep -q "pattern" file || true

# 방법 2: if 문으로 감싸기 (if 내부는 -e 영향 없음)
if grep -q "pattern" file; then
    echo "찾음"
fi

# 방법 3: 종료 코드를 변수로 저장
grep -q "pattern" file; result=$?
if [[ $result -eq 0 ]]; then
    echo "찾음"
elif [[ $result -eq 1 ]]; then
    echo "없음"
else
    echo "오류: $result"
fi

# 방법 4: set +e로 일시적으로 비활성화
set +e
some_command_that_may_fail
local rc=$?
set -e

증상: 병렬로 여러 서버에 SSH 명령을 실행할 때 출력이 뒤섞이거나, 공유 파일에 동시 쓰기로 인해 데이터가 손상됩니다.

잘못된 예시:

로컬 터미널
# 위험! 여러 백그라운드 프로세스가 동시에 같은 파일에 씀
for host in "${HOSTS[@]}"; do
    ssh "$SSH_USER@$host" "df -h /" >> /tmp/results.txt &
done
wait

올바른 해결 방법:

로컬 터미널
#!/usr/bin/env bash
# 방법 1: 서버별 개별 파일에 결과 저장

RESULTS_DIR="$(mktemp -d)"

for host in "${HOSTS[@]}"; do
    (
        ssh "$SSH_USER@$host" "df -h /" > "${RESULTS_DIR}/${host}.txt" 2>&1
        echo $? > "${RESULTS_DIR}/${host}.exit"
    ) &
done
wait  # 모든 백그라운드 프로세스 대기

# 결과 취합
for result_file in "${RESULTS_DIR}"/*.txt; do
    local host
    host="$(basename "$result_file" .txt)"
    local exit_code
    exit_code="$(cat "${RESULTS_DIR}/${host}.exit")"
    echo "=== $host (exit: $exit_code) ==="
    cat "$result_file"
done

# 방법 2: GNU parallel의 --results 옵션 사용
parallel --results "$RESULTS_DIR" \
    ssh "$SSH_USER@{}" "df -h /" ::: "${HOSTS[@]}"

# 방법 3: Python threading.Lock 사용
import threading
results_lock = threading.Lock()
all_results = []

def collect_result(host, result):
    with results_lock:  # 락으로 보호
        all_results.append(result)

flock으로 공유 파일 보호:

로컬 터미널
# flock을 사용하면 동시 쓰기를 직렬화할 수 있음
for host in "${HOSTS[@]}"; do
    (
        output=$(ssh "$SSH_USER@$host" "df -h /")
        flock /tmp/results.lock -c "echo '=== $host ===' >> /tmp/results.txt && echo '$output' >> /tmp/results.txt"
    ) &
done
wait

증상: 로컬에서는 정상 동작하는 멱등성 검사가 특정 서버에서는 매번 재설치를 시도합니다.

원인 사례 1 — 로케일 차이:

로컬 터미널
# 위험! 출력 형식이 로케일에 따라 다름
if dpkg -l nginx | grep -q "^ii"; then  # 영어 환경에서만 동작
    echo "설치됨"
fi

# 안전한 방법: dpkg-query 사용
if dpkg-query -W -f='${Status}' nginx 2>/dev/null | grep -q "install ok installed"; then
    echo "설치됨"
fi

원인 사례 2 — 버전 비교 오류:

로컬 터미널
# 위험! 문자열 비교로 버전을 비교하면 10 < 9 가 됨
CURRENT="2.10.1"
REQUIRED="2.9.0"
if [[ "$CURRENT" < "$REQUIRED" ]]; then  # 문자열 비교: "2.10" < "2.9" → 참!
    echo "업그레이드 필요"
fi

# 안전한 방법: sort -V 사용
version_ge() {
    [[ "$(printf '%s\n' "$2" "$1" | sort -V | head -1)" == "$2" ]]
}

if version_ge "$CURRENT" "$REQUIRED"; then
    echo "버전 충족: $CURRENT >= $REQUIRED"
fi

원인 사례 3 — 공백과 대소문자:

로컬 터미널
# 위험! 파일에 공백이나 대소문자가 다를 수 있음
if grep -q "PermitRootLogin no" /etc/ssh/sshd_config; then
    echo "설정됨"
fi

# 안전한 방법: 정규식 사용
if grep -qP '^\s*PermitRootLogin\s+no\s*$' /etc/ssh/sshd_config; then
    echo "설정됨"
fi

11. Ansible 기초 개념 미리보기

지금까지 배운 Shell/Python 자동화는 Ansible이 내부에서 하는 일과 매우 유사합니다. Ansible은 이러한 패턴을 더 선언적이고 체계적으로 표현할 수 있게 해줍니다.

YAML
# Ansible Playbook 예시 — 서버 초기화
# 우리가 작성한 server-init.sh와 같은 역할
---
- name: 서버 초기화
  hosts: all
  become: yes  # sudo 권한

  tasks:
    - name: 필수 패키지 설치
      package:
        name:
          - vim
          - curl
          - git
          - fail2ban
        state: present  # 멱등적: 없을 때만 설치

    - name: deploy 사용자 생성
      user:
        name: deploy
        shell: /bin/bash
        groups: sudo
        state: present  # 멱등적: 없을 때만 생성

    - name: SSH 공개키 추가
      authorized_key:
        user: deploy
        key: "{{ lookup('file', '~/.ssh/infra_ed25519.pub') }}"
        state: present  # 멱등적: 없을 때만 추가

    - name: 타임존 설정
      timezone:
        name: Asia/Seoul  # 멱등적: 이미 설정된 경우 변경 없음

    - name: sshd 보안 설정
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PermitRootLogin'
        line: 'PermitRootLogin no'
        state: present
      notify: restart sshd  # 변경 시에만 재시작

  handlers:
    - name: restart sshd
      service:
        name: sshd
        state: restarted

우리가 Shell 스크립트에서 id user, grep -q, if ! dpkg -l 같은 멱등성 검사를 직접 작성한 것을 Ansible은 각 모듈 내부에서 자동으로 처리합니다. state: present가 바로 멱등성의 선언적 표현입니다.


12. 현업 적용 사례

💼
실무 맥락
현업 패턴

100대 이상의 서버를 운영하는 SRE 팀의 실제 자동화 워크플로우를 살펴봅니다.

디렉토리 구조:

/opt/infra-scripts/
├── lib/
│   └── lib.sh               # 공통 함수 라이브러리
├── config/
│   ├── automation.yml        # YAML 설정
│   └── servers/
│       ├── production.txt    # 프로덕션 서버 목록
│       ├── staging.txt       # 스테이징 서버 목록
│       └── dev.txt           # 개발 서버 목록
├── checks/
│   ├── disk-check-all.sh    # 디스크 점검
│   ├── service-health.sh    # 서비스 상태 점검
│   └── cert-expiry.sh       # SSL 인증서 만료 점검
├── operations/
│   ├── server-init.sh       # 신규 서버 초기화
│   ├── deploy-app.sh        # 애플리케이션 배포
│   └── rotate-logs.sh       # 로그 로테이션
├── python/
│   ├── parallel_ssh.py      # 병렬 SSH 실행
│   ├── config_loader.py     # 설정 파일 로더
│   └── report_generator.py  # 보고서 생성
└── cron.d/
    ├── disk-check           # 매일 오전 9시
    └── cert-check           # 매주 월요일

일반적인 운영 시나리오:

  1. 신규 서버 추가: servers/production.txt에 호스트명 추가 → server-init.sh를 해당 서버에서 실행
  2. 일괄 패치: 전체 서버에 보안 패치 적용 → parallel_ssh.pyapt-get upgrade -y 병렬 실행
  3. 디스크 알람: cron으로 disk-check-all.sh 실행 → 90% 초과 시 Slack 알람 발송
  4. 인증서 갱신 전 점검: cert-expiry.sh로 30일 이내 만료 인증서 목록 생성

이 워크플로우는 점점 Ansible, Terraform, Kubernetes로 발전하지만, 기본 원리(멱등성, 병렬성, 예외처리)는 모든 도구에 동일하게 적용됩니다.

💼
실무 맥락
현업 패턴

DevOps/SRE 엔지니어 면접에서 인프라 자동화 역량을 어떻게 평가하는지 알아봅니다.

면접관이 중점적으로 보는 것:

  1. 멱등성 이해: "이 스크립트를 두 번 실행하면 어떻게 됩니까?"라는 질문에 명확히 답할 수 있어야 합니다.

  2. 에러 핸들링: set -euo pipefailtrap을 이유와 함께 설명할 수 있어야 합니다.

  3. 확장성 고려: "서버가 1000대로 늘어나면 이 스크립트가 동작합니까?"

  4. 보안 의식: SSH 키 관리, sudo 권한 최소화, 로그에 비밀번호 노출 방지 등

실무 코딩 테스트 예시 문제:

다음 조건을 만족하는 Shell 스크립트를 작성하시오:
1. /etc/infra/servers.txt 에서 서버 목록을 읽어 디스크 사용량을 점검한다
2. 80% 이상인 서버는 WARN, 90% 이상은 CRITICAL로 표시한다
3. 스크립트가 중복 실행되지 않도록 한다
4. 실패한 서버 목록을 별도로 출력한다
5. 결과를 /var/log/disk-check.log에 기록한다

이 챕터에서 배운 내용으로 위 문제를 완벽하게 구현할 수 있습니다.

포트폴리오 팁: GitHub에 공개 저장소를 만들어 자신만의 infra-scripts 라이브러리를 관리하세요. 실제 운영 경험이 없더라도, 코드 품질과 문서화로 역량을 증명할 수 있습니다.

💡개념

시크릿 관리 — 환경변수·파일과 Git 커밋 방지

시크릿 관리 3계층 — 환경변수/파일/Vault와 git-secrets 방지

자동화 스크립트에서 가장 흔한 보안 사고는 패스워드/API 키를 소스코드에 하드코딩하는 것입니다.

안전한 시크릿 주입 패턴:

로컬 터미널
# 방법 1: 환경변수 (CI/CD에서 주입)
export DB_PASSWORD="$(cat /run/secrets/db_password)"
export API_KEY="${API_KEY:?'API_KEY 환경변수가 설정되지 않았습니다'}"  # 미설정 시 즉시 실패

# 방법 2: 전용 시크릿 파일 (권한 600 필수)
SECRET_FILE="/etc/deploy/secrets.env"
if [ ! -f "$SECRET_FILE" ]; then
    echo "[ERROR] 시크릿 파일 없음: $SECRET_FILE" >&2
    exit 1
fi
chmod 600 "$SECRET_FILE"
source "$SECRET_FILE"  # 파일 내용: KEY=value 형식

# 방법 3: AWS SSM Parameter Store
DB_PASS=$(aws ssm get-parameter \
    --name "/prod/db/password" \
    --with-decryption \
    --query Parameter.Value \
    --output text)

Git 커밋 방지 (.gitignore + pre-commit hook):

로컬 터미널
# .gitignore
*.env
.env.*
secrets/
*.key
*.pem

# pre-commit hook으로 시크릿 패턴 검사
# .git/hooks/pre-commit
#!/bin/bash
PATTERNS="password|secret|api_key|token|private_key"
if git diff --cached --name-only | xargs grep -il "$PATTERNS" 2>/dev/null; then
    echo "[ERROR] 시크릿 패턴이 포함된 파일이 커밋되려 합니다. 확인 후 진행하세요."
    exit 1
fi

HashiCorp Vault 연동 개요:

로컬 터미널
# Vault 시크릿 읽기 (Token 인증 방식)
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="$(cat /etc/vault/token)"

DB_PASSWORD=$(vault kv get -field=password secret/prod/database)

# AppRole 인증 (자동화 스크립트 권장)
VAULT_TOKEN=$(vault write auth/approle/login \
    role_id="$ROLE_ID" \
    secret_id="$SECRET_ID" \
    -format=json | jq -r .auth.client_token)

원칙: 스크립트 자체에 시크릿을 절대 포함하지 않습니다. 런타임에 외부에서 주입하고, 로그에도 출력되지 않도록 set +x(debug 비활성화)와 로그 마스킹을 적용합니다.

💡개념

대량 실행 실패 정책 — continue-on-error vs fail-fast + dry-run 모드

fail-fast vs continue-on-error 실행 전략 + dry-run 모드

패키지 업데이트 스크립트를 200대 서버에 적용하던 중 20대에서 실패가 났습니다. 나머지 180대는 계속 진행해야 할까요, 즉시 중단해야 할까요? 실패 정책을 미리 정해두지 않으면 장애가 전체로 번지거나, 반대로 일부 서버만 업데이트된 채 불일치 상태로 남게 됩니다. fail-fast와 continue-on-error 중 어느 쪽을 쓸지, dry-run을 먼저 돌려보는 습관까지 갖추면 대량 배포 사고를 막을 수 있습니다.

수십~수백 대 서버에 동시 적용하는 자동화 스크립트에서 실패 정책을 미리 설계해야 합니다.

fail-fast vs continue-on-error 선택 기준:

시나리오권장 정책이유
보안 패치 적용fail-fast일부만 패치되면 보안 격차 발생
로그 수집 설정continue-on-error1대 실패해도 나머지 서버는 진행
DB 스키마 마이그레이션fail-fast + 롤백데이터 불일치 위험
소프트웨어 버전 업데이트continue-on-error + 이력 기록점진적 롤아웃

dry-run 모드 구현 패턴:

로컬 터미널
#!/bin/bash
# 모든 자동화 스크립트에 --dry-run 옵션 추가
DRY_RUN=false

while [[ "$#" -gt 0 ]]; do
    case $1 in
        --dry-run) DRY_RUN=true ;;
    esac
    shift
done

# 실행 래퍼 함수
run_cmd() {
    if $DRY_RUN; then
        echo "[DRY-RUN] $*"
    else
        "$@"
    fi
}

# 사용 예
run_cmd sudo systemctl restart nginx
run_cmd sudo apt-get install -y nginx

xargs 병렬 실행 실패 정책:

로컬 터미널
# xargs는 기본적으로 continue-on-error
# 실패 서버 목록 수집
FAILED_SERVERS=()
while IFS= read -r server; do
    if ! ssh "$server" "sudo apt-get update -qq" 2>/dev/null; then
        FAILED_SERVERS+=("$server")
        echo "[FAIL] $server"
    fi
done < server_list.txt

# 실패 서버 보고
if [ ${#FAILED_SERVERS[@]} -gt 0 ]; then
    echo "[요약] 실패 서버 ${#FAILED_SERVERS[@]}대:"
    printf '%s\n' "${FAILED_SERVERS[@]}"
fi

핵심 요약

이 챕터에서 다룬 핵심 개념을 정리합니다.

주제핵심 내용
멱등성같은 스크립트를 여러 번 실행해도 결과 동일 — 존재 확인 후 실행
set -euo pipefaile=오류 즉시 종료, u=미정의 변수 오류, o pipefail=파이프 실패 감지
trapEXIT/ERR/INT 신호를 가로채어 정리 작업 수행
함수 라이브러리source lib.sh로 공통 함수 재사용, 중복 로딩 방지
락 파일noclobber(set -C) 또는 flock으로 중복 실행 방지
병렬 실행xargs -P 또는 GNU parallel로 다수 서버 동시 처리
Python 자동화subprocess, paramiko, ThreadPoolExecutor, YAML 설정
Ansible 미리보기state: present가 멱등성의 선언적 표현

다음 챕터에서는 Ansible을 본격적으로 학습하며, 이 챕터에서 배운 Shell/Python 자동화 원칙이 어떻게 선언적 코드로 발전하는지 살펴봅니다.

지식 확인

퀴즈 — 5문제

Q1

Ansible에서 멱등성(Idempotency)의 정의로 가장 정확한 것은?

Q2

Ansible ad-hoc 명령어로 'webservers' 그룹의 모든 호스트에 nginx 패키지를 설치하는 명령은?

Q3

Ansible에서 ansible-vault encrypt 명령의 주된 용도는?

Q4

Ansible 인벤토리 파일에서 호스트 그룹을 정의할 때 올바른 INI 형식은?

Q5

Ansible role의 디렉토리 구조에서 실제 자동화 작업(태스크)을 정의하는 파일의 위치는?

0 / 5 답변

🧪 실습으로 확인하기

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

초급

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

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

이것도 배워보세요

docker입문 · 30
[Docker] 백엔드 개발자에게 Docker와 컨테이너 가상화가 필수인 이유
Docker 트랙 시작점
networking입문 · 45
[Network] OSI 7계층과 TCP/IP 4계층 모델 실무적 관점 분석
Networking 트랙 시작점