curl 없는 컨테이너에서 살아남기: Bash /dev/tcp로 HTTP 요청 날리기
새벽 2시, 컨테이너 안에 curl이 없을 때
한 번쯤 겪어봤을 상황이다. 운영 중인 서비스에서 컨테이너 간 통신이 안 되는 것 같아서 `kubectl exec`로 파드에 들어갔는데, curl도 wget도 nc도 없다. distroless나 scratch 기반으로 알뜰하게 말아둔 이미지라 셸조차 없는 경우도 있다.
이게 왜 흔한 일이냐면, 요즘 운영 이미지는 의도적으로 도구를 다 빼기 때문이다. 공격 표면(attack surface)을 줄이려고 distroless를 쓰고, 이미지 크기를 줄이려고 alpine을 쓴다. curl 하나가 끌고 오는 라이브러리 의존성도 무시 못 하고, 컨테이너 안에 디버깅 도구가 많다는 건 그만큼 침해 시 공격자가 쓸 무기도 많다는 뜻이다. 그래서 "최소 이미지"라는 철학 자체는 옳다. 문제는 그 철학이 디버깅하는 우리를 엿먹인다는 점이다.
그런데 의외로 많은 이미지에 `bash`는 들어있다. 그리고 bash에는 잘 안 알려진 기능이 하나 있다. /dev/tcp/host/port 리다이렉션이다. 이걸로 패키지 설치 없이 TCP 연결을 열고 HTTP 요청을 손으로 짜서 보낼 수 있다. GeekNews에 올라온 글(원문)이 딱 이 트릭을 다룬다.
/dev/tcp는 진짜 파일이 아니다
핵심부터 말하면, /dev/tcp는 디스크에 존재하는 파일이 아니다. bash가 리다이렉션 구문을 파싱할 때 특별 취급하는 "가짜 경로"다. 그래서 이런 게 안 된다.
# 일반 파일처럼 접근하면 실패한다
$ ls /dev/tcp
ls: cannot access '/dev/tcp': No such file or directory
# dash(데비안의 /bin/sh)에서도 안 된다
$ /bin/sh -c 'cat /dev/tcp/example.com/80'
/bin/sh: 1: cannot create /dev/tcp/example.com/80: Directory nonexistent
bash 매뉴얼에 따르면, /dev/tcp/host/port 형태의 리다이렉션을 만나면 bash가 직접 DNS 조회를 하고 connect(2) 시스템 콜로 TCP 소켓을 연다. 비유하자면, 파일을 여는 척하는 문법을 빌려서 실제로는 네트워크 소켓을 파일 디스크립터에 매달아 놓는 것이다. 한번 디스크립터에 묶이면 그 다음부터는 평범한 파일처럼 읽고 쓰면 된다. 유닉스의 "모든 것은 파일이다" 철학을 bash가 셸 레벨에서 흉내 낸 셈이다.
실제로 example.com에 GET 요청을 날려보자.
# 파일 디스크립터 3번에 소켓을 연다
exec 3<>/dev/tcp/example.com/80
# HTTP/1.1 요청을 직접 작성해서 보낸다
printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' >&3
# 응답을 읽는다
cat <&3
실행하면 이런 응답이 나온다.
HTTP/1.1 200 OK
Content-Type: text/html
ETag: "..."
Cache-Control: max-age=...
Date: ...
Connection: close
<!doctype html>
<html>
...
응답에는 상태 줄, 헤더, 빈 줄, 본문이 한꺼번에 들어온다. 헤더를 더 붙이고 싶으면 요청을 끝내는 빈 줄(\r\n\r\n) 앞에 줄을 추가하면 된다. 예를 들어 인증 토큰을 실어야 한다면:
exec 3<>/dev/tcp/api-service/8080
printf 'GET /v1/models HTTP/1.1\r\nHost: api-service\r\nAuthorization: Bearer %s\r\nConnection: close\r\n\r\n' "$API_KEY" >&3
cat <&3
여기서 Connection: close가 진짜 중요하다. HTTP/1.1은 기본적으로 connection을 유지(keep-alive)하기 때문에, 이 헤더가 없으면 서버가 연결을 안 끊고 cat <&3이 EOF를 기다리며 영원히 멈춰 있는다. 디버깅하다가 터미널이 멈춰서 당황하지 말자.
실무 헬스체크 스크립트로 만들기
한 줄짜리 명령으로 끝내지 말고, 내부 서비스 연결성 확인용으로 다듬어보자. 타임아웃과 상태 코드 파싱까지 넣은 버전이다.
#!/usr/bin/env bash
# check_health.sh — curl 없는 컨테이너에서 헬스체크
HOST="${1:-service}"
PORT="${2:-8642}"
PATH_="${3:-/health}"
# timeout으로 감싸서 연결이 안 끊겨도 6초 후 빠져나오게 한다
response=$(timeout 6 bash -c "
exec 3<>/dev/tcp/${HOST}/${PORT} || exit 1
printf 'GET ${PATH_} HTTP/1.1\r\nHost: ${HOST}\r\nConnection: close\r\n\r\n' >&3
cat <&3
")
if [ $? -ne 0 ]; then
echo "연결 실패: ${HOST}:${PORT}"
exit 1
fi
# 상태 줄에서 코드만 뽑기
status=$(echo "$response" | head -1 | awk '{print $2}')
echo "상태 코드: ${status}"
echo "$response" | head -1
실행 결과:
$ ./check_health.sh api-service 8080 /health
상태 코드: 200
HTTP/1.1 200 OK
여기서 timeout 6 bash -c '...'로 감싼 게 핵심이다. 앞에서 말한 keep-alive 멈춤 문제를 안전망으로 막아준다. 무인 환경(예: 라이브니스 프로브 같은 데)에 쓸 거라면 이런 타임아웃은 필수다.
흔한 함정과 한계
1. bash가 아니라 dash/sh로 실행하면 안 된다. /dev/tcp는 POSIX 표준이 아니라 bash 고유 기능이다. 데비안의 /bin/sh는 dash로 링크돼 있어서 무심코 sh script.sh로 돌리면 이런 에러를 만난다.
$ sh check_health.sh
check_health.sh: 9: cannot create /dev/tcp/service/8642: Directory nonexistent
반드시 bash로 직접 호출하거나 셔뱅을 #!/usr/bin/env bash로 정확히 박아야 한다.
2. bash 빌드 옵션이 꺼져 있을 수 있다. /dev/tcp는 bash를 컴파일할 때 --enable-net-redirections 옵션으로 켜진다. 대부분의 주류 빌드는 켜져 있지만, 데비안은 과거 오랫동안 이걸 꺼둔 적이 있다. 원문 댓글에 따르면 2009년경(Debian Bug #146464) 입장이 바뀌어 활성화된 것으로 보인다. 오래된 베이스 이미지를 쓰고 있다면 동작 안 할 가능성을 염두에 두자.
3. HTTPS는 안 된다. /dev/tcp는 평문 TCP 소켓만 연다. TLS 핸드셰이크를 직접 할 능력이 없으니 443 포트에 이걸로 붙으면 서버가 못 알아듣고 깨진다. HTTPS를 손으로 확인해야 한다면 openssl s_client가 필요한데, 그건 또 distroless엔 없을 가능성이 높다.
4. 진짜 HTTP 클라이언트가 아니다. 이건 리다이렉트(3xx), chunked transfer-encoding, gzip 압축, 재시도를 하나도 처리 못 한다. 응답이 chunked로 오면 본문에 청크 크기 숫자가 그대로 섞여 나온다. 그러니 무인 자동화에 진지하게 쓰면 언젠가 발등 찍힌다. 어디까지나 "지금 이 포트가 열려 있고 HTTP로 응답하나?" 정도를 빠르게 보는 디버깅 도구로만 쓰자.
대안은 이런 것들이 있다.
- kubectl debug / ephemeral container: 쿠버네티스 1.25+ 환경이라면
kubectl debug로 디버깅용 사이드카를 임시로 붙여서 curl이 든 이미지를 끼워 넣을 수 있다. 운영 이미지를 건드리지 않는 가장 깔끔한 방법이다. - nsenter: 노드에 SSH로 들어갈 수 있다면 호스트에서 대상 컨테이너의 네트워크 네임스페이스에 진입해 호스트의 도구로 확인할 수 있다.
- 그냥 운영 이미지에 도구를 넣기: 원문 댓글에서도 "운영 이미지에 curl 정도는 거의 필수"라는 의견이 나온다. 보안 트레이드오프를 따져보고, 도구를 빼서 얻는 이득이 디버깅 비용보다 작다면 넣는 게 맞을 수도 있다.
정리
한 줄 요약: exec 3<>/dev/tcp/host/port는 패키지 설치 없이 TCP 연결성과 평문 HTTP 응답을 즉석에서 확인하는 bash 내장 트릭이다.
이걸 써야 할 때는 명확하다. distroless나 alpine 같은 최소 이미지 안에서, 도구 설치가 막혀 있고, 지금 당장 "저 서비스에 닿는지"만 빠르게 보고 싶을 때다. 반대로 HTTPS가 필요하거나, 리다이렉트·압축을 처리해야 하거나, 자동화 파이프라인에 진지하게 넣을 거라면 손대지 말고 제대로 된 클라이언트를 쓰거나 ephemeral container를 띄워라. 화재 진압용 손도끼지, 일상 작업 공구가 아니다.