새벽 3시, 로그 디렉토리가 꽉 찼다는 알림이 옵니다. 접속해보니 30일 넘은 로그 파일이 수백 개 쌓여 있습니다. 그날 밤은 손으로 지웠지만, 다음 달에 또 같은 일이 생겼습니다. 명령어 서너 줄을 파일에 저장해 cron에 등록하는 것만으로 이 상황은 영구히 해결됩니다. Bash 스크립트는 반복 작업을 없애는 가장 직접적인 도구입니다.
Bash 스크립팅 기초
- 1Shebang(#!/bin/bash)과 chmod +x로 실행 가능한 스크립트를 만들 수 있다
- 2변수·특수변수($?, $#, $@)와 조건문(if/elif/else, [[ ]])을 작성할 수 있다
- 3for, while, until 반복문으로 서버 관리 작업을 자동화할 수 있다
- 4파이프(|)와 리다이렉션(>, 2>&1)으로 출력 흐름을 제어할 수 있다
- 5cron에 스크립트를 등록하고 CRLF 개행 문자 문제를 해결할 수 있다
bash --versiontouch script.sh && chmod +x script.sh모든 스크립트 상단에 추가해 오류 발생 시 즉시 종료되도록 설정합니다
crontab -e스크립트의 시작: Shebang과 실행 권한

서버에서 수동으로 반복하던 작업(로그 정리, 백업 압축, 서비스 재시작 등)을 자동화하기로 했을 때, 명령어들을 파일에 모아두고 ./backup.sh를 실행하면 되겠다고 생각합니다. 그런데 막상 실행하면 Permission denied나 /usr/bin/env: bad interpreter 오류가 납니다. 스크립트 파일에는 실행 권한이 없고, 어떤 인터프리터로 실행할지 선언도 없기 때문입니다. Shebang과 실행 권한은 스크립트 자동화의 첫 관문이며, 이 두 가지를 올바르게 설정하지 않으면 스크립트는 동작하지 않습니다.
Bash 스크립트는 단순한 텍스트 파일입니다. 그런데 어떻게 터미널이 이 파일을 "실행 가능한 프로그램"으로 인식할까요? 두 가지 요소가 필요합니다.

Shebang: 인터프리터 선언
파일의 첫 줄에 #!(shebang, "해시뱅")으로 시작하는 인터프리터 경로를 적습니다.
# 실습 디렉토리 준비
mkdir -p /tmp/linux/part2/exam_13 && cd /tmp/linux/part2/exam_13
#!/bin/bash
커널은 파일을 실행할 때 첫 두 바이트가 #!이면 뒤에 오는 경로의 프로그램을 인터프리터로 사용합니다. 즉 #!/bin/bash는 "이 파일을 /bin/bash로 해석하라"는 지시입니다.
Shebang은 여러 변형이 있으며 어떤 것을 쓰느냐에 따라 이식성이 달라집니다. 실무에서 자주 보이는 형태를 정리하면 다음과 같습니다.
| Shebang | 설명 |
|---|---|
#!/bin/bash | 절대 경로로 bash 지정. 가장 일반적 |
#!/usr/bin/env bash | PATH에서 bash를 찾음. 다양한 시스템에서 이식성이 높음 |
#!/bin/sh | POSIX sh 사용. bash 전용 기능([[ ]], 배열 등) 사용 불가 |
#!/usr/bin/python3 | Python 스크립트에도 동일한 방식 적용 |
실무 팁: 팀 서버가 고정된 경우
#!/bin/bash, 다양한 배포판에 배포할 스크립트라면#!/usr/bin/env bash를 권장합니다.
실행 권한 부여: chmod +x
파일을 생성하면 기본적으로 실행 권한이 없습니다. 스크립트를 ./script.sh 형태로 직접 실행하려면 x 권한을 추가해야 합니다.
# 스크립트 파일 생성
vim monitor.sh
# 실행 권한 부여 (소유자, 그룹, 기타 모두에게)
chmod +x monitor.sh
# 또는 소유자에게만
chmod u+x monitor.sh
# 실행
./monitor.sh
권한을 부여하지 않고 실행하면 다음 오류가 발생합니다.
bash: ./monitor.sh: Permission denied
이 경우 두 가지 대안이 있습니다.
# 대안 1: bash에 인자로 전달 (실행 권한 불필요)
bash monitor.sh
# 대안 2: chmod +x로 권한 추가 후 실행
chmod +x monitor.sh && ./monitor.sh
기본 스크립트 뼈대
#!/bin/bash
# ==============================================================
# 스크립트 이름: monitor.sh
# 목적: 서버 기본 상태를 점검하고 결과를 출력
# 작성자: ops-team
# 작성일: 2026-03-26
# ==============================================================
set -euo pipefail
# set -e : 명령 실패 시 즉시 종료
# set -u : 미정의 변수 사용 시 오류
# set -o pipefail : 파이프 중간 실패도 감지
echo "서버 상태 점검을 시작합니다."
set -euo pipefail은 스크립트 상단에 항상 추가하는 것이 좋습니다. 이 옵션 없이는 명령이 실패해도 스크립트가 계속 실행되어 예기치 않은 결과를 낳을 수 있습니다.
실습 전 디렉토리와 예제 파일을 먼저 준비합니다.
# 실습 디렉토리 준비
mkdir -p /tmp/linux/part2/exam_8 && cd /tmp/linux/part2/exam_8
# 실습용 샘플 데이터 파일 생성
cat > /tmp/linux/part2/exam_8/sample_data.txt << 'EOF'
server-01 online 85
server-02 online 23
server-03 offline 0
server-04 online 67
server-05 online 91
EOF
이제 실습을 진행합니다.
서버의 기본 정보를 출력하는 스크립트를 작성합니다.
1단계: 파일 생성
vim ~/first-script.sh
2단계: 내용 작성
#!/bin/bash
# 서버 기본 정보 출력 스크립트
echo "========================================="
echo " 서버 기본 정보"
echo "========================================="
echo "호스트명: $(hostname)"
echo "현재 날짜: $(date '+%Y-%m-%d %H:%M:%S')"
echo "업타임: $(uptime -p)"
echo "현재 사용자: $(whoami)"
echo "현재 디렉토리: $(pwd)"
echo ""
echo "--- CPU 정보 ---"
grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | xargs
echo ""
echo "--- 메모리 사용량 ---"
free -h
echo ""
echo "--- 디스크 사용량 ---"
df -h /
echo "========================================="
3단계: 실행 권한 부여 및 실행
chmod +x ~/first-script.sh
~/first-script.sh
예상 출력
=========================================
서버 기본 정보
=========================================
호스트명: web-server-01
현재 날짜: 2026-03-26 14:32:11
업타임: up 3 days, 2 hours, 15 minutes
현재 사용자: deploy
현재 디렉토리: /home/deploy
...
$(명령어) 형태를 **명령어 치환(command substitution)**이라 합니다. 괄호 안의 명령을 실행한 결과를 문자열로 대입합니다.
- chmod +x 후 ls -l ~/first-script.sh 실행 시 권한에 x 플래그(-rwxr-xr-x)가 표시된다
- ~/first-script.sh 실행 시 '서버 기본 정보' 헤더와 함께 hostname, date, uptime 값이 출력된다
- $(hostname) 결과가 실제 서버의 호스트명과 일치한다
- 스크립트 안에서 echo $? 로 직전 명령이 성공(0)했는지 확인할 수 있다
변수: 데이터를 저장하고 재사용하기

스크립트를 짜다 보면 같은 값(서버 IP, 로그 경로, 파일 이름 등)이 여러 곳에 반복해서 등장합니다. 하드코딩된 값이 여기저기 흩어져 있으면 나중에 경로 하나 바꿀 때 놓친 곳에서 에러가 납니다. 변수로 뽑아두면 한 곳만 수정해도 전체에 반영됩니다. 또한 $1, $2 같은 위치 인자를 쓰면 같은 스크립트를 다른 서버나 다른 날짜에 재사용할 수 있습니다. Bash 변수는 Python이나 Java와 달리 타입 선언이 없고, 선언과 사용의 규칙도 독특한 부분이 있어서 처음엔 실수가 잦습니다.
변수 선언과 사용
Bash 변수는 타입 선언 없이 바로 대입합니다. 주의할 점은 = 앞뒤에 공백이 없어야 한다는 것입니다.
#!/bin/bash
# 올바른 선언
SERVER_NAME="web-01"
MAX_CONNECTIONS=100
LOG_DIR="/var/log/myapp"
# 잘못된 선언 (공백 때문에 오류 발생)
# SERVER_NAME = "web-01" # 오류!
# 변수 사용: $ 접두어
echo "서버: $SERVER_NAME"
echo "최대 연결수: ${MAX_CONNECTIONS}" # 중괄호로 감싸면 경계가 명확함
# 중괄호가 필요한 경우
PREFIX="log"
echo "${PREFIX}_file.txt" # log_file.txt (올바름)
echo "$PREFIX_file.txt" # 빈 문자열 (PREFIX_file 이라는 변수를 찾음)
변수 범위와 환경 변수
#!/bin/bash
# 일반 변수: 현재 스크립트에서만 유효
local_var="나만 보임"
# export: 자식 프로세스(서브쉘)에 전달
export APP_ENV="production"
# 읽기 전용 변수
readonly VERSION="1.0.0"
# VERSION="2.0.0" # 오류: readonly 변수는 변경 불가
특수 변수
Bash는 스크립트 실행 정보를 담은 특수 변수를 자동으로 제공합니다.
| 변수 | 의미 | 예시 |
|---|---|---|
$0 | 스크립트 자체의 이름 | ./deploy.sh |
$1, $2, ... | 위치 매개변수 (커맨드라인 인자) | $1 = 첫 번째 인자 |
$@ | 모든 인자를 개별 문자열로 | "$1" "$2" "$3" |
$* | 모든 인자를 하나의 문자열로 | "$1 $2 $3" |
$# | 인자의 개수 | 3 |
$? | 직전 명령의 종료 코드 (0=성공) | 0 또는 1 |
$$ | 현재 스크립트의 PID | 12345 |
$! | 마지막으로 백그라운드 실행한 명령의 PID | 12346 |
$_ | 직전 명령의 마지막 인자 | /var/log |
#!/bin/bash
# 특수 변수 활용 예시
echo "스크립트 이름: $0"
echo "첫 번째 인자: $1"
echo "두 번째 인자: $2"
echo "모든 인자: $@"
echo "인자 개수: $#"
echo "현재 PID: $$"
# 종료 코드 확인
ls /tmp > /dev/null 2>&1
echo "ls 명령 종료 코드: $?" # 0 (성공)
ls /존재하지않는디렉토리 > /dev/null 2>&1
echo "ls 실패 종료 코드: $?" # 2 (오류)
문자열 조작
Bash는 외부 도구 없이도 문자열을 자르고, 치환하고, 기본값을 지정하는 연산자를 내장하고 있습니다. 로그 파일 이름에서 날짜 부분만 추출하거나, 확장자를 제거하는 작업이 대표적입니다.
#!/bin/bash
FILENAME="server-log-2026-03.tar.gz"
# 문자열 길이
echo "${#FILENAME}" # 26
# 부분 문자열: ${변수:시작:길이}
echo "${FILENAME:0:10}" # server-log
# 접미어 제거 (가장 짧은 매칭)
echo "${FILENAME%.tar.gz}" # server-log-2026-03
# 접두어 제거
echo "${FILENAME#server-}" # log-2026-03.tar.gz
# 치환: ${변수/찾을값/바꿀값}
echo "${FILENAME/2026/2025}" # server-log-2025-03.tar.gz
# 기본값: 변수가 비어있으면 기본값 사용
APP_PORT="${APP_PORT:-8080}"
echo "포트: $APP_PORT" # 8080 (APP_PORT 미설정 시)
산술 연산
Bash의 기본 산술은 $(( )) 구문으로 처리합니다. 정수 연산만 지원하며, 소수점이 필요한 경우에는 bc를 사용합니다.
#!/bin/bash
A=10
B=3
# $(( )) 로 정수 산술
echo $((A + B)) # 13
echo $((A - B)) # 7
echo $((A * B)) # 30
echo $((A / B)) # 3 (정수 나눗셈)
echo $((A % B)) # 1 (나머지)
echo $((A ** B)) # 1000 (거듭제곱)
# 변수 업데이트
COUNT=0
((COUNT++))
echo $COUNT # 1
# 소수점 계산은 bc 사용
echo "scale=2; $A / $B" | bc # 3.33
사용자로부터 인자를 받아 서버에 nginx 가상 호스트를 생성하는 스크립트를 작성합니다.
#!/bin/bash
# create-vhost.sh: nginx 가상 호스트 설정 파일 생성
# 사용법: ./create-vhost.sh <도메인명> <포트>
set -euo pipefail
# --- 인자 검증 ---
if [[ $# -lt 2 ]]; then
echo "사용법: $0 <도메인명> <포트번호>"
echo "예시: $0 example.com 8080"
exit 1
fi
DOMAIN="$1"
PORT="$2"
CONFIG_DIR="/etc/nginx/sites-available"
CONFIG_FILE="${CONFIG_DIR}/${DOMAIN}.conf"
TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
echo "[${TIMESTAMP}] 가상 호스트 생성 시작: ${DOMAIN}:${PORT}"
# 포트 번호 유효성 검사
if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [[ "$PORT" -lt 1 ]] || [[ "$PORT" -gt 65535 ]]; then
echo "오류: 유효하지 않은 포트 번호: $PORT"
exit 1
fi
# 설정 파일 이미 존재하면 백업
if [[ -f "$CONFIG_FILE" ]]; then
echo "기존 설정 파일 발견 → 백업: ${CONFIG_FILE}.bak"
cp "$CONFIG_FILE" "${CONFIG_FILE}.bak"
fi
# 설정 파일 생성
cat > "$CONFIG_FILE" <<EOF
server {
listen 80;
server_name ${DOMAIN} www.${DOMAIN};
location / {
proxy_pass http://127.0.0.1:${PORT};
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
}
access_log /var/log/nginx/${DOMAIN}_access.log;
error_log /var/log/nginx/${DOMAIN}_error.log;
}
EOF
echo "설정 파일 생성 완료: $CONFIG_FILE"
echo "nginx 설정 검증 중..."
nginx -t && echo "설정 유효성 검사 통과" || {
echo "nginx 설정 오류 — 변경 사항을 롤백합니다."
rm -f "$CONFIG_FILE"
exit 1
}
echo "완료: $DOMAIN → localhost:$PORT 프록시 설정이 생성됐습니다."
echo "활성화하려면: ln -s $CONFIG_FILE /etc/nginx/sites-enabled/"
실행 테스트
chmod +x create-vhost.sh
# 정상 실행
./create-vhost.sh myapp.com 3000
# 인자 누락 테스트
./create-vhost.sh
# 출력: 사용법: ./create-vhost.sh <도메인명> <포트번호>
# 잘못된 포트 테스트
./create-vhost.sh myapp.com abc
# 출력: 오류: 유효하지 않은 포트 번호: abc
$#으로 인자 개수를 먼저 확인하고, $1, $2로 각 인자에 접근하는 패턴은 실무 스크립트의 기본 구조입니다.
기본 if 문 구조
#!/bin/bash
DISK_USAGE=85
if [[ $DISK_USAGE -ge 90 ]]; then
echo "위험: 디스크 사용량 ${DISK_USAGE}% — 즉시 조치 필요"
elif [[ $DISK_USAGE -ge 80 ]]; then
echo "경고: 디스크 사용량 ${DISK_USAGE}% — 모니터링 필요"
elif [[ $DISK_USAGE -ge 70 ]]; then
echo "주의: 디스크 사용량 ${DISK_USAGE}%"
else
echo "정상: 디스크 사용량 ${DISK_USAGE}%"
fi
[ ] vs [[ ]] 비교
| 구분 | [ ] (test) | [[ ]] (bash 확장) |
|---|---|---|
| 표준 | POSIX 표준 | bash 전용 |
| 패턴 매칭 | 불가 | =~ 정규식, * 글로브 가능 |
| 논리 연산 | -a, -o | &&, || |
| 단어 분리 | 변수 인용 필수 | 인용 없어도 안전 |
| 권장 | sh 호환 필요 시 | bash 스크립트 기본 |
#!/bin/bash
FILE="/var/log/nginx/access.log"
USER_INPUT="hello world"
# --- 파일 조건 검사 ---
if [[ -f "$FILE" ]]; then
echo "파일 존재함"
fi
if [[ -d "/var/log" ]]; then
echo "디렉토리 존재함"
fi
if [[ -r "$FILE" ]]; then
echo "파일 읽기 가능"
fi
if [[ -s "$FILE" ]]; then
echo "파일이 비어있지 않음"
fi
# --- 문자열 비교 ---
STATUS="running"
if [[ "$STATUS" == "running" ]]; then
echo "서비스 실행 중"
fi
if [[ -z "$STATUS" ]]; then
echo "STATUS가 빈 문자열"
fi
if [[ -n "$STATUS" ]]; then
echo "STATUS가 비어있지 않음"
fi
# --- 정규식 매칭 ([[ ]] 전용) ---
IP="192.168.1.100"
if [[ "$IP" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "유효한 IP 형식"
fi
# --- 숫자 비교 ---
# -eq -ne -lt -le -gt -ge
COUNT=42
if [[ $COUNT -gt 10 ]]; then
echo "$COUNT 는 10보다 큽니다"
fi
# --- 논리 연산자 ---
CPU_USAGE=75
MEM_USAGE=85
if [[ $CPU_USAGE -gt 70 && $MEM_USAGE -gt 80 ]]; then
echo "CPU와 메모리 모두 높음 — 부하 상태"
fi
# --- case 문: 여러 패턴 매칭 ---
SERVICE="nginx"
case "$SERVICE" in
nginx|apache)
echo "웹 서버"
;;
mysql|postgresql)
echo "데이터베이스 서버"
;;
redis|memcached)
echo "캐시 서버"
;;
*)
echo "알 수 없는 서비스: $SERVICE"
;;
esac
파일 조건 테스트 옵션 요약
| 옵션 | 의미 |
|---|---|
-f | 일반 파일이 존재함 |
-d | 디렉토리가 존재함 |
-e | 파일/디렉토리/링크 등 존재함 |
-r | 읽기 권한 있음 |
-w | 쓰기 권한 있음 |
-x | 실행 권한 있음 |
-s | 크기가 0보다 큼 |
-L | 심볼릭 링크임 |
for 반복문
#!/bin/bash
# --- 목록 순회 ---
SERVERS=("web-01" "web-02" "web-03" "db-01")
for SERVER in "${SERVERS[@]}"; do
echo "[$SERVER] 연결 확인 중..."
if ping -c 1 -W 2 "$SERVER" > /dev/null 2>&1; then
echo "[$SERVER] 온라인"
else
echo "[$SERVER] 응답 없음 — 점검 필요!"
fi
done
# --- 숫자 범위 ---
echo "=== 카운트다운 ==="
for i in {10..1}; do
echo -n "$i "
done
echo "발사!"
# --- C 스타일 for 문 ---
for ((i=0; i<5; i++)); do
echo "반복 $i"
done
# --- 파일 글로브 ---
echo "=== /var/log/*.log 파일 목록 ==="
for LOGFILE in /var/log/*.log; do
if [[ -f "$LOGFILE" ]]; then
SIZE=$(du -sh "$LOGFILE" | cut -f1)
echo " $LOGFILE ($SIZE)"
fi
done
# --- 커맨드 출력 순회 ---
echo "=== 실행 중인 서비스 ==="
for SERVICE in $(systemctl list-units --type=service --state=running --no-legend | awk '{print $1}'); do
echo " 실행 중: $SERVICE"
done
while 반복문
#!/bin/bash
# --- 기본 while ---
COUNT=1
while [[ $COUNT -le 5 ]]; do
echo "시도 $COUNT/5"
((COUNT++))
done
# --- 파일을 한 줄씩 읽기 ---
while IFS= read -r LINE; do
echo "처리 중: $LINE"
done < /etc/hosts
# --- 파이프로 받기 ---
ps aux | grep nginx | while IFS= read -r LINE; do
PID=$(echo "$LINE" | awk '{print $2}')
echo "nginx 프로세스 PID: $PID"
done
# --- 서비스 상태 대기 ---
MAX_WAIT=30
ELAPSED=0
echo "서비스 시작 대기 중..."
while ! systemctl is-active --quiet nginx; do
if [[ $ELAPSED -ge $MAX_WAIT ]]; then
echo "타임아웃: ${MAX_WAIT}초 내에 서비스가 시작되지 않음"
exit 1
fi
sleep 1
((ELAPSED++))
echo " ${ELAPSED}초 경과..."
done
echo "nginx 시작 완료 (${ELAPSED}초 소요)"
until 반복문
until은 while의 반대입니다. 조건이 거짓인 동안 반복하고, 조건이 참이 되면 종료합니다.
#!/bin/bash
# until: 조건이 참이 될 때까지 반복
RETRY=0
MAX_RETRY=5
until curl -sf http://localhost:8080/health > /dev/null; do
((RETRY++))
if [[ $RETRY -ge $MAX_RETRY ]]; then
echo "최대 재시도 횟수($MAX_RETRY) 초과 — 서비스 점검 필요"
exit 1
fi
echo "헬스체크 실패 (${RETRY}/${MAX_RETRY}) — 3초 후 재시도"
sleep 3
done
echo "서비스 헬스체크 통과"
break와 continue
#!/bin/bash
# break: 반복문 완전 탈출
for FILE in /var/log/app/*.log; do
SIZE=$(stat -c%s "$FILE" 2>/dev/null || echo 0)
if [[ $SIZE -gt 1073741824 ]]; then # 1GB 초과
echo "경고: $FILE 크기가 1GB 초과 — 즉시 처리 필요"
break # 즉시 반복 종료
fi
done
# continue: 현재 반복만 건너뛰기
for USER in $(getent passwd | awk -F: '$3 >= 1000 {print $1}'); do
if [[ "$USER" == "nobody" ]]; then
continue # nobody는 건너뜀
fi
echo "사용자 처리: $USER"
done
함수는 반복 사용하는 코드 블록을 이름으로 묶어 재사용성을 높입니다.
#!/bin/bash
# 함수를 활용한 서버 점검 스크립트
set -euo pipefail
# ── 로깅 함수 ───────────────────────────────────────────────
LOG_FILE="/var/log/server-check.log"
log_info() {
local MESSAGE="$1"
local TIMESTAMP
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[INFO] ${TIMESTAMP} ${MESSAGE}" | tee -a "$LOG_FILE"
}
log_warn() {
local MESSAGE="$1"
local TIMESTAMP
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[WARN] ${TIMESTAMP} ${MESSAGE}" | tee -a "$LOG_FILE" >&2
}
log_error() {
local MESSAGE="$1"
local TIMESTAMP
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[ERROR] ${TIMESTAMP} ${MESSAGE}" | tee -a "$LOG_FILE" >&2
}
# ── 반환값 사용 함수 ─────────────────────────────────────────
# Bash 함수는 정수 종료 코드만 return할 수 있음
# 문자열 "반환"은 echo + 명령어 치환을 사용
get_disk_usage() {
local MOUNT_POINT="${1:-/}"
df -h "$MOUNT_POINT" | awk 'NR==2 {print $5}' | tr -d '%'
}
get_memory_usage() {
free | awk '/^Mem:/ {printf "%.0f", ($3/$2)*100}'
}
# ── 서비스 점검 함수 ─────────────────────────────────────────
check_service() {
local SERVICE_NAME="$1"
local ALERT_RECIPIENT="${2:-root}"
if systemctl is-active --quiet "$SERVICE_NAME"; then
log_info "${SERVICE_NAME}: 정상 실행 중"
return 0
else
log_error "${SERVICE_NAME}: 서비스 중단 감지!"
log_info "${SERVICE_NAME}: 재시작 시도 중..."
if systemctl restart "$SERVICE_NAME"; then
log_info "${SERVICE_NAME}: 재시작 성공"
# 담당자에게 알림 (mailutils 또는 sendmail 필요)
echo "${SERVICE_NAME}이 중단되어 자동 재시작됐습니다." | \
mail -s "[자동복구] ${SERVICE_NAME} 재시작 알림" "$ALERT_RECIPIENT" 2>/dev/null || true
return 0
else
log_error "${SERVICE_NAME}: 재시작 실패 — 수동 점검 필요"
return 1
fi
fi
}
# ── 종합 점검 함수 ───────────────────────────────────────────
run_health_check() {
local FAILED=0
log_info "=== 서버 헬스체크 시작 ==="
# 디스크 점검
local DISK_USAGE
DISK_USAGE=$(get_disk_usage "/")
if [[ $DISK_USAGE -ge 90 ]]; then
log_error "디스크 사용량 ${DISK_USAGE}% — 위험 수준"
((FAILED++))
elif [[ $DISK_USAGE -ge 80 ]]; then
log_warn "디스크 사용량 ${DISK_USAGE}% — 경고 수준"
else
log_info "디스크 사용량 ${DISK_USAGE}% — 정상"
fi
# 메모리 점검
local MEM_USAGE
MEM_USAGE=$(get_memory_usage)
if [[ $MEM_USAGE -ge 90 ]]; then
log_error "메모리 사용량 ${MEM_USAGE}% — 위험 수준"
((FAILED++))
else
log_info "메모리 사용량 ${MEM_USAGE}% — 정상"
fi
# 서비스 점검
for SVC in nginx mysql redis; do
check_service "$SVC" "ops@company.com" || ((FAILED++))
done
log_info "=== 헬스체크 완료: 이상 항목 ${FAILED}개 ==="
return $FAILED
}
# ── 메인 실행 ───────────────────────────────────────────────
main() {
run_health_check
local EXIT_CODE=$?
if [[ $EXIT_CODE -ne 0 ]]; then
log_error "점검 중 ${EXIT_CODE}개 항목 이상 감지"
exit 1
fi
exit 0
}
main "$@"
함수의 핵심 규칙
local키워드로 함수 내부 변수를 지역 변수로 선언하면 전역 변수와 충돌을 방지합니다- 함수는 호출하기 전에 정의되어야 합니다 (또는
main패턴으로 구조화) return값은 0–255의 정수만 가능합니다. 문자열 반환은echo+$(함수명)패턴을 사용합니다
파이프와 리다이렉션: 출력 흐름 제어

스크립트에서 명령어 출력을 화면이 아닌 파일에 저장하거나, 에러 메시지는 따로 모으거나, 한 명령의 결과를 다른 명령의 입력으로 넘길 때 파이프와 리다이렉션을 씁니다. cron 작업에서 스크립트 출력을 로그 파일에 쌓거나(>> /var/log/backup.log 2>&1), grep 결과를 awk로 넘기는 패턴이 모두 이 두 개념을 기반으로 합니다. 특히 2>&1의 의미를 모르면 에러가 로그에 안 찍혀서 디버깅이 어려워지는 상황이 생깁니다.
Linux의 강력함은 작은 도구들을 파이프(|)로 연결하고, 리다이렉션으로 출력 목적지를 바꾸는 능력에서 나옵니다.
표준 스트림
모든 Linux 프로세스는 세 가지 표준 스트림을 가지고 태어납니다. 리다이렉션은 이 스트림의 목적지를 바꾸는 것이고, 파이프는 한 프로세스의 stdout을 다음 프로세스의 stdin으로 연결하는 것입니다.
| 번호 | 이름 | 기본 목적지 | 약어 |
|---|---|---|---|
| 0 | stdin (표준 입력) | 키보드 | stdin |
| 1 | stdout (표준 출력) | 터미널 화면 | stdout |
| 2 | stderr (표준 오류) | 터미널 화면 | stderr |
리다이렉션 연산자
각 연산자가 stdout/stderr를 어디로 보내는지 알면 로그 파일 분리, 오류 억제, 파일로부터 입력 읽기를 자유롭게 조합할 수 있습니다.
#!/bin/bash
# > : stdout을 파일로 (덮어쓰기)
ls /var/log > /tmp/log-list.txt
# >> : stdout을 파일로 (추가)
echo "$(date): 점검 완료" >> /var/log/cron-job.log
# 2> : stderr를 파일로
find /root -name "*.conf" 2> /tmp/find-errors.txt
# 2>&1 : stderr를 stdout으로 합치기
# (stdout이 향하는 곳으로 stderr도 보냄)
rsync -avz /data/ /backup/ > /tmp/rsync.log 2>&1
# &> : stdout과 stderr를 모두 같은 파일로 (bash 전용)
./deploy.sh &> /tmp/deploy-$(date +%Y%m%d).log
# /dev/null : 출력 완전 억제
ping -c 1 8.8.8.8 > /dev/null 2>&1 && echo "인터넷 연결 정상"
# < : 파일을 stdin으로
while IFS= read -r line; do
echo "처리: $line"
done < /etc/hosts
# Here-document (<<EOF): 여러 줄을 stdin으로
cat <<'EOF' > /tmp/config.txt
server_name=web-01
max_conn=100
log_level=info
EOF
파이프: 명령 연결
파이프(|)는 앞 명령의 stdout을 뒤 명령의 stdin으로 연결합니다. 단순한 명령들을 연결해 복잡한 데이터 처리를 한 줄로 표현하는 것이 Linux 쉘의 핵심 능력입니다.
#!/bin/bash
# 기본 파이프
ps aux | grep nginx | grep -v grep
# 복잡한 파이프 체인
# "80% 이상 사용 중인 파티션 찾기"
df -h | awk 'NR>1 {print $5, $6}' | \
sed 's/%//' | \
awk '$1 >= 80 {print "경고: " $2 " 사용률 " $1 "%"}'
# tee: 파이프 중간에서 파일로도 저장
./backup.sh | tee /var/log/backup.log | grep -i "error\|warn"
# xargs: 파이프 결과를 다음 명령의 인자로
# 7일 이상 된 .log 파일 삭제
find /var/log/app -name "*.log" -mtime +7 | xargs rm -f
# 프로세스 치환: 두 명령의 출력을 비교
diff <(ls /backup/2026-03-25/) <(ls /backup/2026-03-26/)
종료 코드와 논리 연산자
모든 명령은 종료할 때 0(성공) 또는 1 이상(실패)의 종료 코드를 반환합니다. &&와 ||는 이 종료 코드를 조건으로 다음 명령 실행 여부를 결정합니다.
#!/bin/bash
# && : 앞 명령 성공 시에만 다음 실행
mkdir -p /var/app/logs && chown app:app /var/app/logs && chmod 755 /var/app/logs
# || : 앞 명령 실패 시에만 다음 실행
systemctl start nginx || {
echo "nginx 시작 실패 — 로그를 확인하세요"
journalctl -u nginx --since "5 minutes ago"
exit 1
}
# ; : 결과와 무관하게 순서대로 실행
echo "백업 시작"; ./backup.sh; echo "백업 완료"
# 파이프라인의 종료 코드
set -o pipefail # 파이프 중 하나라도 실패하면 전체 실패
cat /var/log/app.log | grep "ERROR" | wc -l
echo "파이프라인 종료 코드: $?"
read 명령으로 대화형 입력 받기
#!/bin/bash
# interactive-deploy.sh: 배포 전 확인을 요청하는 대화형 스크립트
set -euo pipefail
# --- 기본 read ---
echo -n "배포할 환경을 입력하세요 (staging/production): "
read -r ENVIRONMENT
echo -n "배포할 버전을 입력하세요 (예: v1.2.3): "
read -r VERSION
# --- 타임아웃 있는 read (-t) ---
echo ""
echo "======================================================="
echo "배포 정보 확인"
echo " 환경 : $ENVIRONMENT"
echo " 버전 : $VERSION"
echo " 시각 : $(date '+%Y-%m-%d %H:%M:%S')"
echo "======================================================="
echo ""
read -r -t 30 -p "위 내용으로 배포를 진행하겠습니까? [y/N] " CONFIRM
# --- 비밀번호 입력 (화면 출력 없음, -s) ---
read -r -s -p "배포 키 패스프레이즈를 입력하세요: " PASSPHRASE
echo "" # read -s는 개행을 출력하지 않으므로 수동 추가
# --- 입력값 처리 ---
case "${CONFIRM,,}" in # ,, : 소문자 변환 (bash 4.0+)
y|yes)
echo "배포를 시작합니다..."
# 실제 배포 명령
;;
*)
echo "배포가 취소됐습니다."
exit 0
;;
esac
# --- 배열로 여러 값 읽기 ---
echo "점검할 서버 목록을 공백으로 구분해 입력하세요:"
read -r -a SERVER_LIST
echo "총 ${#SERVER_LIST[@]}개 서버: ${SERVER_LIST[*]}"
for SERVER in "${SERVER_LIST[@]}"; do
echo "[$SERVER] 점검 중..."
done
getopts로 옵션 파싱
실무 스크립트는 --environment production 같은 옵션 인자를 사용합니다.
#!/bin/bash
# deploy.sh: getopts를 활용한 옵션 파싱
usage() {
echo "사용법: $0 [-e <환경>] [-v <버전>] [-d] [-h]"
echo ""
echo "옵션:"
echo " -e 배포 환경 (staging|production)"
echo " -v 배포 버전 (예: v1.2.3)"
echo " -d 드라이런 모드 (실제 배포 없이 확인만)"
echo " -h 도움말 출력"
exit 1
}
ENVIRONMENT=""
VERSION=""
DRY_RUN=false
while getopts "e:v:dh" OPT; do
case "$OPT" in
e) ENVIRONMENT="$OPTARG" ;;
v) VERSION="$OPTARG" ;;
d) DRY_RUN=true ;;
h) usage ;;
*) usage ;;
esac
done
# 필수 인자 검증
[[ -z "$ENVIRONMENT" ]] && { echo "오류: -e 옵션 필수"; usage; }
[[ -z "$VERSION" ]] && { echo "오류: -v 옵션 필수"; usage; }
echo "환경: $ENVIRONMENT | 버전: $VERSION | 드라이런: $DRY_RUN"
if $DRY_RUN; then
echo "[드라이런] 실제 배포를 실행하지 않습니다."
else
echo "배포 실행..."
fi
실행 예시
./deploy.sh -e staging -v v1.2.3
./deploy.sh -e production -v v1.2.3 -d
./deploy.sh -h
실무에서 가장 자주 마주치는 상황 중 하나입니다. 디스크가 가득 차면 서비스가 멈추기 때문에 미리 알림을 받아야 합니다.
#!/bin/bash
# disk-alert.sh: 디스크 사용량 모니터링 및 알림
# 크론 등록 예시: */15 * * * * /opt/scripts/disk-alert.sh
#
# 의존성: mailutils (또는 sendmail), 또는 슬랙 웹훅
set -euo pipefail
# ── 설정 값 ──────────────────────────────────────────────────
WARN_THRESHOLD=80 # 경고 임계값 (%)
CRITICAL_THRESHOLD=90 # 위험 임계값 (%)
ALERT_EMAIL="ops@company.com"
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL:-}" # 환경 변수로 주입
HOSTNAME=$(hostname -f)
SCRIPT_NAME=$(basename "$0")
LOG_FILE="/var/log/disk-alert.log"
# ── 로깅 ────────────────────────────────────────────────────
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') [$1] $2" | tee -a "$LOG_FILE"
}
# ── 슬랙 알림 함수 ──────────────────────────────────────────
send_slack_alert() {
local LEVEL="$1" # WARNING | CRITICAL
local MOUNT="$2"
local USAGE="$3"
local COLOR
[[ "$LEVEL" == "CRITICAL" ]] && COLOR="danger" || COLOR="warning"
if [[ -z "$SLACK_WEBHOOK" ]]; then
log "INFO" "Slack 웹훅 미설정 — 슬랙 알림 건너뜀"
return 0
fi
local PAYLOAD
PAYLOAD=$(cat <<EOF
{
"attachments": [{
"color": "${COLOR}",
"title": "[${LEVEL}] 디스크 사용량 경고 — ${HOSTNAME}",
"text": "마운트 포인트 *${MOUNT}* 사용량이 *${USAGE}%* 에 도달했습니다.",
"footer": "${SCRIPT_NAME}",
"ts": $(date +%s)
}]
}
EOF
)
curl -s -X POST -H "Content-Type: application/json" \
-d "$PAYLOAD" "$SLACK_WEBHOOK" > /dev/null
}
# ── 이메일 알림 함수 ─────────────────────────────────────────
send_email_alert() {
local LEVEL="$1"
local MOUNT="$2"
local USAGE="$3"
local SUBJECT="[${LEVEL}] ${HOSTNAME} 디스크 ${MOUNT} ${USAGE}% 사용"
if ! command -v mail &> /dev/null; then
log "WARN" "mail 명령을 찾을 수 없음 — 이메일 알림 건너뜀"
return 0
fi
{
echo "서버: ${HOSTNAME}"
echo "마운트: ${MOUNT}"
echo "사용률: ${USAGE}%"
echo "시각: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
echo "--- 현재 디스크 상태 ---"
df -h
} | mail -s "$SUBJECT" "$ALERT_EMAIL"
log "INFO" "이메일 알림 발송: $SUBJECT → $ALERT_EMAIL"
}
# ── 디스크 점검 함수 ─────────────────────────────────────────
check_disk_usage() {
local ALERT_SENT=0
# df 출력에서 헤더 제외, 사용률 파싱
# 출력 형식: 사용률(%) 마운트포인트
while IFS= read -r LINE; do
# 숫자%로 시작하는 필드 추출
USAGE=$(echo "$LINE" | awk '{print $5}' | tr -d '%')
MOUNT=$(echo "$LINE" | awk '{print $6}')
# 숫자가 아니면 건너뜀 (tmpfs, devtmpfs 등 제외 가능)
if ! [[ "$USAGE" =~ ^[0-9]+$ ]]; then
continue
fi
if [[ $USAGE -ge $CRITICAL_THRESHOLD ]]; then
log "CRITICAL" "${MOUNT} 사용량 ${USAGE}% — 즉각 조치 필요"
send_slack_alert "CRITICAL" "$MOUNT" "$USAGE"
send_email_alert "CRITICAL" "$MOUNT" "$USAGE"
((ALERT_SENT++))
elif [[ $USAGE -ge $WARN_THRESHOLD ]]; then
log "WARNING" "${MOUNT} 사용량 ${USAGE}% — 경고"
send_slack_alert "WARNING" "$MOUNT" "$USAGE"
((ALERT_SENT++))
else
log "INFO" "${MOUNT} 사용량 ${USAGE}% — 정상"
fi
done < <(df -h --output=pcent,target | tail -n +2)
return $ALERT_SENT
}
# ── 메인 ────────────────────────────────────────────────────
main() {
log "INFO" "디스크 사용량 점검 시작 (경고: ${WARN_THRESHOLD}%, 위험: ${CRITICAL_THRESHOLD}%)"
check_disk_usage
local RESULT=$?
if [[ $RESULT -gt 0 ]]; then
log "WARN" "총 ${RESULT}개 파티션에서 임계값 초과"
exit 1
fi
log "INFO" "모든 파티션 정상"
exit 0
}
main
스크립트 설치 및 테스트
# 스크립트 저장
sudo cp disk-alert.sh /opt/scripts/disk-alert.sh
sudo chmod +x /opt/scripts/disk-alert.sh
# 임계값을 낮춰 테스트 (현재 디스크 사용량보다 낮게)
WARN_THRESHOLD=10 /opt/scripts/disk-alert.sh
# 로그 확인
tail -f /var/log/disk-alert.log
장기간 운영하는 서버에서 로그 파일이 쌓여 디스크를 가득 채우는 문제가 자주 발생합니다.
#!/bin/bash
# log-cleanup.sh: 오래된 로그 파일 정리 및 압축
# 크론 등록: 0 3 * * * /opt/scripts/log-cleanup.sh
#
# 동작:
# - 30일 이상 된 .log 파일 삭제
# - 7일 이상 된 .log 파일은 gzip 압축
# - 압축된 .gz 파일은 90일 후 삭제
# - 실행 결과를 로그에 기록
set -euo pipefail
# ── 설정 ────────────────────────────────────────────────────
LOG_DIRS=(
"/var/log/nginx"
"/var/log/app"
"/var/log/myservice"
)
COMPRESS_AFTER_DAYS=7 # N일 이상 된 로그 압축
DELETE_LOG_AFTER_DAYS=30 # N일 이상 된 원본 로그 삭제
DELETE_GZ_AFTER_DAYS=90 # N일 이상 된 압축 로그 삭제
SCRIPT_LOG="/var/log/log-cleanup.log"
DRY_RUN="${DRY_RUN:-false}" # DRY_RUN=true 로 실행하면 실제 삭제 안 함
# ── 유틸리티 ────────────────────────────────────────────────
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" | tee -a "$SCRIPT_LOG"; }
run_cmd() {
if [[ "$DRY_RUN" == "true" ]]; then
log "[DRY-RUN] $*"
else
log "[EXEC] $*"
"$@"
fi
}
bytes_to_human() {
local BYTES="$1"
if [[ $BYTES -ge 1073741824 ]]; then
echo "$(echo "scale=1; $BYTES/1073741824" | bc)GB"
elif [[ $BYTES -ge 1048576 ]]; then
echo "$(echo "scale=1; $BYTES/1048576" | bc)MB"
elif [[ $BYTES -ge 1024 ]]; then
echo "$(echo "scale=1; $BYTES/1024" | bc)KB"
else
echo "${BYTES}B"
fi
}
# ── 디렉토리별 정리 ──────────────────────────────────────────
cleanup_directory() {
local DIR="$1"
if [[ ! -d "$DIR" ]]; then
log "경고: 디렉토리 없음 — 건너뜀: $DIR"
return 0
fi
log "--- 정리 시작: $DIR ---"
local DELETED_COUNT=0
local DELETED_BYTES=0
local COMPRESSED_COUNT=0
# 1단계: 오래된 원본 로그 삭제
while IFS= read -r FILE; do
local SIZE
SIZE=$(stat -c%s "$FILE" 2>/dev/null || echo 0)
run_cmd rm -f "$FILE"
((DELETED_COUNT++)) || true
((DELETED_BYTES += SIZE)) || true
log " 삭제: $FILE ($(bytes_to_human $SIZE))"
done < <(find "$DIR" -maxdepth 2 -name "*.log" -type f -mtime "+${DELETE_LOG_AFTER_DAYS}" 2>/dev/null)
# 2단계: 오래된 압축 로그 삭제
while IFS= read -r FILE; do
local SIZE
SIZE=$(stat -c%s "$FILE" 2>/dev/null || echo 0)
run_cmd rm -f "$FILE"
((DELETED_COUNT++)) || true
((DELETED_BYTES += SIZE)) || true
log " 삭제(압축): $FILE ($(bytes_to_human $SIZE))"
done < <(find "$DIR" -maxdepth 2 -name "*.log.gz" -type f -mtime "+${DELETE_GZ_AFTER_DAYS}" 2>/dev/null)
# 3단계: 압축 대상 로그 gzip 압축
while IFS= read -r FILE; do
local ORIG_SIZE
ORIG_SIZE=$(stat -c%s "$FILE" 2>/dev/null || echo 0)
run_cmd gzip -9 "$FILE"
((COMPRESSED_COUNT++)) || true
log " 압축: $FILE ($(bytes_to_human $ORIG_SIZE))"
done < <(find "$DIR" -maxdepth 2 -name "*.log" -type f -mtime "+${COMPRESS_AFTER_DAYS}" ! -mtime "+${DELETE_LOG_AFTER_DAYS}" 2>/dev/null)
log " 완료: 삭제 ${DELETED_COUNT}개 ($(bytes_to_human $DELETED_BYTES)), 압축 ${COMPRESSED_COUNT}개"
}
# ── 메인 ────────────────────────────────────────────────────
main() {
log "========================================"
log "로그 정리 시작 (DRY_RUN=${DRY_RUN})"
log " 압축 기준: ${COMPRESS_AFTER_DAYS}일 이상"
log " 삭제 기준: ${DELETE_LOG_AFTER_DAYS}일 이상 (원본)"
log " 삭제 기준: ${DELETE_GZ_AFTER_DAYS}일 이상 (압축)"
log "========================================"
for DIR in "${LOG_DIRS[@]}"; do
cleanup_directory "$DIR"
done
log "========================================"
log "로그 정리 완료"
log "========================================"
}
main
사용 방법
# 실제 실행
sudo /opt/scripts/log-cleanup.sh
# 드라이런 (실제로 삭제하지 않고 무엇을 할지만 출력)
sudo DRY_RUN=true /opt/scripts/log-cleanup.sh
# 결과 확인
tail -50 /var/log/log-cleanup.log
상황: Windows에서 작성한 스크립트를 Linux 서버에 올려 실행했을 때 위 오류가 발생합니다. 또는 스크립트는 실행되지만 변수 비교가 항상 실패하거나, 예기치 않은 출력이 보입니다.
원인: Windows는 줄 끝에 CR+LF(\r\n, 0x0D 0x0A) 두 바이트를 사용합니다. Linux는 LF(\n, 0x0A) 한 바이트만 사용합니다. Windows에서 저장한 스크립트를 그대로 Linux로 옮기면 각 줄 끝에 보이지 않는 \r(Carriage Return, ^M)이 포함됩니다. Shebang 줄이 #!/bin/bash\r이 되면 커널이 /bin/bash\r이라는 인터프리터를 찾으려 하고, 존재하지 않으므로 오류가 납니다.
진단
# 방법 1: cat -A 로 줄 끝 확인 (^M$는 CRLF를 의미)
cat -A deploy.sh | head -5
# #!/bin/bash^M$ ← ^M이 보이면 CRLF
# 방법 2: file 명령
file deploy.sh
# deploy.sh: Bourne-Again shell script, ASCII text, with CRLF line terminators
# 방법 3: xxd로 바이트 레벨 확인
xxd deploy.sh | head -3
# 0x0d 0x0a 가 보이면 CRLF
해결 방법
# 방법 1: dos2unix 도구 (가장 간단)
sudo apt install dos2unix # Ubuntu/Debian
sudo yum install dos2unix # RHEL/CentOS
dos2unix deploy.sh # 파일을 LF로 변환
unix2dos deploy.sh # 반대 방향 (참고용)
# 여러 파일 일괄 변환
find /opt/scripts -name "*.sh" | xargs dos2unix
# 방법 2: sed로 변환 (dos2unix 없을 때)
sed -i 's/\r//' deploy.sh
# 방법 3: tr로 변환
tr -d '\r' < deploy.sh > deploy-fixed.sh
mv deploy-fixed.sh deploy.sh
# 방법 4: vim에서 직접 변환
vim deploy.sh
# vim 내부에서:
# :set fileformat=unix
# :wq
예방 방법
Windows 환경에서 개발하는 경우, .gitattributes 파일을 프로젝트 루트에 추가합니다.
# .gitattributes
# 쉘 스크립트는 항상 LF로 저장
*.sh text eol=lf
*.bash text eol=lf
# Windows 파일은 CRLF 허용
*.bat text eol=crlf
*.ps1 text eol=crlf
# 자동 감지
* text=auto
VS Code를 사용한다면 우측 하단의 CRLF 표시를 클릭해 LF로 변경하거나, .editorconfig를 설정합니다.
# .editorconfig
[*.sh]
end_of_line = lf
charset = utf-8
상황: 스크립트 파일이 분명히 존재하는데 실행 시 위 오류가 발생합니다. 또는 sudo로 실행 시 스크립트 내부에서 특정 명령이 실패합니다.
원인: 파일에 실행 권한(x) 비트가 설정되지 않았거나, 다른 사용자 소유이거나, /tmp 등 noexec 옵션으로 마운트된 디렉토리에서 실행을 시도하는 경우입니다.
진단:
ls -la deploy.sh
# -rw-r--r-- 1 deploy deploy 1234 Mar 26 14:00 deploy.sh
# ↑ x 권한 없음
mount | grep noexec # noexec 마운트 확인
해결:
케이스 1: 실행 권한 없음
ls -la deploy.sh
# -rw-r--r-- 1 deploy deploy 1234 Mar 26 14:00 deploy.sh
# ↑ x 권한 없음
# 해결
chmod +x deploy.sh
# 또는 소유자에게만
chmod u+x deploy.sh
케이스 2: 다른 사용자 소유의 파일
ls -la /opt/scripts/deploy.sh
# -rwxr-xr-x 1 root root 1234 Mar 26 14:00 /opt/scripts/deploy.sh
# 현재 사용자가 deploy인 경우, 소유자가 root라도 실행 가능
# (others의 x 비트가 설정되어 있으면)
# 소유자 변경이 필요한 경우
sudo chown deploy:deploy /opt/scripts/deploy.sh
케이스 3: noexec 마운트 옵션
스크립트를 /tmp나 NFS 마운트된 디렉토리에서 실행하려 할 때 발생합니다.
# 마운트 옵션 확인
mount | grep noexec
# /tmp on tmpfs type tmpfs (rw,nosuid,nodev,noexec,...)
# 해결: 실행 가능한 위치로 복사
cp deploy.sh /opt/scripts/
chmod +x /opt/scripts/deploy.sh
/opt/scripts/deploy.sh
케이스 4: sudo 환경의 PATH 문제
# 스크립트 내에서 sudo로 실행되는 명령이 없다고 나올 때
sudo ./deploy.sh
# deploy.sh: line 10: some-tool: command not found
# 원인: sudo는 기본적으로 PATH를 제한함
# 해결 1: 전체 경로 사용
/usr/local/bin/some-tool
# 해결 2: sudo -E (환경 변수 유지)
sudo -E ./deploy.sh
# 해결 3: sudo bash -c (현재 환경 전달)
sudo bash -c "source /etc/profile && ./deploy.sh"
상황: set -e를 설정했는데 예상치 못한 위치에서 스크립트가 갑자기 종료됩니다. 반대로 파이프라인 중간 실패가 무시되고 스크립트가 계속 실행되기도 합니다.
원인: grep은 매칭 없으면 종료 코드 1을 반환합니다. set -e 환경에서 이것이 스크립트를 종료시킵니다. 반대로 set -o pipefail 없이는 파이프 중간 실패가 무시됩니다.
진단:
# grep이 매칭 없으면 exit 1
set -e
ERROR_COUNT=$(grep "ERROR" /var/log/app.log | wc -l)
# → 로그에 ERROR 없으면 스크립트 즉시 종료!
해결:
케이스 1: grep의 종료 코드
grep은 매칭되는 줄이 없으면 종료 코드 1을 반환합니다. set -e 환경에서 이것이 스크립트를 종료시킵니다.
# 문제 있는 코드
set -e
ERROR_COUNT=$(grep "ERROR" /var/log/app.log | wc -l)
# grep이 매칭 없으면 exit 1 → 스크립트 종료!
# 해결 1: || true로 실패를 무시
ERROR_COUNT=$(grep "ERROR" /var/log/app.log | wc -l || true)
# 해결 2: grep -c 대신 조건부 처리
if grep -q "ERROR" /var/log/app.log; then
ERROR_COUNT=$(grep -c "ERROR" /var/log/app.log)
else
ERROR_COUNT=0
fi
케이스 2: pipefail 없는 파이프라인
#!/bin/bash
set -e
# set -o pipefail 없음!
# false는 실패를 반환하지만, 파이프 다음의 cat이 성공하므로
# 전체 파이프라인 종료 코드는 0 (성공)
false | cat # 스크립트 계속 실행됨!
# 해결: pipefail 추가
set -euo pipefail
false | cat # 이제 스크립트 종료
케이스 3: 함수 내부의 set -e 동작
#!/bin/bash
set -e
is_file_exists() {
local FILE="$1"
# test 실패 = 종료 코드 1
# 함수 내부에서 set -e는 함수를 호출한 맥락에 따라 다르게 동작함
[[ -f "$FILE" ]]
}
# if 조건에서 호출 시: 실패해도 스크립트 종료 안 됨 (의도적)
if is_file_exists "/tmp/test.txt"; then
echo "파일 존재"
fi
# 단독 호출 시: 실패하면 set -e 에 의해 스크립트 종료
is_file_exists "/tmp/test.txt" # 파일 없으면 종료!
# 해결: 명시적으로 처리
is_file_exists "/tmp/test.txt" || echo "파일 없음"
cron은 지정한 시간에 명령을 자동으로 실행하는 Linux의 내장 작업 스케줄러입니다. 디스크 점검, 로그 정리, 백업, 리포트 생성 등 반복 작업을 수동으로 실행할 필요가 없어집니다.
crontab 기본 사용법
# 현재 사용자의 crontab 편집
crontab -e
# 현재 crontab 조회
crontab -l
# 특정 사용자의 crontab 조회 (root만 가능)
sudo crontab -l -u deploy
# crontab 삭제 (주의: 전체 삭제)
crontab -r
crontab 형식
# ┌── 분 (0-59)
# │ ┌── 시 (0-23)
# │ │ ┌── 일 (1-31)
# │ │ │ ┌── 월 (1-12)
# │ │ │ │ ┌── 요일 (0-7, 0과 7 모두 일요일)
# │ │ │ │ │
# * * * * * 실행할 명령
자주 사용하는 cron 표현식
| 표현식 | 의미 |
|---|---|
* * * * * | 매 분마다 |
*/15 * * * * | 15분마다 |
0 * * * * | 매 시 정각 |
0 3 * * * | 매일 새벽 3시 |
0 3 * * 0 | 매주 일요일 새벽 3시 |
0 3 1 * * | 매월 1일 새벽 3시 |
0 3 1 1 * | 매년 1월 1일 새벽 3시 |
@reboot | 시스템 재부팅 시 1회 |
@daily | 매일 자정 (= 0 0 * * *) |
@weekly | 매주 일요일 자정 |
@monthly | 매월 1일 자정 |
실무 crontab 예시
# /etc/crontab 또는 crontab -e 로 편집
# 환경 변수 설정 (cron은 최소한의 환경만 제공)
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@company.com
HOME=/root
# 디스크 사용량 점검: 15분마다
*/15 * * * * root /opt/scripts/disk-alert.sh >> /var/log/disk-alert-cron.log 2>&1
# 로그 정리: 매일 새벽 3시
0 3 * * * root /opt/scripts/log-cleanup.sh >> /var/log/log-cleanup-cron.log 2>&1
# 데이터베이스 백업: 매일 새벽 2시
0 2 * * * deploy /opt/scripts/db-backup.sh
# 주간 보고서: 매주 월요일 오전 9시
0 9 * * 1 deploy /opt/scripts/weekly-report.sh
# 서버 재부팅 후 초기화 스크립트
@reboot root /opt/scripts/on-boot-setup.sh >> /var/log/boot-setup.log 2>&1
cron 실행 환경의 함정과 해결
cron은 로그인 쉘이 아닌 최소 환경에서 실행됩니다. 터미널에서는 잘 되는데 cron에서 실패하는 이유의 80%는 환경 변수 문제입니다.
#!/bin/bash
# cron 친화적 스크립트 작성 체크리스트
# 1. 명령 전체 경로 사용 (PATH가 제한될 수 있음)
/usr/bin/find /var/log -name "*.log" -mtime +30
# 2. 스크립트 내에서 PATH 직접 설정
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# 3. 환경 변수가 필요한 경우 명시적으로 로드
source /etc/profile
source /home/deploy/.bashrc
# 4. cron이 메일로 출력을 보내지 않게 하려면 리다이렉션
0 3 * * * /opt/scripts/cleanup.sh >> /var/log/cleanup.log 2>&1
# 5. 잠금 파일로 중복 실행 방지
LOCKFILE="/var/run/myscript.lock"
if [[ -f "$LOCKFILE" ]]; then
echo "이미 실행 중 — 종료합니다." >&2
exit 1
fi
touch "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT # 스크립트 종료 시 잠금 파일 자동 삭제
systemd timer: cron의 현대적 대안
RHEL 8+, Ubuntu 20.04+ 환경에서는 systemd timer가 cron보다 강력한 기능을 제공합니다.
# /etc/systemd/system/disk-alert.service
[Unit]
Description=디스크 사용량 모니터링
[Service]
Type=oneshot
ExecStart=/opt/scripts/disk-alert.sh
StandardOutput=journal
StandardError=journal
# /etc/systemd/system/disk-alert.timer
[Unit]
Description=디스크 사용량 15분마다 점검
[Timer]
OnCalendar=*:0/15 # 15분마다
Persistent=true # 놓친 실행 복구
[Install]
WantedBy=timers.target
# timer 활성화
sudo systemctl enable --now disk-alert.timer
# 상태 확인
sudo systemctl status disk-alert.timer
sudo systemctl list-timers
# 로그 확인
sudo journalctl -u disk-alert.service --since "1 hour ago"
버전 관리와 코드 리뷰
서버 관리 스크립트는 코드입니다. 인프라 코드도 Git으로 관리해야 합니다.
# 스크립트 저장소 구조 예시
/opt/scripts/
├── README.md
├── .gitignore
├── lib/ # 공통 함수 라이브러리
│ ├── logging.sh
│ ├── alerts.sh
│ └── utils.sh
├── monitoring/
│ ├── disk-alert.sh
│ └── service-check.sh
├── maintenance/
│ ├── log-cleanup.sh
│ └── db-backup.sh
└── deployment/
└── deploy.sh
공통 라이브러리 활용
#!/bin/bash
# lib/logging.sh — 모든 스크립트에서 공유
LOG_FILE="${LOG_FILE:-/var/log/scripts.log}"
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" >&2; }
log_error() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] $*" | tee -a "$LOG_FILE" >&2; }
#!/bin/bash
# monitoring/disk-alert.sh — 라이브러리 로드
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/logging.sh"
source "${SCRIPT_DIR}/../lib/alerts.sh"
# 이후 log_info, send_alert 등을 바로 사용 가능
log_info "디스크 점검 시작"
스크립트 품질 도구
# shellcheck: Bash 스크립트 정적 분석 도구
sudo apt install shellcheck # Ubuntu
sudo yum install shellcheck # RHEL
# 스크립트 검사
shellcheck disk-alert.sh
# 전체 디렉토리 검사
find /opt/scripts -name "*.sh" -exec shellcheck {} \;
shellcheck가 잡아주는 대표적인 실수들
# 잘못된 예 (shellcheck 경고)
for f in $(ls *.txt); do # ls 대신 글로브 사용 권장
...
if [ $COUNT == 0 ]; then # 숫자 비교는 -eq 사용
VAR="hello world"
echo $VAR # 인용 필요: echo "$VAR"
비밀값(시크릿) 관리
스크립트에 패스워드, API 키, 웹훅 URL을 하드코딩하지 마세요.
#!/bin/bash
# 잘못된 방법 (절대 사용 금지)
DB_PASSWORD="mypassword123" # Git 히스토리에 영구 노출
SLACK_WEBHOOK="https://hooks.slack.com/..."
# 올바른 방법 1: 환경 변수
DB_PASSWORD="${DB_PASSWORD:?'DB_PASSWORD 환경 변수가 설정되지 않았습니다'}"
# 올바른 방법 2: 별도 시크릿 파일 (권한 600, .gitignore에 추가)
if [[ -f "/etc/myapp/secrets.env" ]]; then
source "/etc/myapp/secrets.env"
fi
# 올바른 방법 3: AWS Secrets Manager, Vault 등 시크릿 관리 도구
DB_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id "myapp/db-password" \
--query SecretString \
--output text)
다음 모듈에서는 디스크와 스토리지 관리 — df, du, lsblk, mount로 파일시스템을 관리하는 방법을 다룹니다.