Bash /dev/tcp로 HTTP 요청 — curl 없는 최소 컨테이너 디버깅

목차

Before와 After — 컨테이너 안에서 막혔던 순간

장애 대응 중 컨테이너 안에 들어가 외부 API를 직접 호출해 봐야 할 때가 있다. 흔한 시나리오는 이렇다.

$ kubectl exec -it api-gateway-7d9f -- sh
$ curl https://payment.internal/health
sh: curl: not found
$ wget -qO- https://payment.internal/health
sh: wget: not found
$ nc -zv payment.internal 443
sh: nc: not found
$ apk add curl
sh: apk: not found

물론, 도구 스택 전체가 비어 있다. distroless 이미지나 alpine 베이스 위에 정적 바이너리만 올린 컨테이너에서 자주 만난다.

해결 흐름은 의외로 짧다. bash가 들어 있다면 아래 한 줄이 동작한다.

$ exec 3<>/dev/tcp/payment.internal/80
$ printf 'GET /health HTTP/1.0\r\nHost: payment.internal\r\n\r\n' >&3
$ cat <&3
HTTP/1.0 200 OK
Content-Type: application/json

{"status":"ok"}

그래서, 외부 바이너리를 한 번도 호출하지 않았다. 전부 bash 내장이다. 글의 본론은 이 트릭이 어디까지 동작하고, distroless처럼 정말 비어 있는 이미지에서는 어떻게 풀어야 하는지다.

왜 컨테이너에 curl이 없는가

이처럼, 요즘 프로덕션 이미지는 작게 만드는 게 흐름이다. 보안 표면을 줄이고, 풀(pull) 속도를 높이고, 공급망 공격을 줄인다는 이유다. 대표적인 후보가 셋이다.

  • alpine 계열: 5MB 안팎. busybox 셸과 apk가 들어 있다. curl/wget은 별도 설치.
  • debian-slim / ubuntu-minimal: 50~80MB. bash와 apt는 있지만 네트워크 도구는 빠져 있다.
  • distroless (Google): 2~20MB. 셸 자체가 없다. 정적 바이너리 + libc 수준만 남는다.

예를 들어, 장애 시 들어가서 트래픽을 흘려 봐야 하는데 이 도구들이 다 빠져 있는 게 첫 번째 문제다. 그렇다고 이미지마다 curl을 강제로 넣자는 결정을 하면 빌드 정책이 무너진다. 결국 "있는 것만으로 돌릴 방법"을 찾게 된다. 그 시작이 bash의 redirection 기능이다.

/dev/tcp의 실체 — 파일이 아니다

/dev/tcp를 처음 보면 디바이스 파일처럼 느껴진다. 실제로는 그렇지 않다. 시스템에 /dev/tcp라는 경로가 존재하지 않는다. ls /dev/tcp를 치면 "No such file or directory"가 떨어진다.

따라서, 이건 bash가 가로채는 가짜 경로다. GNU bash 매뉴얼의 REDIRECTION 절에 명시되어 있다. /dev/tcp/host/port 형식의 경로가 redirection 대상으로 들어오면, bash가 직접 소켓을 열어서 file descriptor에 묶는다. UDP는 /dev/udp/host/port로 쓴다. bash 2.04(1998)부터 들어 있던 기능이다.

# 이건 안 됨
$ cat /dev/tcp/example.com/80
cat: /dev/tcp/example.com/80: No such file or directory

# 이건 됨 (exec redirection 사용)
$ exec 3<>/dev/tcp/example.com/80
$ echo $?
0

미묘한 차이가 있다. cat /dev/tcp/...처럼 외부 명령에 인수로 넘기면 bash가 경로 처리할 기회가 없어 실패한다. redirection 연산자(<, >, <>)와 같이 써야 매직이 발동한다.

file descriptor 번호의 의미

위에서 exec 3<>/dev/tcp/...라고 썼다. 숫자 3은 마음대로 쓴 게 아니다. 0, 1, 2는 stdin/stdout/stderr로 예약되어 있어 피해야 한다. 3 이상이 자유롭게 쓸 수 있는 번호다.

<>는 양방향 열기를 뜻한다. HTTP는 요청을 보내고 응답을 받아야 하므로 양방향이 필요하다. 단방향으로 열면 Bad file descriptor 에러가 떨어진다.

그러나, 연결을 끝낼 땐 exec 3<&- 또는 exec 3>&-로 닫는다. 셸을 빠져나가도 자동으로 정리되긴 하지만, 장수 셸에서 fd를 누적시키면 ulimit에 걸린다. 보통 한 셸당 1024개 정도가 한계다.

HTTP 요청을 손으로 짜기

curl이 가려 줬던 부분을 직접 써야 한다. HTTP/1.0 또는 HTTP/1.1 메시지를 RFC 그대로 만들어 넣는다.

HOST="api.example.com"
PORT=80
TARGET="/v1/users/42"

exec 3<>/dev/tcp/$HOST/$PORT

printf 'GET %s HTTP/1.1\r\n' "$TARGET" >&3
printf 'Host: %s\r\n' "$HOST" >&3
printf 'Connection: close\r\n' >&3
printf 'User-Agent: bash-tcp/1.0\r\n' >&3
printf '\r\n' >&3

cat <&3
exec 3<&-

실제로, 주의할 게 두 가지다.

  • 줄 끝은 반드시 \r\n (CRLF). LF 단독으로 보내면 일부 서버는 받아 주지만 nginx 1.25 이상은 400으로 잘라 버린다.
  • 마지막에 빈 줄 하나가 들어가야 헤더가 끝난다. printf '\r\n'이 그 역할이다.

그러나, HTTP/1.1을 쓰면 Host 헤더가 필수다. 빠뜨리면 400. Connection: close를 안 넣으면 서버가 keepalive로 fd를 잡고 있어서 cat <&3이 영원히 끝나지 않는다. HTTP/1.0을 쓰면 기본이 close라 신경 안 써도 된다.

POST와 JSON 바디

바디가 있을 땐 Content-Length를 계산해 넣어야 한다. 자동으로 안 붙는다.

BODY='{"name":"alice","plan":"pro"}'
LEN=${#BODY}

exec 3<>/dev/tcp/api.example.com/80
printf 'POST /v1/users HTTP/1.1\r\n' >&3
printf 'Host: api.example.com\r\n' >&3
printf 'Content-Type: application/json\r\n' >&3
printf 'Content-Length: %d\r\n' "$LEN" >&3
printf 'Connection: close\r\n' >&3
printf '\r\n' >&3
printf '%s' "$BODY" >&3

cat <&3

${#BODY}는 문자 수에 가까운 값을 돌려준다. LC_ALL이 UTF-8 로케일이면 한글 한 글자가 1로 잡혀 실제 바이트 수와 어긋난다. 한글 JSON을 보낼 일이 있다면 LC_ALL=C printf '%s' "$BODY" | wc -c로 바이트 길이를 다시 잰 뒤 헤더에 박는 게 안전하다. 영문 키만 쓰는 내부 API라면 보통은 무시해도 된다.

응답을 분리해서 읽기

cat <&3은 단순하지만 헤더와 바디가 한 덩어리로 떨어진다. 디버깅 중에는 상태 코드만 잘라 보고 싶을 때가 많다.

exec 3<>/dev/tcp/$HOST/80
printf 'GET / HTTP/1.0\r\nHost: %s\r\n\r\n' "$HOST" >&3

# 첫 줄만 읽기 (상태 라인)
read -r status_line <&3
echo "$status_line"
# HTTP/1.0 200 OK

# 나머지 응답 모두 읽기
cat <&3

read -r line <&3는 한 줄씩 끊어 읽는다. CRLF가 들어와 끝에 \r이 붙어 있을 수 있는데, ${status_line%$'\r'}로 떼면 된다.

상태 코드만 추출하려면 awk를 쓰면 좋지만 미니멀 이미지에 awk가 있다는 보장이 없다. bash 파라미터 확장으로도 된다.

status_code="${status_line#* }"     # HTTP/1.0 200 OK -> 200 OK
status_code="${status_code%% *}"    # 200 OK -> 200
echo "$status_code"

이게 미니멀 컨테이너 디버깅의 정수 같은 느낌이다. 외부 명령 없이 셸 기능만으로 끝낸다.

distroless에서는 사실 이게 안 통한다

이처럼, 여기까지 읽고 진짜 distroless 컨테이너에 들어가 /dev/tcp를 쳤다면 실망할 가능성이 높다. Google distroless 베이스 이미지에는 셸 자체가 없다. distroless/base, distroless/python3, distroless/nodejs 다 마찬가지다.

:debug 태그가 붙은 변형은 busybox 셸을 끼워 준다 (gcr.io/distroless/base:debug). 그런데 busybox sh는 bash가 아니다. /dev/tcp는 bash의 고유 기능이라 busybox에서는 동작하지 않는다.

실제로 들어가 보면 이렇게 떨어진다.

/ # exec 3<>/dev/tcp/example.com/80
sh: can't create /dev/tcp/example.com/80: nonexistent directory

특히, 해결 경로는 두 갈래다.

kubectl debug로 사이드카 띄우기

그러나, 쿠버네티스 1.25부터 정식 지원되는 ephemeral container를 활용한다. 디버깅용 이미지를 임시로 같은 PID 네임스페이스에 붙인다.

kubectl debug -it api-gateway-7d9f \
  --image=nicolaka/netshoot \
  --target=api-gateway \
  --share-processes

nicolaka/netshoot에는 curl, dig, tcpdump, mtr이 다 들어 있다. 대상 컨테이너의 네트워크 네임스페이스를 공유하므로, distroless가 보는 DNS·라우팅을 그대로 본다. 이게 가장 깔끔하다.

다만 클러스터 정책에서 ephemeral container를 막아 둔 경우가 있다. PodSecurityPolicy나 OPA Gatekeeper로 잠가 두면 권한 에러가 떨어진다. 인프라 팀과 사전에 정책을 맞춰 둬야 한다.

정적 바이너리 복사하기

ephemeral container를 못 쓰면 정적 빌드된 curl 바이너리를 컨테이너 안으로 복사한다.

# 호스트에서 정적 curl 다운로드 (static-curl v8.7.1 기준)
wget https://github.com/stunnel/static-curl/releases/download/8.7.1/curl-linux-x86_64-glibc-8.7.1.tar.xz

# 컨테이너에 복사 후 실행
kubectl cp curl /api-gateway-7d9f:/tmp/curl
kubectl exec -it api-gateway-7d9f -- /tmp/curl https://payment.internal/health

distroless에 셸이 없어도 kubectl exec로 단일 바이너리를 실행하는 건 가능하다. 단, 컨테이너 파일시스템이 read-only로 마운트된 경우 /tmp에도 못 쓴다. 이때는 emptyDir 볼륨이 마운트된 경로를 찾거나 사이드카 패턴으로 돌아가야 한다.

요약하면 bash가 들어간 alpine·slim 이미지에서는 /dev/tcp가 강력하지만, 진짜 distroless에서는 결국 ephemeral container가 답이다. 글 제목의 "distroless 디버깅"은 사실 :debug 태그와 alpine 사이 어딘가에 있는 미니멀 이미지를 가리키는 경우가 더 흔하다.

HTTPS는 어떻게 풀까

/dev/tcp는 평문 TCP다. TLS는 bash가 못 한다. 내부망 헬스체크는 보통 평문이라 80 포트로 끝나지만, 외부 API나 internal mesh에 mTLS가 깔린 경우 막힌다.

실제로, 세 가지 우회가 있다.

방법 가능 조건 비고
openssl s_client openssl 바이너리가 컨테이너에 있어야 함 s_client에 HTTP 메시지 흘려 넣는 식
socat + SSL socat과 ssl 라이브러리 둘 다 필요 alpine은 apk add socat openssl
kubectl port-forward 호스트에서 우회 컨테이너 안에서는 불가, 호스트에서만

가장 흔한 게 openssl 방식이다.

printf 'GET /health HTTP/1.1\r\nHost: payment.internal\r\nConnection: close\r\n\r\n' \
  | openssl s_client -connect payment.internal:443 \
                     -servername payment.internal \
                     -quiet 2>/dev/null

-servername을 빠뜨리면 SNI가 비어 와일드카드 인증서 외에는 다 거절당한다. -quiet을 안 넣으면 인증서 정보가 응답 앞에 잔뜩 붙는다.

이마저도 openssl 바이너리가 없으면 끝이다. distroless에는 openssl이 없다. 다시 ephemeral container로 돌아가는 게 가장 빠르다.

자주 만나는 함정 몇 가지

이처럼, 이 트릭을 처음 따라 하면 대부분 같은 곳에서 막힌다.

한편, DNS가 안 풀리는 경우. /dev/tcp/host/port의 host 자리에 도메인을 쓰면 bash가 시스템 리졸버를 통해 해석한다. 컨테이너 안 /etc/resolv.conf가 비어 있거나 nsswitch 설정이 망가져 있으면 여기서 hang이 걸린다. IP를 직접 써서 분리해 보면 원인이 DNS인지 네트워크인지 가른다.

즉, 연결은 되는데 응답이 안 오는 경우. 99%가 CRLF를 빠뜨린 경우다. 작은 따옴표 안에서 \r\n을 그대로 쓰면 6글자 리터럴이 들어간다. printf '\r\n'이나 $'\r\n' 문법을 써야 실제 제어문자로 들어간다.

그래서, 응답을 cat <&3으로 받았는데 셸이 돌아오지 않는 경우. HTTP/1.1을 쓰면서 Connection: close를 안 넣었거나, Content-Length가 큰데 서버가 keepalive로 잡고 있는 상태다. Connection: close를 못 박아라.

타임아웃. bash /dev/tcp에는 connect timeout 옵션이 없다. SYN 응답이 안 오면 운영체제 기본값(보통 75~180초)까지 기다린다. 백그라운드로 돌리고 timeout 명령으로 감싸는 패턴이 그나마 낫다.

timeout 5 bash -c 'exec 3<>/dev/tcp/payment.internal/80; \
  printf "GET /health HTTP/1.0\r\nHost: payment.internal\r\n\r\n" >&3; \
  cat <&3'

timeout 명령도 미니멀 이미지에는 빠져 있을 수 있다 (coreutils 일부). 없으면 backgrounded 프로세스 PID를 잡고 kill -9 하는 식으로 직접 짜야 한다.

이처럼, chunked encoding 응답. HTTP/1.1로 호출하면 서버가 Transfer-Encoding: chunked로 내려주는 경우가 있다. 헥사 길이 줄이 사이사이 끼어 있어 그대로 받으면 깨져 보인다. 이걸 셸에서 풀려면 파서를 직접 짜야 해서 사실상 비현실적이다. 처음부터 HTTP/1.0을 보내거나 Accept-Encoding: identity를 명시해서 chunked를 피하는 게 낫다.

함수로 묶어 두기

장애 대응이 잦은 환경에서는 셸 함수로 만들어 두고 dotfiles에 박아 둔다. 사이드카 pod 안의 .bashrc에 미리 박아 두면 컨테이너 진입 후 바로 쓸 수 있다.

hcheck() {
  local host="$1" port="${2:-80}" path="${3:-/}"
  exec 3<>/dev/tcp/$host/$port || { echo "connect failed" >&2; return 1; }
  printf 'GET %s HTTP/1.0\r\nHost: %s\r\n\r\n' "$path" "$host" >&3
  cat <&3
  exec 3<&-
}

# 사용
hcheck payment.internal 80 /health
hcheck 10.0.3.21 8080 /metrics

그러나, 여러 호스트를 한 번에 훑는 패턴도 자주 쓴다. 어떤 의존성이 떨어졌는지 빠르게 격리할 때 유용하다.

for target in \
  "payment.internal:80:/health" \
  "auth.internal:80:/ready" \
  "10.0.3.21:8080:/metrics"; do
  IFS=':' read -r host port path <<< "$target"
  printf '== %s:%s%s ==\n' "$host" "$port" "$path"
  (exec 3<>/dev/tcp/$host/$port \
   && printf 'GET %s HTTP/1.0\r\nHost: %s\r\n\r\n' "$path" "$host" >&3 \
   && head -n 1 <&3) || echo "FAILED"
done

head -n 1 <&3은 상태 줄만 뽑는다. 헬스체크용으로는 충분하다. 200대인지 5xx인지만 보면 되니까.

/dev/tcp는 28년째 같은 자리에 있는 기능이다. 새로 배워야 할 syntax 같은 게 아니라 그냥 bash 안에 박혀 있다. 다음번 미니멀 컨테이너 장애에서 디버그 이미지를 새로 빌드하기 전에 exec 3<>/dev/tcp/...를 먼저 쳐 보면 된다.

관련 글