신규 서비스를 배포하기 위해 웹 서버 10대에 nginx, 방화벽 규칙, 앱 설정을 하나하나 SSH로 접속해 적용했습니다. 3시간 뒤, 서버 한 대가 패키지 설치 중에 실패했는데 어느 서버인지 기억이 나지 않습니다. 나중에 같은 작업을 다시 실행했더니 이미 처리된 서버에서 "이미 존재한다"는 에러가 발생합니다. 자동화 스크립트가 멱등성을 갖추고 있었다면, 몇 번을 다시 실행해도 안전하게 원하는 상태로 맞춰줬을 겁니다.
인프라 자동화 스크립트
운영 환경에서 수십 대, 수백 대의 서버를 사람이 직접 하나씩 관리하는 것은 불가능합니다. 실수가 발생하고, 시간이 낭비되며, 무엇보다 반복 작업은 엔지니어의 집중력을 소모시킵니다. 이 챕터에서는 Shell 스크립트와 Python을 활용해 다수 서버를 코드로 관리하는 방법을 체계적으로 학습합니다.
핵심은 두 가지입니다. 멱등성(Idempotency) — 같은 스크립트를 몇 번 실행해도 결과가 동일해야 한다는 원칙, 그리고 견고한 예외처리 — 어떤 상황에서도 시스템이 일관된 상태를 유지하도록 보장하는 기법입니다.
- 1멱등성(Idempotency) 원칙을 이해하고 상태 확인 후 조건부 실행 패턴을 적용할 수 있다
- 2set -euo pipefail, trap ERR로 Bash 예외처리와 롤백을 설계할 수 있다
- 3Ansible 인벤토리·플레이북·롤 구조를 이해하고 ad-hoc 명령을 실행할 수 있다
- 4ansible-vault로 민감 정보를 AES256으로 암호화 관리할 수 있다
- 5Python paramiko/fabric으로 다중 서버 병렬 자동화를 구현할 수 있다
sudo apt install -y ansible # Ubuntu/Debian
pip3 install ansible # pip 방식ssh-keygen -t ed25519 && ssh-copy-id user@target-hostpip3 install paramiko fabric1. 멱등성(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 같은 모든 현대 인프라 도구의 핵심 설계 원칙입니다. 직접 스크립트를 작성할 때도 이 원칙을 철저히 지켜야 합니다.
자동화 도구가 서버 상태를 수렴시키는 법 — 선언부터 검증까지 6단계
Ansible 플레이북을 돌리면 결과에 changed와 ok가 섞여 나옵니다. 도구가 내부에서 무엇을 하는지 모르면 왜 어떤 건 바뀌고 어떤 건 그대로인지, 부분 실패한 뒤 같은 플레이북을 다시 돌려도 왜 안전한지 감이 오지 않습니다. 핵심은 자동화 도구가 "명령을 실행"하는 게 아니라 현재 상태를 원하는 상태로 수렴시킨다는 데 있습니다. 그 수렴이 어떤 단계를 거치는지 보면 멱등성·부분 실패·드리프트가 각각 어디에서 생기는지 한눈에 잡힙니다.
[제어 노드] ansible-playbook site.yml
│
① 원하는 상태 선언 읽기 (플레이북·롤: "nginx present, 설정=이 내용")
│
② 대상 접속 + facts 수집 (SSH → 현재 패키지·파일·서비스 상태 파악)
│
③ 선언 상태 ↔ 현재 상태 비교 (이미 맞으면 ok, 어긋나면 아래로)
│
④ 차이나는 부분만 적용 (없으면 설치·틀리면 교정 = 멱등)
│
⑤ 변경 결과 보고(changed) + notify로 핸들러 예약
│
⑥ 검증·재적용 (다시 돌리면 changed=0 → 수렴 완료)
▼
[대상 fleet] 전 서버가 선언한 상태로 수렴
각 단계에서 무슨 일이 일어나고, 어긋나면 어떤 증상인가:
| 단계 | 하는 일 | 어긋나면 증상 |
|---|---|---|
| ① 상태 선언 | 있어야 할 것(present)과 없어야 할 것(absent)을 플레이북·롤에 선언 | present만 쓰고 absent를 빠뜨리면 → 선언에서 지운 항목이 이미 배포된 서버엔 그대로 잔존(드리프트가 안 지워짐) |
| ② facts 수집 | SSH로 붙어 현재 OS·패키지·파일·서비스 상태를 읽음 | 접속 실패 → unreachable, 일부 호스트만 실패해 fleet이 갈라짐(부분 실패) |
| ③ 비교 | 현재가 선언과 같은지 모듈이 검사 | 검사 없이 무조건 append하는 명령형 스크립트는 재실행마다 중복 누적(비멱등) |
| ④ 차이만 적용 | 어긋난 부분만 설치·교정 | 태스크 간 순서 의존(설정보다 서비스 시작이 먼저 등)이면 첫 실행만 되고 재현 불가 |
| ⑤ changed 보고 | 실제 바뀐 것만 changed로 표시 | notify 핸들러는 changed일 때만 예약 — ok면 재시작이 안 걸림 |
| ⑥ 검증·재적용 | 주기적 converge로 드리프트 교정 | 정기 재적용을 안 하면 수동 변경(드리프트)이 쌓여 코드와 실서버가 어긋남 |
즉 "수렴 완료"는 세 가지가 참이라는 뜻입니다 — 선언에 present와 absent가 모두 있고(①), 매 태스크가 현재 상태를 검사해 차이만 건드리며(③④), 주기적으로 재적용해 드리프트를 지운다(⑥). 재실행했을 때 PLAY RECAP의 changed가 0으로 수렴하는지가 멱등성의 리트머스이고, changed가 매번 1 이상 남으면 어딘가에 비멱등 태스크(무조건 append·매번 재생성)가 숨어 있다는 신호입니다.
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가 파일 디스크립터를 닫으면서 락이 자동으로 해제되기 때문입니다.
- 먼저 Ansible 실행 후 PLAY RECAP 의 changed/ok/failed 수를 확인하고, 그 다음 failed가 0인지 점검한다 — changed 수는 실제 변경된 호스트·태스크 수를 나타낸다
- changed=0이면 완전한 멱등성 달성 — 동일 플레이북을 3회 연속 실행해도 changed 값이 0으로 유지되어야 인프라 상태가 코드와 일치
- failed가 1 이상이면 → FAILED 메시지에서 permission denied(권한 오류)인지 unreachable(연결 오류)인지 구분하고, -v 옵션으로 상세 출력 확인
- ok 가 많고 changed=0 이면 재실행 안전 — changed 값이 있으면 어떤 파일·서비스가 바뀌었는지 ansible-playbook -v 로 태스크별 diff 확인 필요
5. 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}"
# 배포 전에 대역 외에서 서버 지문을 확인해 known_hosts에 등록합니다.
SSH_OPTS="-i $SSH_KEY -o StrictHostKeyChecking=yes -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로 병렬 실행
순차 실행은 서버가 많아질수록 시간이 선형으로 증가합니다. 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=yes \
-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=yes \
-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"
실제 운영 서버에 명령을 보내기 전, 반드시 아래 주의사항을 확인하세요.
전체 프로덕션 서버 병렬 원격 명령 실행
안전한 실행 조건: 명령을 실제 실행하기 전에 echo 또는 dry-run 모드로 대상 서버와 명령을 먼저 확인하고, 스테이징 환경에서 검증한 후 실행
실행 전 반드시 확인
- get_servers production.txt 로 대상 서버 목록을 먼저 출력하여 의도한 서버만 포함되는지 확인
- 실행할 명령이 읽기 전용인지 확인 (df, uptime, ps 등은 안전 / rm, reboot, apt upgrade 등은 위험)
- PARALLEL_JOBS 값이 너무 크면 서버 부하가 몰릴 수 있음 — 10대씩 나눠 실행 권장
run_parallel_gnu "<명령>" "production"위 항목을 모두 확인한 후 복사할 수 있습니다
7. 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=yes -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 자동화의 장점과 도구
확대
서버 50대의 설정 상태를 JSON으로 수집해 특정 조건에 맞는 서버만 필터링한 뒤 업데이트하는 작업을 Shell로 짜면 jq, awk, sed를 섞어가며 파싱하다 복잡해집니다. 예외 처리 한 줄 추가하는 것도 부담스러워지고, 코드 자체가 읽기 어려워집니다. 자동화가 일정 수준 이상 복잡해지면 Python으로 작성하는 것이 유지보수 면에서 훨씬 낫습니다.
Shell 스크립트는 간단한 자동화에 적합하지만, 복잡한 로직, 구조화된 데이터 처리, 더 강력한 에러 핸들링이 필요할 때는 Python이 더 적합합니다.
Shell vs Python 선택 기준:
| 상황 | 권장 | 이유 |
|---|---|---|
| 명령어 파이프라인 조합 | Shell | 자연스러운 문법 |
| JSON/YAML 파싱 | Python | 내장 라이브러리 |
| 복잡한 비즈니스 로직 | Python | 가독성, 테스트 용이 |
| 병렬 처리 (고급) | Python | threading, asyncio |
| SSH 자동화 | Python | paramiko 라이브러리 |
| 간단한 파일 조작 | Shell | 짧고 빠름 |
| 재사용 가능한 도구 | 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로 명령 실행
#!/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=yes",
"-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 연결
#!/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 실행
#!/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 설정 파일 읽기
#!/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 설정 파일 예시:
# /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
심화 — 멱등성만으로는 수렴하지 않는다
심화: present만 관리하면 절반이다 — 수렴·삭제·드리프트
멱등성은 '같은 스크립트를 여러 번 돌려도 결과가 같다'입니다. 그런데 실무에서 진짜 원하는 것은 그보다 강한 성질 — '서버가 내가 의도한 전체 상태로 수렴(converge) 한다'입니다. 이 둘의 틈에서 조용한 사고가 납니다.
- 추가만 하고 삭제는 안 한다: 대부분의 멱등 태스크는
grep -qF || echo >> file,id user || useradd처럼 '없으면 추가/보장'만 합니다. 즉state: present만 다룹니다. 스크립트에서 어떤 항목(옛 SSH 키, 옛 설정 줄, 옛 패키지)을 지워도, 그것은 '앞으로 추가하지 않는다'일 뿐 이미 배포된 서버에서 그 항목을 없애지 않습니다. 유령 상태(orphaned state)가 남습니다. - 수렴에는 absent 선언이 필요하다: 진짜 상태 관리는 '있어야 할 것'뿐 아니라 '없어야 할 것'까지 선언해야 완성됩니다. Ansible의
state: absent,lineinfile ... state: absent가 그 역할입니다. 더 강한 방식은authorized_keys를 '허용 키 목록 전체로 통째 덮어쓰기'처럼 상태 전체를 선언해, 목록에서 빠진 것이 자동으로 사라지게 하는 것입니다. - 드리프트는 멱등성으로 안 막힌다: 누군가 수동으로 서버를 바꾸거나, 스크립트 버전이 시점마다 달라지면 fleet이 이질적으로 갈라집니다. 멱등성은 '한 번의 실행이 현재 스크립트로 수렴'을 보장할 뿐, '전 서버가 서로 같음'을 보장하지 않습니다. 그래서 단일 진실 소스(버전 고정)와 정기 재적용(주기적 converge)이 필요합니다.
정리하면, 성숙한 자동화는 '추가만 하는 스크립트'에서 '원하는 최종 상태 전체를 선언하고 매 실행마다 어긋난 것을 교정(remediate)하는' 방향으로 갑니다. 멱등성은 그 출발점이지 종착점이 아닙니다.
상황: 퇴사자의 공개키를 server-init.sh의 authorized_keys 추가 목록에서 삭제했습니다. 그런데 이미 구축돼 운영 중인 서버 80대에서는 그 키로 SSH 접속이 그대로 됩니다. 새로 초기화한 서버에만 키가 없어, fleet이 두 갈래로 나뉘었습니다.
원인: 키 추가 태스크가 grep -qF "$key" authorized_keys || echo "$key" >> authorized_keys 형태의 '없으면 추가' 멱등 방식이었습니다. 이 방식은 present만 관리합니다. 스크립트에서 키를 지운 것은 '앞으로 이 키를 추가하지 않는다'는 뜻일 뿐, 이미 파일에 들어 있는 키를 능동적으로 제거하지 않습니다. 그래서 옛 서버들엔 잔존, 새 서버엔 부재라는 불일치가 생깁니다.
진단: 스크립트가 관리하는 리소스를 실제 서버 상태와 비교(diff)해 '스크립트엔 없는데 서버엔 있는' 항목을 찾습니다. 이 경우 전 서버에 대해 grep -F "$old_key" ~/.ssh/authorized_keys를 병렬로 돌려 잔존 범위(몇 대에 남아 있는지)를 먼저 확정합니다 — 보안 사고이므로 범위 파악이 최우선입니다.
해결: 키를 명시적으로 제거하는 absent 태스크(예: sed -i 또는 Ansible authorized_key: state=absent)를 추가해 전 서버에 재적용합니다. 근본적으로는 authorized_keys를 '현재 허용 키 목록 전체로 덮어쓰기'처럼 상태 전체를 선언하는 방식으로 바꿔, 목록에서 빠진 키가 다음 converge에서 자동으로 사라지게 합니다. 여기에 정기 재적용을 걸어 수동 드리프트까지 함께 교정합니다.
11. Ansible 기초 개념 미리보기
지금까지 배운 Shell/Python 자동화는 Ansible이 내부에서 하는 일과 매우 유사합니다. Ansible은 이러한 패턴을 더 선언적이고 체계적으로 표현할 수 있게 해줍니다.
# 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·staging·dev.txt 서버 목록)checks/—disk-check-all.sh,service-health.sh,cert-expiry.shoperations/—server-init.sh,deploy-app.sh,rotate-logs.shpython/—parallel_ssh.py,config_loader.py,report_generator.pycron.d/—disk-check(매일 09시),cert-check(매주 월요일)
일반적인 운영 시나리오:
- 신규 서버 추가:
servers/production.txt에 호스트명 추가 →server-init.sh를 해당 서버에서 실행 - 일괄 패치: 전체 서버에 보안 패치 적용 →
parallel_ssh.py로apt-get upgrade -y병렬 실행 - 디스크 알람: cron으로
disk-check-all.sh실행 → 90% 초과 시 Slack 알람 발송 - 인증서 갱신 전 점검:
cert-expiry.sh로 30일 이내 만료 인증서 목록 생성
이 워크플로우는 점점 Ansible, Terraform, Kubernetes로 발전하지만, 기본 원리(멱등성, 병렬성, 예외처리)는 모든 도구에 동일하게 적용됩니다.
DevOps/SRE 엔지니어 면접에서 인프라 자동화 역량을 어떻게 평가하는지 알아봅니다.
면접관이 중점적으로 보는 것:
-
멱등성 이해: "이 스크립트를 두 번 실행하면 어떻게 됩니까?"라는 질문에 명확히 답할 수 있어야 합니다.
-
에러 핸들링:
set -euo pipefail과trap을 이유와 함께 설명할 수 있어야 합니다. -
확장성 고려: "서버가 1000대로 늘어나면 이 스크립트가 동작합니까?"
-
보안 의식: 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 커밋 방지
확대
자동화 스크립트에서 가장 흔한 보안 사고는 패스워드/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 모드
확대
패키지 업데이트 스크립트를 200대 서버에 적용하던 중 20대에서 실패가 났습니다. 나머지 180대는 계속 진행해야 할까요, 즉시 중단해야 할까요? 실패 정책을 미리 정해두지 않으면 장애가 전체로 번지거나, 반대로 일부 서버만 업데이트된 채 불일치 상태로 남게 됩니다. fail-fast와 continue-on-error 중 어느 쪽을 쓸지, dry-run을 먼저 돌려보는 습관까지 갖추면 대량 배포 사고를 막을 수 있습니다.
수십~수백 대 서버에 동시 적용하는 자동화 스크립트에서 실패 정책을 미리 설계해야 합니다.
fail-fast vs continue-on-error 선택 기준:
| 시나리오 | 권장 정책 | 이유 |
|---|---|---|
| 보안 패치 적용 | fail-fast | 일부만 패치되면 보안 격차 발생 |
| 로그 수집 설정 | continue-on-error | 1대 실패해도 나머지 서버는 진행 |
| 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 pipefail | e=오류 즉시 종료, u=미정의 변수 오류, o pipefail=파이프 실패 감지 |
| trap | EXIT/ERR/INT 신호를 가로채어 정리 작업 수행 |
| 함수 라이브러리 | source lib.sh로 공통 함수 재사용, 중복 로딩 방지 |
| 락 파일 | noclobber(set -C) 또는 flock으로 중복 실행 방지 |
| 병렬 실행 | xargs -P 또는 GNU parallel로 다수 서버 동시 처리 |
| Python 자동화 | subprocess, paramiko, ThreadPoolExecutor, YAML 설정 |
| Ansible 미리보기 | state: present가 멱등성의 선언적 표현 |
명령어·단축키 빠른 참조
이 모듈에서 다룬 다중 서버 자동화 명령을 멱등성·병렬 실행·안전장치 관점에서 실전 옵션과 함께 모았습니다. "예" 열의 조합을 그대로 써도 됩니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
set -euo pipefail | 프로덕션 셸 3대 안전 옵션(에러·미정의변수·파이프실패 시 즉시 중단) | 스크립트 첫 줄에 set -euo pipefail, 실패해도 되는 곳만 cmd || true |
trap | 종료·에러·인터럽트 신호를 가로채 정리·롤백 실행 | trap cleanup EXIT · trap on_error ERR · trap on_interrupt INT TERM |
ssh | 원격 서버에 명령 실행(자동화용 무중단 옵션) | ssh -i key -o BatchMode=yes -o ConnectTimeout=10 user@host "df -h /" |
ssh-keygen / ssh-copy-id | 패스워드 없는 키 기반 인증 준비 | ssh-keygen -t ed25519 && ssh-copy-id user@target-host |
flock | 파일 디스크립터 락으로 스크립트 중복 실행 방지 | exec 200>lock; flock -n 200 || exit 1 |
grep -qF | 내용이 없을 때만 추가하는 멱등성 검사 | grep -qF "$LINE" ~/.bashrc || echo "$LINE" >> ~/.bashrc |
id | 사용자 존재 확인 후 조건부 생성(멱등) | id deploy-user &>/dev/null || useradd -m -s /bin/bash deploy-user |
xargs -P | 서버 목록을 받아 SSH를 병렬 실행 | get_servers list | xargs -P10 -I{} ssh {} "uptime" |
parallel (GNU) | 결과·joblog를 남기는 고급 병렬 실행 | parallel --jobs 20 --timeout 60 --joblog log.txt --tag ssh {} "df -h /" |
df -h | 마운트별 디스크 사용량 점검 | df -h --output=target,size,used,avail,pcent |
dpkg-query | 로케일과 무관하게 패키지 설치 여부 확인 | dpkg-query -W -f='${Status}' nginx | grep -q "install ok installed" |
systemctl | 서비스 상태 확인 후 조건부 기동(멱등) | systemctl is-active --quiet fail2ban || systemctl enable --now fail2ban |
sort -V | 문자열 비교 함정을 피한 버전 비교 | printf '%s\n' "$A" "$B" | sort -V | head -1 |
bash -x | 실행 명령을 추적해 예기치 않은 종료 디버깅 | bash -x your-script.sh 2>&1 | head -50 |
관련 모듈로 더 깊이:
- 백엔드 개발자를 위한 실전 Bash 쉘 스크립트 핵심 가이드 — 자동화의 기본 빌딩 블록인 셸 스크립트 작성 기초
- cron으로 리눅스 주기적 반복 작업(배치) 예약 및 백업 자동화 — 자동화 작업을 주기적으로 실행하는 스케줄링 계층
- BIOS/UEFI, GRUB 로더부터 systemd 시작까지의 부팅 프로세스 완전 분석 — 서버를 표준 상태로 초기 구성하는 프로비저닝과 자동화의 연결
다음 모듈에서는 Ansible을 본격적으로 학습하며, 이 모듈에서 배운 Shell/Python 자동화 원칙이 어떻게 선언적 코드로 발전하는지 다룹니다.