리눅스나 컨테이너 안에서 명령어를 치다 보면 bash와 sh를 자주 만나게 된다.
예를 들어 Kubernetes에서 curl 테스트용 Pod를 띄울 때 이렇게 실행했다.
kubectl run curl-test --image=curlimages/curl -it --rm -- sh
여기서 마지막에 붙은 sh는 컨테이너 안에서 sh 셸을 실행하겠다는 뜻이다.
그런데 문제는 우리가 평소에 쓰던 문법이 여기서 그대로 안 먹을 때가 있다는 것이다.
예를 들어 이런 반복문을 썼다.
for i in {1..1000}
do
curl http://10.43.188.207
echo " (${i})"
sleep 1
done
bash에서는 자연스럽게 보이는 코드다. 하지만 sh에서는 {1..1000}이 숫자로 펼쳐지지 않고 그대로 문자열처럼 처리될 수 있다.
그래서 결과가 이렇게 나올 수 있다.
({1..1000})
이걸 이해하려면 bash와 sh의 근본적인 차이를 알아야 한다.
sh는 Bourne Shell에서 출발한 오래된 기본 셸이다.
정확히 말하면 요즘 리눅스에서 /bin/sh는 진짜 옛날 Bourne Shell 그 자체라기보다는, POSIX 표준에 맞춘 셸을 가리키는 경우가 많다.
즉 sh는 이렇게 이해하면 된다.
sh = 거의 모든 유닉스/리눅스 환경에서 동작하도록 만든 기본 셸
그래서 sh는 기능이 많다기보다는 호환성과 이식성이 중요하다.
여러 리눅스 배포판, 서버, 컨테이너 환경에서 최대한 공통으로 동작해야 하기 때문에 문법이 비교적 단순하다.
예를 들어 이런 환경에서는 sh만 있는 경우도 많다.
alpine
busybox
curlimages/curl
distroless에 가까운 경량 이미지
작게 만든 운영용 컨테이너 이미지
컨테이너 이미지는 보통 가볍게 만드는 게 중요하기 때문에 bash, vim, curl, ping 같은 도구가 빠져 있는 경우가 많다.
그래서 Kubernetes 실습 중 컨테이너 안에 들어갔을 때 이런 에러가 자주 나온다.
bash: command not found
curl: command not found
vi: command not found
이건 이상한 상황이 아니다. 그냥 그 이미지 안에 해당 프로그램이 없는 것이다.
bash는 Bourne Again Shell의 줄임말이다.
이름 그대로 기존 Bourne Shell을 다시 확장해서 만든 셸이다.
즉 bash는 sh와 비슷한 문법을 기본으로 하지만, 그 위에 여러 편의 기능이 추가된 셸이다.
bash = sh 계열 문법 + 여러 확장 기능
bash에서는 다음과 같은 기능을 쓸 수 있다.
{1..1000} 같은 brace expansion
배열
[[ 조건문 ]]
향상된 문자열 처리
명령어 자동완성
히스토리 기능
여러 편의 문법
그래서 우리가 리눅스 서버에서 터미널을 열었을 때 익숙하게 쓰는 셸은 대부분 bash인 경우가 많다.
예를 들어 bash에서는 아래 코드가 정상적으로 동작한다.
for i in {1..5}
do
echo $i
done
결과는 다음과 같다.
1
2
3
4
5
여기서 {1..5}가 1 2 3 4 5로 펼쳐지는 기능을 brace expansion이라고 한다.
하지만 이 기능은 bash 확장 기능에 가깝다. 그래서 sh에서는 동작하지 않을 수 있다.
bash와 sh의 근본적인 차이는 이거다.
sh
= 표준과 호환성을 중시하는 기본 셸
bash
= sh 계열을 기반으로 편의 기능을 많이 추가한 확장 셸
그래서 sh 스크립트는 다양한 환경에서 실행될 가능성이 높다.
반면 bash 스크립트는 bash가 설치된 환경에서만 안전하게 실행된다.
예를 들어 이 코드는 bash에서는 가능하다.
for i in {1..1000}
do
echo $i
done
하지만 sh에서는 {1..1000}을 숫자 범위로 해석하지 못할 수 있다.
sh에서 더 안전한 방식은 이런 코드다.
i=1
while [ $i -le 1000 ]
do
echo $i
i=$((i+1))
done
이 방식은 bash에서도 되고 sh에서도 된다.
즉, 컨테이너처럼 어떤 셸이 들어 있는지 불확실한 환경에서는 sh 호환 문법을 쓰는 것이 더 안전하다.
내가 실행한 명령어는 이거였다.
kubectl run curl-test --image=curlimages/curl -it --rm -- sh
마지막에 sh라고 썼기 때문에 컨테이너 안에서는 bash가 아니라 sh가 실행된다.
그 상태에서 아래 코드를 입력했다.
for i in {1..1000}
do
curl http://10.43.188.207
echo " (${i})"
sleep 1
done
이 코드에서 문제는 이 부분이다.
{1..1000}
bash라면 이걸 다음처럼 펼친다.
1 2 3 4 5 ... 1000
하지만 sh에서는 이걸 숫자 범위로 펼치지 않고 문자열 하나로 볼 수 있다.
그러면 반복문은 1000번 도는 게 아니라 딱 한 번만 돈다.
실제로는 이런 느낌이 된다.
for i in "{1..1000}"
그래서 echo " (${i})" 결과가 이렇게 나온다.
({1..1000})
즉, i에 숫자 1, 2, 3이 들어간 게 아니라 {1..1000}이라는 문자열이 그대로 들어간 것이다.
그래서 sh에서는 아래처럼 쓰는 것이 안전하다.
i=1
while [ $i -le 1000 ]
do
curl http://10.43.188.207
echo " ($i)"
i=$((i+1))
sleep 1
done
이 코드는 sh에서도 동작한다.
하나씩 보면 다음과 같다.
i=1
반복 카운터를 1로 시작한다.
while [ $i -le 1000 ]
i가 1000보다 작거나 같은 동안 반복한다.
curl http://10.43.188.207
ClusterIP Service로 요청을 보낸다.
echo " ($i)"
몇 번째 요청인지 출력한다.
i=$((i+1))
i를 1 증가시킨다.
sleep 1
1초 쉬고 다음 요청을 보낸다.
이 방식은 bash의 편의 문법에 의존하지 않는다. 그래서 sh에서도 안전하다.
bash에서 편하게 쓸 수 있는 코드:
for i in {1..1000}
do
curl http://10.43.188.207
echo " (${i})"
sleep 1
done
sh에서도 안전한 코드:
i=1
while [ $i -le 1000 ]
do
curl http://10.43.188.207
echo " ($i)"
i=$((i+1))
sleep 1
done
차이는 이렇다.
| 구분 | bash 방식 | sh 호환 방식 |
|---|---|---|
| 반복 방식 | for i in {1..1000} | while [ $i -le 1000 ] |
| 사용하는 기능 | brace expansion | 기본 조건 반복 |
| bash에서 동작 | 가능 | 가능 |
| sh에서 동작 | 안 될 수 있음 | 가능 |
| 컨테이너 실습 안정성 | 낮음 | 높음 |
핵심은 bash 전용 문법을 쓰면 sh에서는 깨질 수 있다는 것이다.
여기서 더 헷갈리는 점이 하나 있다.
리눅스에서 /bin/sh가 항상 같은 프로그램을 의미하지는 않는다.
어떤 시스템에서는 /bin/sh가 bash를 가리킬 수 있고, 어떤 시스템에서는 dash를 가리킬 수 있고, 어떤 컨테이너에서는 busybox sh일 수 있다.
예를 들면 이런 식이다.
Ubuntu 계열
/bin/sh → dash인 경우가 많음
일부 리눅스 환경
/bin/sh → bash인 경우도 있음
Alpine
/bin/sh → busybox ash
그래서 내 로컬에서는 sh script.sh가 되는 것처럼 보였는데, 컨테이너 안에서는 안 될 수 있다.
이유는 /bin/sh의 실제 구현체가 다를 수 있기 때문이다.
그래서 스크립트를 짤 때는 이 기준을 잡으면 좋다.
정말 어디서나 돌아가야 한다
→ sh 호환 문법 사용
bash 기능을 쓰고 싶다
→ 명확하게 bash로 실행
스크립트 파일 맨 위에 이런 줄을 본 적이 있을 것이다.
#!/bin/sh
또는:
#!/bin/bash
이걸 shebang이라고 한다.
이 줄은 이 스크립트를 어떤 셸로 실행할지 정하는 역할을 한다.
#!/bin/sh
라고 쓰면 sh로 실행한다.
이 경우에는 sh에서 지원하는 문법만 쓰는 것이 안전하다.
반대로:
#!/bin/bash
라고 쓰면 bash로 실행한다.
이 경우에는 {1..1000}, 배열, [[ ]] 같은 bash 기능을 써도 된다. 단, 실행 환경에 bash가 설치되어 있어야 한다.
예를 들어 컨테이너 이미지 안에 bash가 없는데 스크립트 첫 줄이 이렇게 되어 있으면:
#!/bin/bash
실행할 때 에러가 날 수 있다.
/bin/bash: not found
그래서 컨테이너 환경에서는 shebang도 중요하다.
컨테이너 이미지는 작을수록 좋다.
이미지가 작으면:
pull이 빠름
배포가 빠름
공격 표면이 줄어듦
불필요한 패키지가 줄어듦
그래서 운영용 이미지에는 bash를 안 넣는 경우가 많다.
특히 alpine 기반 이미지는 기본적으로 bash가 아니라 sh를 제공하는 경우가 흔하다.
예를 들어 컨테이너 안에서 이런 명령이 실패할 수 있다.
bash
결과:
bash: not found
이럴 때는 보통 이렇게 들어간다.
sh
Kubernetes에서 exec를 할 때도 마찬가지다.
kubectl exec -it pod-name -- sh
bash가 있는 이미지라면 이렇게도 가능하다.
kubectl exec -it pod-name -- bash
하지만 모든 이미지에 bash가 있는 것은 아니다.
그래서 실습이나 디버깅에서는 일단 sh 기준으로 생각하는 게 안전하다.
간단하게 이렇게 구분하면 된다.
서버에서 직접 쓰는 편한 명령
→ bash 문법을 써도 괜찮은 경우가 많음
컨테이너 안에서 실행할 명령
→ sh 문법 기준으로 생각하는 게 안전함
배포 스크립트, CI/CD 스크립트
→ 실행 환경을 명확히 확인해야 함
여러 환경에서 재사용할 스크립트
→ sh 호환 문법이 안전함
bash 기능을 꼭 써야 하는 스크립트
→ #!/bin/bash 명시하고 bash 설치 여부 확인
특히 Kubernetes 실습에서는 이런 상황이 많다.
kubectl exec로 들어갔는데 bash가 없음
curl이 없음
vi가 없음
for i in {1..1000}이 안 됨
이때는 컨테이너가 이상한 게 아니라, 내가 bash 기준으로 생각하고 있었던 것이다.
bash와 sh의 근본적인 차이는 기능의 많고 적음이 아니라, 기준이 다르다는 점이다.
sh
= 표준성, 호환성, 최소 기능 중심
bash
= 사용 편의성, 확장 기능, 대화형 사용성 중심
그래서 bash에서는 되는 문법이 sh에서는 안 될 수 있다.
대표적인 예가 이것이다.
for i in {1..1000}
bash에서는 {1..1000}이 숫자로 펼쳐지지만, sh에서는 그대로 문자열처럼 처리될 수 있다.
그래서 sh 환경에서는 다음처럼 쓰는 것이 안전하다.
i=1
while [ $i -le 1000 ]
do
curl http://10.43.188.207
echo " ($i)"
i=$((i+1))
sleep 1
done
한 문장으로 정리하면 다음과 같다.
bash는 sh보다 편의 기능이 많은 확장 셸이고, sh는 여러 환경에서 동작하기 위한 기본 셸에 가깝다.
Kubernetes나 Docker 컨테이너 안에서는 bash가 없을 수 있으므로, 명령어가 이상하게 동작하면 먼저 내가 지금 bash를 쓰는지 sh를 쓰는지부터 확인해야 한다.