본문으로 건너뛰기
개발 머꼬
개발 노트인프라·운영
hohyeon.dev68

실패한 서버를 세는 셸 스크립트가 항상 0을 출력한 이유

  • #Bash
  • #Common Pitfall
  • #Engineering Note

문제 발생

배포 전에 서버 목록을 돌면서 헬스 체크를 하고, 한 대라도 죽어 있으면 배포를 멈추는 셸 스크립트였습니다.

failed=0
cat servers.txt | while read host; do
  if ! curl -fsS --max-time 3 "http://$host/health" > /dev/null; then
    echo "DOWN: $host"
    failed=$((failed + 1))
  fi
done

echo "실패: ${failed}대"
[ "$failed" -eq 0 ] || exit 1

어느 날 배포 로그를 보다가 눈을 의심했습니다.

DOWN: web2
실패: 0대

바로 윗줄에 web2가 죽었다고 찍어 놓고는 실패가 0대라고 합니다. 스크립트는 종료 코드 0으로 끝났고, 배포는 그대로 진행됐습니다. 원인을 찾다가 하나를 더 발견했습니다. 목록 맨 끝의 web3도 죽어 있었는데, 로그에 DOWN: web3은커녕 검사한 흔적조차 없었습니다. 며칠 전 누가 servers.txt를 웹 편집기로 고치면서 마지막 줄바꿈이 빠졌던 겁니다. 버그 두 개가 겹쳐서 죽은 서버 두 대가 둘 다 조용히 넘어갔습니다.

원인 분석

파이프 뒤의 while은 서브셸에서 돕니다. Bash 매뉴얼의 명령 실행 환경 설명에 따르면 파이프라인의 일부로 실행되는 명령은 서브셸 환경에서 실행되고, 서브셸 환경에서 바꾼 내용은 원래 셸의 실행 환경에 영향을 줄 수 없습니다. cat servers.txt | while ...에서 while 루프 전체가 별도의 서브셸입니다. 루프 안에서 failed를 늘린 건 서브셸의 복사본이었고, 루프가 끝나면서 그 복사본도 같이 사라졌습니다. 바깥의 failed는 처음부터 끝까지 0이었습니다. 단순화해서 돌려 보면 바로 보입니다.

count=0
cat servers.txt | while read host; do count=$((count + 1)); done
echo "$count"   # 0

echo "DOWN: $host"는 화면에 나오니까 루프가 돈 건 분명한데 숫자만 0인, 이 묘한 상황이 그래서 생깁니다.

마지막 줄에 줄바꿈이 없으면 read는 실패를 돌려줍니다. Bash 매뉴얼의 read 설명에 따르면 종료 상태는 0이지만 파일 끝(EOF)을 만나면 예외입니다. web3처럼 줄바꿈 없이 끝나는 마지막 줄을 읽으면, read는 변수에 web3을 넣어 두고도 EOF를 만났으니 실패를 돌려줍니다. while은 조건이 실패했으니 본문을 실행하지 않고 끝납니다. 직접 확인해 보니 printf 'web1\nweb2'를 읽을 때 두 번째 read가 종료 코드 1을 돌려주면서도 변수에는 web2가 들어 있었습니다. POSIX도 줄(line)을 "줄바꿈 문자로 끝나는 문자들의 연속"으로 정의하고, 줄바꿈 없이 파일 끝에 남은 부분은 따로 "불완전한 줄(incomplete line)"이라고 부릅니다. 셸 입장에서 web3은 줄이 아니었던 겁니다.

해결 방안

  1. 파이프 대신 리다이렉션으로 입력을 넣습니다. cat file | 대신 done < file로 쓰면 루프가 현재 셸에서 돌기 때문에 변수 변경이 그대로 남습니다. 파일이 아니라 명령의 출력을 돌아야 한다면 프로세스 치환을 씁니다. Bash 매뉴얼대로 <(list) 형태는 읽으면 list의 출력을 얻는 파일처럼 동작합니다.
while read host; do ...; done < servers.txt
while read host; do ...; done < <(kubectl get nodes -o name)

< <(...)의 두 < 사이 공백은 빼먹으면 안 됩니다. 앞의 <는 리다이렉션이고 뒤의 <(가 프로세스 치환입니다.

  1. read는 while IFS= read -r line || [ -n "$line" ] 형태로 씁니다. 길지만 각각 이유가 있습니다. 끝의 || [ -n "$line" ]은 read가 EOF로 실패했더라도 변수에 뭔가 담겼으면 한 번 더 본문을 돌게 해서, 줄바꿈 없는 마지막 줄을 살립니다. -r은 매뉴얼대로 백슬래시를 이스케이프 문자로 취급하지 않게 합니다. IFS=는 앞뒤 공백을 지우지 않게 합니다. 경로 한 줄로 차이를 확인해 보면 이렇습니다.
입력:            "  C:\new\temp  "
read p:          [C:newtemp]
read -r p:       [C:\new\temp]
IFS= read -r p:  [  C:\new\temp  ]

서버 이름에는 백슬래시가 없으니 괜찮다고 넘기기 쉬운데, 이 형태를 습관으로 박아 두면 파일 경로나 사용자 입력을 읽을 때 같은 실수를 하지 않습니다.

  1. Bash 스크립트라면 lastpipe도 방법입니다. 매뉴얼에 따르면 lastpipe를 켜면 작업 제어(job control)가 꺼져 있을 때 파이프라인의 마지막 명령을 현재 셸 환경에서 실행합니다. 스크립트는 기본적으로 작업 제어가 꺼져 있어서, 맨 위에 shopt -s lastpipe만 넣으면 원래 코드의 failed가 제대로 남습니다. 2번의 read 형태와 함께 써서 직접 돌려 보니, 0이던 카운터가 세 줄을 모두 센 3으로 나왔습니다. 다만 대화형 셸에서는 작업 제어가 켜져 있어서 터미널에 붙여 넣고 시험하면 동작이 다르고, Bash 전용 옵션이라 셔뱅이 /bin/sh인 스크립트에는 쓸 수 없습니다. 그래서 저는 1번을 기본으로 씁니다.

  2. 루프 안에서 표준 입력을 읽는 명령을 조심합니다. 1번으로 고친 뒤 헬스 체크를 ssh "$host" systemctl is-active app로 바꾸면 이번엔 첫 서버만 검사하고 끝납니다. 루프 안의 명령이 루프와 같은 표준 입력, 즉 servers.txt의 나머지 줄을 먹어 버리기 때문입니다. 표준 입력을 끝까지 읽는 cat > /dev/null을 루프 안에 넣어 재현해 보면 web1만 처리되고 끝났습니다. ssh라면 매뉴얼의 -n 옵션이 표준 입력을 /dev/null로 돌려 이걸 막아 줍니다. 명령이 무엇이든 확실히 막으려면 목록을 다른 파일 디스크립터로 읽습니다. read -u fd는 매뉴얼대로 지정한 파일 디스크립터에서 읽습니다.

while IFS= read -r -u 3 host || [ -n "$host" ]; do
  ssh "$host" systemctl is-active app
done 3< servers.txt

이렇게 바꾸니 루프 안에서 표준 입력을 다 읽어 버리는 명령이 있어도 세 줄이 모두 처리됐습니다.

  1. 파이프 안의 exit도 서브셸만 끝낸다는 걸 기억합니다. 카운터 대신 "죽은 서버를 만나면 루프 안에서 바로 exit 1" 하는 식으로 고치고 싶어질 수 있는데, 파이프 뒤의 루프라면 이것도 서브셸만 끝냅니다. 직접 해 보니 abort at web2가 찍힌 뒤에도 다음 줄이 실행됐고, 스크립트는 종료 코드 0으로 끝났습니다. 원래 사고와 같은 원인이니, 루프 안에서 바깥 상태를 바꾸거나 스크립트를 끝내야 한다면 무조건 1번 형태로 씁니다.

고친 스크립트를 같은 상황에 돌리면 이제 이렇게 나옵니다.

DOWN: web2
DOWN: web3
실패: 2대

종료 코드는 1이고, 배포는 멈춥니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.