셸 스크립트의 함정에서 빠져나오는 법

computerphilosopher·2023년 6월 4일
6
post-thumbnail

들어가며

본질적으로 셸 스크립트(Shell script)란 리눅스 명령어를 순서대로 나열한 것이다. 따라서 배포에 필요한 명령어와 적용 순서를 모두 알고 있다면 누구나 셸 스크립트를 사용해 서비스를 배포할 수 있다. 과연 그럴까? 셸 스크립트에는 생각보다 함정이 많이 숨어있다.

설명을 위해 example-app 이라는 가상의 응용프로그램을 예시로 사용하겠다. 응용은 /usr/example-app에 설치 되어 있으며 설정 파일을 저장하는 conf 디렉토리, 실행 파일을 저장하는 bin 디렉토리, 실행중인 프로세스의 id를 저장하는 pid 파일이 있다. pid 파일의 역할은 응용이 이미 실행중임을 나타내어 중복 실행을 방지하는 것인데, bin/example-app이 실행될 때 스스로 생성한다. 응용 conf 디렉토리의 하위 디렉토리로는 반드시 작성하여야 하는 설정 파일을 저장하는 required와 optional이 있는데, 현재 optional은 사용하지 않는다고 가정하자. 설명한 구조를 트리 형태로 나타내면 다음과 같다.

/usr/
└── example-app/
    ├── conf/
    │   ├── required/
    │   │   └── app.conf
    │   └── optional
    ├── bin/
    │   └── example-app
    ├── pid
    └── run.sh

다음은 run.sh의 내용이다. 환경변수 'DEBUG'의 값이 'TRUE'로 설정되어 있으면 디버그 모드로 동작한다.

run.sh

mkdir conf/required
cd conf/required
echo listen="$LISTEN_ADDR" > app.conf

if [[ $DEBUG == "TRUE" ]]; then
   echo "debug=true" >> app.conf
fi

cd ../..

if [[ -e pid ]]; then
    echo 'kill existing process'
    kill $(cat pid)
fi

./bin/example-app --config=conf/required/app.conf

얼핏 보기엔 별 문제가 없어 보인다. 하지만 실제론 처음부터 끝까지 문제 투성이이다. 이 코드를 수정하면서 올바른 셸 스크립트 작성법을 알아보자.

첫 실행과 반복 실행에 주의하라.

위 스크립트에서의 mkdir 명령어는 다음의 두 가지 조건을 전제하고 있다.

  • conf 디렉토리는 존재한다.
  • conf/required는 아직 없다.

해당 장비에 서비스를 처음 배포하는 상황이라면 관련 디렉토리가 아직 존재하지 않을 것이므로 No such file or directory 라는 에러가 발생할 것이다. 우선 상위 디렉토리를 먼저 만들어야 실패하지 않는다.

예시 1-1

mkdir conf
mkdir conf/required
cd conf/required
# ...

아직도 심각한 문제점이 남아 있다. 예시 1-1은 처음 실행 했을 때만 정상적으로 동작한다. 같은 스크립트를 다시 실행 했을 땐 이미 관련 디렉토리가 모두 만들어져 있으므로 File exists 에러가 발생할 것이다. 다행히 mkdir에는 첫 실행과 반복 실행을 모두 안전하게 만들어주는 옵션이 존재한다.

예시 1-2

# -p 옵션의 효과
# 1. 디렉토리가 이미 존재하더라도 에러를 발생하지 않음
# 2. 부모 디렉토리가 존재하지 않을 경우 생성함
mkdir -p conf/required

이렇게 같은 연산을 반복적으로 적용하여도 결과가 동일한 특성을 '멱등성(Idempotence)'이라 한다. 배포 자동화를 위한 스크립트는 최대한 멱등성을 보장하는 것이 좋다. 그렇지 않으면 배포 결과를 예측하기가 너무 어렵게 된다.

안타깝게도 모든 명령어가 이런 멋진 옵션을 제공하지는 않는다. 예를 들어 useradd 명령의 경우 이미 존재하는 유저를 다시 추가하려고 하면 반드시 실패한다. 따라서 다음과 같이 추가하려는 사용자가 이미 존재하는지 먼저 점검하고, 존재하지 않을 때에만 명령어를 실행하도록 작성하여야 한다.

예시 1-3

if ! id -u skynet > /dev/null 2>&1; then
    useradd skynet
fi

너무 복잡하다는 생각이 들지도 모른다. 사실 셸 스크립트를 안전하면서도 간결하게 작성하는 것은 쉽지 않다. 멱등성을 보장하는 IaC가 널리 유행하는 이유이기도 하다. 스크립트가 지나치게 복잡해지고 있다면 IaC 채택을 진지하게 검토해야 한다.

실패에 대비하라

명령어 실패를 고려하여야 하는 이유

기본적으로 셸 스크립트는 명령어가 실패하더라도 끝까지 실행된다. 처음 우리가 작성한 스크립트의 의도는 /usr/example-app/conf/required라는 디렉토리에 설정 파일을 작성하는 것이었다. 부모 디렉토리가 존재하지 않아 디렉토리 생성에 실패하는 상황에서 스크립트가 끝까지 실행되면 어떤 일이 발생하는지 살펴보자.

예시 2-1

# 상위 디렉토리가 존재하지 않으므로 생성에 실패한다.
mkdir conf/required
# 존재하지 않는 디렉토리로 이동하려 했으므로 명령어 수행에 실패하고 현재 디렉토리에 머무른다.
cd conf/required
# 의도하지 않은 경로인 /usr/example-app 에 설정 파일이 생긴다.
echo "listen=$LISTEN_ADDR" > app.conf

if [[ $DEBUG == "TRUE" ]]; then
   echo "debug=true" >> app.conf
fi

# 루트 디렉토리(/)로 이동한다.
cd ../..

# 기존 프로세스를 종료한다. 
if [[ -e pid ]]; then
    echo 'kill existing process'
    kill $(cat pid)
fi

# 존재하지도 않는 파일을 실행하려 한다.
./bin/example-app --config=conf/required/app.conf

기존 프로세스를 종료하였으나 재실행이 되지 않았으므로 서비스 장애가 발생한다. 디렉토리 생성 실패라는 사소한 사건이 서비스 장애라는 재앙으로 이어진 것이다. mkdir이 실패하였을 때 즉시 실행을 중지하였다면 뒤의 내용은 실행되지 않았을 것이다.

실패에 대비하는 방법

스크립트가 시작되는 부분에서 set 명령어로 플래그를 주면 명령어가 실패했을 때 즉시 스크립트 실행을 중지할 수 있다. 사용법은 다음과 같다.

예시 2-2

# -e: 명령어가 실패하면 즉시 실행 중단
# -u: 설정하지 않은 변수를 참조하려 하면 즉시 실행 중단
# -x: 어떤 명령어를 실행하는지 로깅
set -eux

mkdir /usr/example-app/conf
#....

-e 플래그는 명령어가 실패하였을 때(0이 아닌 값을 리턴하였을 때) 실행을 즉시 종료하도록 설정한다. 예시 2-2 에서는 /usr/example-app 디렉토리가 존재하지 않으면 즉시 실행을 종료하기 때문에 존재하지 않는 파일을 읽을 가능성이 없다. -u 플래그는 설정하지 않은 변수값을 참조하려고 할 때 실행을 중단하는 플래그다. 운영자가 환경변수 DEBUG 값을 설정하는 것을 잊으면 즉시 실행이 종료된다. -x는 디버깅이 쉽도록 명령어 실행 로그를 남기는 역할을 하는데 주제와는 관련이 없지만 꽤 유용하다.

상대 경로 사용에 주의하라

지금까지 보였던 예시에는 사용자가 /usr/example-app로 이동한 상태에서 스크립트를 실행한다는 가정이 숨어 있다. 만약 부모 디렉토리 /usr에서 스크립트를 실행한다면 어떻게 될까?

예시 3-1

set -eux

# 의도: /usr/example-app/conf/required 디렉토리 생성
# 결과 /usr/conf/required 디렉토리 생성
mkdir -p conf/required

# 의도하지 않은 경로인 /usr/conf/required 에 설정 파일이 생긴다.
echo "listen=$LISTEN_ADDR" > app.conf

# 의도: /usr/conf/example-app으로 이동
# 결과: 루트 디렉토리(/)로 이동
cd ../..

# 의도: 기존 프로세스를 종료한다. 
# 결과: 아무 일도 안 일어남
if [[ -e pid ]]; then
    echo 'kill existing process'
    kill $(cat pid)
fi

# 의도: 프로그램 실행
# 결과: 존재하지 않는 경로이므로 실행 실패
./bin/example-app --config=conf/required/app.conf

처음부터 끝까지 엉망진창이 된다. 절대 경로를 특정하지 않은 상태에서 상대경로를 무분별하게 사용했기 때문이다. 이렇게 스크립트의 실행 위치에 따라 접근 경로가 달라져서는 곤란하다. 응용이 설치된 경로를 정확하게 특정할 수 있을 경우 다음과 같이 해당 디렉토리로 이동한 후 작업을 시작하면 된다.

mkdir -p /usr/example-app/conf/required
cd /usr/example-app/conf/required

echo "listen=$LISTEN_ADDR" > app.conf
# ...

스크립트의 위치에 따라 접근 경로가 동적으로 변해야 하는 상황이라면, realpath를 이용해 스크립트의 절대 경로를 읽으면 된다.

SCRIPT_DIR=$(dirname $(realpath $0))
cd $SCRIPT_DIR
mkdir -p conf/required

realpath는 비교적 최근에 생긴 명령어이기 때문에 reanlink -f 명령어를 사용하라고 조언하는 자료도 많다. 그러나 이 비교적 최근도 10년이 넘었기 때문에 지금은 명령어의 이름과 동작이 잘 일치하는 realpath를 사용하는 것이 낫다고 본다. readlink의 매뉴얼 페이지에서도 이를 언급하고 있다.

Bashism 사용에 유의하라

bash의 역사가 워낙 오래되다보니 bash script를 곧 shell script로 착각하는 경우가 많다. bash라는 이름은 'Bourne Again Shell'의 약자로, 기존에 널리 쓰이던 본 셸(bourn shell)을 개량하였다는 의미다. 본 셸은 과거 AT&T 유닉스 시절부터 쓰이던 셸로, 비슷한 시기의 발명품으로는 돌도끼와 빗살무늬 토기가 있다. bash는 본 셸에는 없는 편리한 기능을 많이 포함시켰는데, 애석하게도 POSIX 표준은 아니기 때문에 다른 셸에서는 지원하지 않는 경우가 많다. 이렇게 bash외의 셸에선 정상 동작이 보장되지 않는 구문을 'bashism'이라 한다. 예시 스크립트에서도 bashism이 숨어 있다.

if [[ -e pid ]]; then
    echo 'kill existing process'
    kill $(cat pid)
fi

대괄호를 두 개 사용하는 비교 구문은 대표적인 bashism이다. 사용하기 더 편리한 면이 있어 선호하는 사람들이 많지만 어쨌거나 POSIX 표준이 아니기 때문에 bash가 아니면 정상 실행이 보장되지 않는다.

"bash가 없는 리눅스를 찾아보기가 더 힘든 지경인데 굳이 편리한 bashism을 배격할 필요는 없다. 정 찜찜하면 셔뱅(Shebang) 한 줄 넣어주면 그만이다." 라는 생각이 들지도 모른다. 그러나 컨테이너 세계의 주류인 알파인(Alpine) 리눅스는 bash를 포함하고 있지 않다. 알파인의 기본 셸은 그 시절 본 셸을 집어넣은게 아닌가 싶을 정도로 최소한의 기능만 있는 'busybox ash'가 기본 셸이다. Dockerfile에 생각없이 bashism을 포함시켰다가 빌드가 되지 않는 상황은 꽤 흔하게 발생한다. 물론 알파인 리눅스도 bash의 설치 자체를 금지하는 것은 아니기 때문에, 스크립트에서 bashism을 모두 빼버리느니 apk add 명령어로 설치해버리는게 쉬울수도 있다. 실무에서의 판단은 스스로에게 맡기도록 하겠다.

POSIX 표준을 최대한 고수하기로 마음 먹었다면 셔뱅을 #!/bin/sh로 바꾸어두고 스크립트를 테스트 하면 된다. bashism을 사용하면 테스트 과정에서 문법 에러나 의도하지 않은 동작이 나올 것이므로 제외하기 쉬울 것이다. checkbashisms과 같은 툴을 사용해도 된다.

마치며

'셸 스크립트' 라는 말을 들었을 때 사람들은 흔히 awk나 sed 같은 도구를 활용해 문자열 흑마법을 쓰는 모습을 떠올린다. 물론 이러한 역량도 매우 중요하긴 하지만, 정 어려우면 파이썬으로 해버리면 그만이기 때문에 대체재가 있다. 그러나 '리눅스 명령어를 미리 정해둔 순서대로 실행한다' 라는 본질은 어떤 도구로도 대체되기가 어렵다. Ansible 같은 IaC 도구들도 셸의 모든 기능에 대해 빌트인 플러그인을 제공하는 것이 아니기 때문에 결국 셸을 사용해야 하는 경우가 생긴다. 멱등성이나 명령어 실패 대비에 대해 고민하는 것은 운영 엔지니어에게 매우 중요한 경험이다.

참고 자료

  • Linux man page - set, realpath, readlink

0개의 댓글