CONNECT 응답 200 이후 앱과 서버 사이에 정확히 무엇이 오가는지, 그리고 "연결을 재사용한다"는 말이 어느 층의 이야기인지를 실제 Squid로 측정해 정리한 문서입니다. 모든 출력은 재현 가능한 랩에서 캡처한 실측값이며, 마지막 파트에 그 랩을 직접 구성하는 방법을 담았습니다.
Connection: close면 요청마다 새 터널·새 ACL 심사·새 로그. HTTP/1.1의 동시 요청은 반드시
터널을 늘리고, HTTP/2는 스트림으로 접어 넣는다.A안(앱이 CONNECT 유지)에서 egress gateway의 Envoy는 TCP 프록시이므로 downstream 연결 1개당 upstream 연결 1개를 1:1로 맺습니다. 따라서 앱의 커넥션 풀 설정이 gateway의 동시 연결 수와 ephemeral port 소모량을 그대로 결정합니다. 용량 산정의 기준 단위가 "요청 수"가 아니라 "동시 터널 수"여야 하는 이유입니다.
흔한 오해는 "CONNECT로 터널이 열리면 앱이 그 터널이라는 새 통로에 다시 연결한다"는 그림입니다. 실제로는 새로 열리는 것이 없습니다. 앱은 프록시에 연결했던 같은 file descriptor에 계속 쓰고 읽을 뿐이고, 200 응답을 기점으로 거기에 쓰는 내용의 성격만 바뀝니다.
ACL 심사는 터널을 열 때 딱 한 번(그림의 평문 국면)이고, 그 뒤로 터널에 무엇이 몇 개나 흐르든 Squid는 관여하지 않습니다. 이 성질이 Part 03~04의 모든 결과를 설명합니다.
아래는 curl -v가 아니라 raw socket + ssl.MemoryBIO로
소켓에 실제로 나가는 바이트를 찍은 결과입니다(스크립트는 Part 05).
대상은 실제 Squid(:3128)와 실제 TLS origin(api.demo.test:443, CN=api.demo.test).
[1] TCP 연결 fd=3 ('127.0.0.1', 45484) -> ('127.0.0.1', 3128)
[2] fd=3 에 평문 63B 씀:
CONNECT api.demo.test:443 HTTP/1.1
Host: api.demo.test:443
[3] 프록시 응답: HTTP/1.1 200 Connection established (fd=3 그대로)
[4] 같은 fd=3 위에서 TLS 핸드셰이크 — 여기서부터 squid 에게 불투명
→ 517B 0x16 handshake SNI 평문 포함!
hex: 16 03 01 02 00 01 00 01 fc 03 03 1e 8c 0d 2d 44 ...
← 1387B 0x16 handshake
hex: 16 03 03 00 7a 02 00 00 76 03 03 07 49 11 fe 0b ...
→ 80B 0x14 change_cipher_spec
협상 결과: TLSv1.3 / TLS_AES_256_GCM_SHA384
origin 인증서 subject: CN=api.demo.test <- 앱이 터널 너머 origin 을 직접 검증
[5] 앱이 쓴 평문 GET 72B -> 와이어에는 94B 0x17 application_data
hex: 17 03 03 00 59 09 95 36 8f 59 b4 e4 1b 2b 06 c9 ...
와이어에서 '/v1/pay' 문자열이 보이나? False
와이어에서 'api.demo.test' 가 보이나? False
← 510B 0x17 application_data 응답도 암호화된 레코드
[6] 복호화한 응답 첫 줄: HTTP/1.1 404 File not found
[7] 이 전체 대화가 오간 fd = 3 (처음 프록시에 연결한 그 소켓)
| 단계 | 관측 | 의미 |
|---|---|---|
| [3]→[4] 경계 | fd 번호가 그대로, 곧장 16 03 01 | 새 연결 없음. 0x16=handshake 레코드 타입. 이 순간부터 Squid에겐 해석 불가능한 바이트 스트림 |
| [4] ClientHello | 517B 안에 api.demo.test 문자열 존재 | 암호 협상 전이라 SNI는 가려지지 않음. Squid의 peek 모드와 egress gateway의 SNI 제어가 붙잡는 게 정확히 이 바이트 |
| [4] 인증서 | subject가 origin의 것 | 검증 주체는 앱. issuer가 사내 CA면 경로 어딘가에서 bump(MITM) 중이라는 신호 |
| [5] 요청 | 평문 72B → 와이어 94B, 경로 문자열 소멸 | 차이 22B = 레코드 헤더 5B + AEAD 태그 16B + 콘텐츠 타입 1B. "Squid는 경로를 모른다"의 실증 |
TLS origin 의 액세스 로그:
"GET /v1/pay?user=123 HTTP/1.1" ← origin-form. 프록시의 흔적이 없음
터널 안쪽은 프록시가 존재하지 않는 세계입니다. origin 입장에서 이 요청은 평범한 직접 연결과 구분되지 않습니다.
(대조: 평문 http:// 요청은 Squid가 absolute-form을 파싱해 origin-form으로 재작성해 보냅니다.)
1784243300.392 22 127.0.0.1 TCP_TUNNEL/200 2500 CONNECT api.demo.test:443 - HIER_DIRECT/192.0.2.2 -
└22ms └터널로 처리 └총 2500B (핸드셰이크+요청+응답 전부 합산) └squid가 해석한 IP
같은 요청을 인증서를 신뢰하지 않는 상태로(-k 없이) 보내면:
앱: < HTTP/1.1 200 Connection established
* SSL certificate problem: self-signed certificate
curl: (60) SSL certificate problem
squid access.log: TCP_TUNNEL/200 1384 CONNECT api.demo.test:443 HIER_DIRECT/192.0.2.2 ← 정상 200
Squid는 200을 주고 정상 터널로 기록했는데 앱만 실패했습니다. TLS 검증은 앱과 origin 사이의 일이라
프록시 로그에 흔적이 남지 않습니다. "Squid 로그는 문제없다는데 앱은 안 된다"의 대표 원인 —
사내 CA 미배포, 인증서 만료, SAN 불일치, TLS 버전 미스매치가 전부 이 패턴입니다.
진단은 프록시 로그가 아니라 앱 쪽 curl -v의 subject/issuer 줄에서 해야 합니다.
"연결을 재사용한다"가 헷갈리는 이유는 "연결"이라는 한 단어가 서로 다른 네 개의 층을 가리키기 때문입니다. 각 요청이 HTTPS라고 해서 요청마다 새 연결이 필요한 게 아닙니다 — "HTTPS 연결"은 요청 단위가 아니라 TLS 세션 단위이고, TLS 세션 하나가 요청 여러 개를 실어 나릅니다. 재사용이란 TLS를 생략하는 게 아니라, 이미 성립한 TLS 세션 위에 다음 요청을 얹는 것입니다.
| 층 | 무엇의 단위인가 | 실측에서 개수 |
|---|---|---|
| TCP 연결 (소켓 fd) | 앱↔Squid 물리 배관 | 1 |
| CONNECT 터널 | Squid의 ACL 심사 · access.log 1줄 | 1 |
| TLS 세션 | 핸드셰이크 · 인증서 검증 | 1 |
| HTTP 요청 | GET /a, GET /b, GET /c | 3 |
위 세 층은 함께 태어나 함께 죽습니다(터널이 곧 TCP이고, 그 위에 TLS 세션 하나). 맨 아래 층만 N개입니다.
HTTP/1.0 시절엔 요청 하나마다 TCP를 열고 닫았습니다("요청 = 연결"이 진짜였던 시대).
HTTP/1.1부터 persistent connection이 기본이 되어, 응답을 받아도 연결이 살아있고
다음 요청이 그 위로 흐릅니다. Connection: close가 있어야 닫힙니다.
이건 프록시와 무관한 HTTP 자체의 성질인데, 프록시가 끼면 그 성질이 터널에 그대로 상속됩니다. 앱이 연결을 유지하면 터널도 유지되고, 앱이 닫으면 터널도 닫힙니다. 터널의 수명은 Squid가 아니라 앱과 origin이 정합니다.
앱의 HTTP 라이브러리는 쓰고 난 연결을 버리지 않고 풀(pool)에 담아둡니다. 다음 요청이 오면
(scheme, host, port, proxy) 조합을 키로 재사용 가능한 연결을 찾습니다.
프록시를 쓸 때 이 키가 중요한 이유는 목적지가 다르면 터널을 공유할 수 없기 때문입니다 —
같은 Squid를 지나도 api.a.com용 터널과 api.b.com용 터널은 별개입니다.
CONNECT가 열어준 파이프의 반대편은 이미 특정 origin에 고정돼 있으니까요.
HTTP/1.1은 한 연결에서 요청 하나가 끝나야 다음 요청을 보낼 수 있습니다. 따라서 재사용은 순차적일 때만 가능하고, 동시 요청 N개는 반드시 연결 N개 = 터널 N개가 됩니다. HTTP/2는 하나의 연결 안에 여러 스트림을 다중화하므로 터널 하나로 접힙니다.
같은 3요청을 조건만 바꿔가며 측정했습니다. 측정 원리는 터널 1개 = access.log 1줄입니다.
| 케이스 | 조건 | 요청 | 터널 | 관측 |
|---|---|---|---|---|
| A | 순차 · keep-alive · 같은 호스트 | 3 | 1 | 137ms, 7221B 합산 |
| B | 순차 · Connection: close | 3 | 3 | 3265B, 1135B, 3891B |
| C | 병렬 · HTTP/1.1 · 같은 호스트 | 3 | 3 | 멀티플렉싱 불가 |
| D | 순차 · 서로 다른 호스트 | 2 | 2 | 풀 키가 다름 |
| E | 병렬 · HTTP/2 | 3 | 1 | 149ms, 5803B |
요청은 똑같이 3개인데, 앱이 연결을 유지하느냐 닫느냐에 따라 TLS 핸드셰이크가 1번이냐 3번이냐가 갈립니다. B에서는 매번 새 CONNECT → 새 ACL 심사 → 새 TLS 핸드셰이크 → 새 로그 줄입니다. "각 요청이 별도의 HTTPS 연결"인 상황이 정확히 B입니다.
E 케이스의 클라이언트 트레이스:
> CONNECT example.com:443 HTTP/1.1 ← 터널은 딱 하나
* ALPN: server accepted h2
* [HTTP/2] [1] [:path: /a]
* Re-using existing connection with proxy squid.test
* [HTTP/2] [3] [:path: /b] ← stream 3
* Re-using existing connection with proxy squid.test
* [HTTP/2] [5] [:path: /c] ← stream 5
squid access.log: 149ms TCP_TUNNEL/200 5803B CONNECT example.com:443 ← 1줄
터널 하나 안에서 stream 1·3·5가 동시에 흐르고 Squid는 로그 한 줄만 남깁니다. 프록시 구간은 HTTP/1.1인데 터널 내부는 h2라는 사실이 여기서 실질적 의미를 가집니다 — 터널이 순수 TCP라 안에서 무엇을 하든 상관없기 때문입니다.
앱의 동시 터널 수 = gateway 의 동시 downstream = gateway→Squid 동시 연결 수
A안에서 egress gateway의 Envoy는 TCP 프록시입니다. TCP 스트림은 풀링이 불가능하므로
(바이트 순서가 그 커넥션에 묶여 있으니) downstream 연결 1개당 upstream 연결 1개를 1:1로 맺습니다.
따라서 앱의 커넥션 풀 설정이 gateway의 ephemeral port 소모량을 그대로 결정합니다.
Connection: close를 쓰거나 풀이 작은 앱이 많으면 터널이 계속 새로 뚫리고,
h2를 쓰는 앱은 요청 수천 개가 터널 하나로 접혀 들어갑니다.
Squid 로그로 API 호출 횟수를 세면 틀립니다. 로그 줄 수는 터널 수이지 요청 수가 아닙니다 — A에서 3요청이 1줄, h2 앱이면 수천 요청이 1줄. 감사·과금·이상탐지를 로그 줄 수 기반으로 하고 있다면 이미 어긋나 있을 수 있습니다.
TLS session resumption ≠ 터널 재사용. 새 터널이 필요한 상황에서도 session ticket으로 핸드셰이크 왕복은 줄일 수 있지만, CONNECT·ACL 심사·로그 줄은 그대로 새로 발생합니다. 다른 층의 최적화입니다.
풀 기본값은 런타임마다 다릅니다. Go의 MaxIdleConnsPerHost 기본값은 2라서,
동시성이 높은 Go 앱은 유휴 연결을 버리고 계속 새 터널을 뚫는 패턴이 나오기 쉽습니다.
전환 후 gateway 커넥션 수가 예상보다 많다면 앱의 풀 설정부터 보는 게 순서입니다.
사내 Squid로는 이 실험을 할 수 없습니다. 목적지가 남의 서버면 "origin이 실제로 무엇을 받았는지"를 볼 수 없고, ACL을 바꿔볼 수도, 실패를 일부러 만들 수도 없습니다. 랩의 조건은 클라이언트·프록시·origin 세 지점을 전부 관측 가능하게 만드는 것입니다.
멱등(idempotent)하게 작성 — 몇 번을 실행해도 안전합니다.
LAB=/opt/proxylab
apt-get install -y squid openssl curl
# 이름 등록 — 컨테이너가 /etc/hosts 를 리셋할 수 있어 매번 재확인
grep -q ' squid.test$' /etc/hosts || echo "127.0.0.1 squid.test" >> /etc/hosts
grep -q ' api.demo.test$' /etc/hosts || echo "$ORIGIN_IP api.demo.test" >> /etc/hosts
grep -q ' api2.demo.test$' /etc/hosts || echo "$ORIGIN_IP api2.demo.test" >> /etc/hosts
# ACL — deny 를 반드시 'allow localhost' 앞에 (first-match)
sed -i "s|^http_access allow localhost$|acl blocked_domains dstdomain .badsite.test\nhttp_access deny blocked_domains\nhttp_access allow localhost|" /etc/squid/squid.conf
# origin 인증서 — SAN 에 두 이름 다 넣어야 D 케이스(다른 호스트)가 성립
openssl req -x509 -newkey rsa:2048 -nodes -days 30 \
-keyout $LAB/demo.key -out $LAB/demo.crt \
-subj "/CN=api.demo.test" \
-addext "subjectAltName=DNS:api.demo.test,DNS:api2.demo.test"
start_bg() { # $1=확인할 포트, 나머지=명령
local port="$1"; shift # pgrep 대신 포트 확인이 확실함
(timeout 1 bash -c "</dev/tcp/127.0.0.1/$port") 2>/dev/null && return 0
nohup setsid "$@" </dev/null >>"$LAB/bg.log" 2>&1 &
}
start_bg 8080 python3 -u -m http.server 8080
start_bg 443 python3 -u "$LAB/origin_tls.py" "$LAB/demo.crt" "$LAB/demo.key"
pgrep -x squid >/dev/null && squid -k reconfigure || squid
sleep 5 # squid 는 바인딩까지 3~5초
import http.server, ssl, sys
h = http.server.SimpleHTTPRequestHandler
h.protocol_version = "HTTP/1.1" # 없으면 keep-alive 불가 = 재사용 실험 자체가 성립 안 함
srv = http.server.ThreadingHTTPServer(("0.0.0.0", 443), h) # 단일 스레드면 교착 (함정 ③)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(sys.argv[1], sys.argv[2])
srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
srv.serve_forever()
| 스크립트 | 방법 | 증명하는 것 |
|---|---|---|
verify-01-forms.sh | 가짜 프록시로 요청 라인 원문 캡처 | request-target 세 형태(origin / absolute / authority)는 "읽는 상대"가 결정한다 |
verify-02-tunnel.py | raw socket + ssl.MemoryBIO | TLS는 앱↔origin end-to-end이고 Squid는 바이트의 의미를 모른다 |
verify-03-reuse.sh | 조건별로 access.log 줄 수 카운트 | 터널 수를 정하는 건 앱과 origin이다 (5케이스 매트릭스) |
진짜 Squid는 요청을 받아 즉시 처리해버리므로 "앱이 무엇을 보냈나"를 원문으로 보려면 받기만 하고 아무것도 안 하는 서버가 필요합니다.
import socket, sys
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", int(sys.argv[1]))); srv.listen(1)
conn, _ = srv.accept(); conn.settimeout(2)
sys.stdout.write(conn.recv(4096).decode(errors="replace")) # 원문 그대로 출력
conn.close(); srv.close()
실행 결과 — 같은 URL, 스킴만 다르게:
===== 대상이 http:// → absolute-form =====
GET http://api.demo.test:8080/v1/pay?user=123 HTTP/1.1
Host: api.demo.test:8080
===== 대상이 https:// → authority-form (CONNECT) =====
CONNECT api.demo.test:443 HTTP/1.1
Host: api.demo.test:443
===== 비교: 프록시 없이 직접 → origin-form =====
> GET /v1/pay?user=123 HTTP/1.1
> Host: api.demo.test:8080
curl -v는 해석된 결과를 보여줍니다. 보통 ssl.wrap_socket은 TLS 엔진을
소켓에 직접 붙여서 암호문을 볼 수 없는데, MemoryBIO로 중간에 버퍼를 끼우면
와이어에 나가는 바이트가 손에 잡힙니다.
inb, outb = ssl.MemoryBIO(), ssl.MemoryBIO()
tls = ctx.wrap_bio(inb, outb, server_hostname=HOST) # TLS 엔진을 소켓이 아니라 메모리에 붙임
# 핸드셰이크: 소켓과 BIO 사이를 손으로 펌프질
while True:
try:
tls.do_handshake(); break
except ssl.SSLWantReadError:
out = outb.read()
if out: sock.sendall(out) # 나갈 바이트 = 여기서 눈으로 볼 수 있음
inb.write(sock.recv(16384))
# 요청: 평문을 넣고 암호문을 꺼낸다
tls.write(b"GET /v1/pay?user=123 HTTP/1.1\r\nHost: ...\r\n\r\n")
wire = outb.read() # ← 0x17 application_data 레코드
print(b"/v1/pay" in wire) # False — 경로가 사라졌음을 증명
b=$(wc -l < /var/log/squid/access.log)
curl -sk -x $PROXY "$U1" "$U2" "$U3" -o /dev/null -o /dev/null -o /dev/null
sleep 1.5 # 로그는 '닫힐 때' 쓰이므로 필수
echo "터널 $(( $(wc -l < /var/log/squid/access.log) - b ))개"
케이스별 커맨드:
A curl -sk -x $PROXY $U1 $U2 $U3 # → 1터널
B curl -sk -x $PROXY -H "Connection: close" $U1 $U2 $U3 # → 3터널
C curl -sk -x $PROXY -Z --parallel-immediate --http1.1 ... # → 3터널
D curl -sk -x $PROXY https://api.demo.test/ https://api2.demo.test/ # → 2터널
E curl -s -x $PROXY -Z --http2 https://example.com/{a,b,c} # → 1터널
A 케이스는 실제로 존재하는 파일을 요청해 200을 받아야 합니다. 404 응답에는
Connection: close가 붙어(파이썬 send_error의 동작) 재사용이 깨지고
항상 3터널로 나옵니다 — 함정 ④.
구성 과정에서 실제로 겪은 것들이며, 각각이 이 주제의 메커니즘을 그대로 반영합니다.
Ubuntu 패키지의 include /etc/squid/conf.d/*.conf는 1621행인데
http_access allow localhost는 1604행입니다. conf.d에 deny를 넣으면 이미 allow에서
결정이 끝난 뒤라 규칙이 통째로 무시됩니다.
first-match는 "설정 파일 어디에 쓰느냐"까지 포함하는 규칙입니다. 운영에서 "ACL 반영 안 돼요"의 다수가 이것입니다.
Squid가 자체 DNS 캐시(ipcache)를 갖고 있어, 한 번 실패한 이름은 계속 실패합니다.
squid -k reconfigure로 플러시해야 합니다.
Squid가 이름을 해석하는 주체라는 사실이 운영 절차에 그대로 드러나는 지점입니다 — 사내에서 DNS 레코드를 바꿨는데 Squid만 옛 IP로 가는 상황이 정확히 같은 원리입니다.
origin이 단일 스레드였던 것이 원인. keep-alive 연결이 다음 요청을 기다리며 스레드를 붙잡으니,
두 번째 연결이 첫 연결의 idle timeout까지 막혀 교착됐습니다.
ThreadingHTTPServer로 바꾸니 53ms/103ms로 정상화.
"연결을 유지한다"는 것은 서버 자원을 붙잡는다는 뜻입니다. 프록시·gateway 계층에서도 동일하게 성립하며, Part 04의 용량 논의와 직결됩니다.
404 응답에 Connection: close가 붙어서였습니다. 실제 존재하는 파일을 요청해 200을 받아야
keep-alive가 유지됩니다. 터널 수명은 응답 하나하나가 좌우한다는 증거.
curl -Z --parallel-immediate가 범인. h2 협상 결과를 기다리지 않고 연결 3개를 즉시 열어버립니다.
플래그를 빼면 curl이 첫 연결의 ALPN 결과를 보고 멀티플렉싱해 1터널이 됩니다.
"h2를 쓰면 자동으로 1터널"이 아니라 클라이언트의 연결 개시 전략이 정합니다. 앱 프레임워크의 커넥션 매니저 설정이 gateway 부하를 좌우한다는 뜻이기도 합니다.
sleep이 부족하면 헬스체크가 FAIL로 나옴setsid로 띄워야 세션 종료 후에도 살아남음.local 도메인은 RFC 6762가 mDNS용으로 예약 — 프록시 이름으로 쓰면 avahi 설치 호스트에서 해석 실패 가능pgrep -f보다 포트 확인이 확실함 (pgrep은 자기 명령줄까지 매치)랩에서 익힌 것 중 실제 Squid를 건드리지 않고 파드에서 바로 쓸 수 있는 읽기 전용 진단들입니다.
# 1) 이 요청이 프록시를 타는가, 직행하는가 (NO_PROXY 진단)
curl -v https://api.target.com/ 2>&1 | grep -E 'Connected to|CONNECT'
# "Connected to squid..." → 프록시 경유
# "Connected to api.target.com" → NO_PROXY 매치로 직행
# 2) 경로상에 TLS 개입(bump)이 있는가
curl -v https://api.target.com/ 2>&1 | grep -E 'subject:|issuer:'
# issuer 가 공인 CA 가 아니면 어딘가에서 가로채는 중
# 3) 내 앱이 터널을 몇 개 쓰는가 (커넥션 풀 진단 — gateway 용량과 직결)
ss -tn state established '( dport = :3128 )' | wc -l
# 4) 라이브러리가 env 를 읽기는 하는가
# curl 성공 + 앱 timeout → JVM/Node 의 env 미지원 의심
| 증상 | 실패 구간 | 다음 행동 |
|---|---|---|
| :3128 connection timeout | 프록시까지 못 감 | 네트워크/방화벽/NO_PROXY 확인 |
403 + X-Squid-Error | 정책 차단 — 정상 동작 | Squid 운영팀에 도메인 허용 요청 |
| 503 | Squid → origin 실패 (DNS 포함) | Squid access.log 의 HIER_ 필드 |
SSL certificate problem | 앱 ↔ origin 의 TLS — 프록시 로그엔 안 남음 | 앱 쪽 curl -v 의 subject/issuer |
| 약 1시간 주기 간헐 끊김 | idle timeout (프록시 또는 Envoy 기본값) | 타임아웃 층 정렬 (설계서 7.2) |
GET /path) · absolute-form(GET http://host/path) · authority-form(CONNECT host:443). 요청 라인을 읽는 상대가 서버냐 프록시냐, 자원 요청이냐 배관 요청이냐가 결정한다.0x16 handshake · 0x14 change_cipher_spec · 0x17 application_data · 0x15 alert. 와이어 첫 바이트로 구분된다.