CCR 컨테이너를 위한 MITM 가능 WebSocket 터널.
업스트림 프록시는 CCR(Claude Code Remote) 컨테이너에서 실행되며, 클라이언트와 API 서버 사이의 WebSocket 트래픽을 중계합니다. 핵심 과제는 GKE(Google Kubernetes Engine) L7 로드 밸런서를 통해 WebSocket 연결을 안정적으로 유지하는 것입니다.
proxy/server.ts → proxy/connection.ts → proxy/protobuf.ts → proxy/keepalive.ts → proxy/env.ts
핵심 도전 과제:
GKE의 L7 로드 밸런서(Envoy 기반)는 HTTP/WebSocket 트래픽에 여러 제약을 부과합니다.
// proxy/connection.ts — 512KB 청크 분할
const MAX_CHUNK_SIZE = 512 * 1024 // 512KB Envoy 캡
function splitIntoChunks(data: Buffer): Buffer[] {
const chunks: Buffer[] = []
for (let i = 0; i < data.length; i += MAX_CHUNK_SIZE) {
chunks.push(data.subarray(i, Math.min(i + MAX_CHUNK_SIZE, data.length)))
}
return chunks
}
직접 연결(클라이언트 → API 서버)은 LB 제약에 부딪힙니다. 프록시는 중간에서 큰 메시지를 512KB 이하로 분할하고, 킵얼라이브 핑을 주입하며, 연결이 끊어지면 재연결을 시도합니다. 또한 프록시는 MITM(Man-in-the-Middle) 위치에서 트래픽을 관찰하여 모니터링 데이터를 수집할 수 있습니다.
프록시 서버의 시작은 보안 설정부터 서버 바인딩까지 6단계로 구성됩니다.
// proxy/server.ts — 6단계 초기화
async function startProxy(): Promise<void> {
// 1. 코어 덤프 비활성화
if (process.platform === 'linux') {
prctl(PR_SET_DUMPABLE, 0)
}
// 2. 환경 변수 로드
const config = loadProxyConfig()
// 3. Protobuf 초기화
const codec = initProtobufCodec()
// 4. 킵얼라이브 설정
const keepalive = new KeepaliveManager(config.pingInterval, config.idleTimeout)
// 5. HTTP 서버 + WebSocket 업그레이드
const server = createServer()
server.on('upgrade', (req, socket, head) => {
handleUpgrade(req, socket, head, codec, keepalive)
})
// 6. 포트 바인딩
server.listen(config.port, () => {
console.log(`프록시 서버 시작: 포트 ${config.port}`)
})
}
prctl PR_SET_DUMPABLE(0)은 Linux 전용입니다. macOS/Windows에서는 이 호출이 무시됩니다. 코어 덤프 비활성화는 API 토큰이나 사용자 데이터가 /tmp/의 덤프 파일로 유출되는 것을 방지합니다. CCR 컨테이너는 다수의 사용자 세션을 처리하므로 이 보호가 특히 중요합니다.
각 WebSocket 연결은 두 단계의 상태를 거칩니다.
클라이언트의 첫 메시지에서 인증 토큰을 추출하고 검증합니다. 유효한 경우 업스트림 API 서버로의 WebSocket 연결을 수립합니다.
양방향 메시지 중계가 시작됩니다. 클라이언트 → 업스트림과 업스트림 → 클라이언트 방향의 메시지를 투명하게 전달하며, 필요 시 청크 분할과 Protobuf 변환을 적용합니다.
// proxy/connection.ts — 2단계 상태 머신
class ProxyConnection {
private state: 'handshake' | 'relay' = 'handshake'
onMessage(data: Buffer): void {
if (this.state === 'handshake') {
const token = this.extractToken(data)
if (this.validateToken(token)) {
this.connectUpstream(token)
this.state = 'relay'
} else {
this.close(4001, '인증 실패')
}
return
}
// relay 상태: 메시지를 업스트림으로 전달
const chunks = splitIntoChunks(data)
for (const chunk of chunks) {
this.upstream.send(chunk)
}
}
}
업스트림 프록시는 protobuf-js 같은 라이브러리를 사용하지 않고 인코더/디코더를 수작업으로 구현합니다. 이는 번들 크기를 줄이고, CCR 컨테이너의 제한된 환경에서 의존성 문제를 피하기 위함입니다.
// proxy/protobuf.ts — 수작업 varint 인코딩
function encodeVarint(value: number): Uint8Array {
const bytes: number[] = []
while (value > 0x7f) {
bytes.push((value & 0x7f) | 0x80)
value >>>= 7
}
bytes.push(value & 0x7f)
return new Uint8Array(bytes)
}
function encodeMessage(fieldNumber: number, data: Uint8Array): Uint8Array {
const tag = encodeVarint((fieldNumber << 3) | 2) // wire type 2 = length-delimited
const length = encodeVarint(data.length)
return concat(tag, length, data)
}
protobuf-js는 약 200KB의 런타임 코드를 추가하며, .proto 파일 컴파일 단계가 필요합니다. CCR 컨테이너는 최소한의 이미지 크기를 목표로 하며, 사용하는 메시지 타입이 3-4개에 불과하므로 수작업 구현이 더 효율적입니다. varint 인코딩과 wire type 처리는 Protobuf의 핵심이지만 코드량은 50줄 미만입니다.
업스트림 프록시는 Bun과 Node.js 두 런타임에서 모두 실행될 수 있도록 설계되었습니다. 런타임 감지 후 API 차이를 추상화합니다.
// proxy/server.ts — 런타임 감지 및 추상화
const isBun = typeof globalThis.Bun !== 'undefined'
const WebSocketServer = isBun
? Bun.serve // Bun 내장 WebSocket 서버
: require('ws').WebSocketServer // Node.js용 ws 라이브러리
Bun은 WebSocket을 네이티브로 지원하여 ws 라이브러리가 불필요하고, 메모리 사용량이 더 적습니다. Node.js는 호환성이 더 넓고 디버깅 도구가 풍부합니다. CCR 환경에서는 기본적으로 Bun을 사용합니다.
프록시는 8개의 환경 변수에서 설정을 읽습니다.
PROXY_PORT — 프록시 서버 바인딩 포트UPSTREAM_URL — API 서버 WebSocket URLPING_INTERVAL_MS — 킵얼라이브 핑 간격 (기본 30,000ms)IDLE_TIMEOUT_MS — 유휴 연결 타임아웃 (기본 50,000ms)MAX_CHUNK_SIZE — 최대 청크 크기 (기본 524,288 = 512KB)AUTH_TOKEN — 프록시 인증 토큰TLS_CERT_PATH — TLS 인증서 경로 (선택적)LOG_LEVEL — 로깅 수준 (debug/info/warn/error)프록시는 여러 보안 계층을 적용합니다:
prctl PR_SET_DUMPABLE(0)으로 메모리 덤프 방지GKE LB의 유휴 타임아웃을 방지하기 위해 프록시는 30초마다 WebSocket 핑을 전송합니다. 50초 동안 어떤 트래픽도 없으면 연결을 종료합니다.
// proxy/keepalive.ts — 킵얼라이브 관리
class KeepaliveManager {
private pingTimer: NodeJS.Timer
private idleTimer: NodeJS.Timer
constructor(
private pingInterval = 30_000, // 30초
private idleTimeout = 50_000, // 50초
) {}
start(ws: WebSocket): void {
this.pingTimer = setInterval(() => {
ws.ping() // WebSocket 핑 프레임 전송
}, this.pingInterval)
this.resetIdleTimer(ws)
}
onActivity(ws: WebSocket): void {
this.resetIdleTimer(ws) // 트래픽 시 유휴 타이머 리셋
}
private resetIdleTimer(ws: WebSocket): void {
clearTimeout(this.idleTimer)
this.idleTimer = setTimeout(() => {
ws.close(1000, '유휴 타임아웃')
}, this.idleTimeout)
}
}
30초 핑 간격은 GKE LB의 기본 유휴 타임아웃(60초)보다 충분히 짧아야 합니다. 50초 유휴 타임아웃은 핑이 실패하더라도(네트워크 분리 등) LB 타임아웃 전에 프록시 측에서 정리할 수 있는 버퍼를 제공합니다. 핑 간격과 유휴 타임아웃의 비율은 중요합니다 — 핑 간격이 유휴 타임아웃에 너무 가까우면 일시적 네트워크 지연으로 불필요한 연결 종료가 발생합니다.
prctl PR_SET_DUMPABLE(0)이 첫 단계로, 코어 덤프를 통한 토큰 유출을 방지합니다PROXY_PORT와 UPSTREAM_URL이 필수입니다Q1. 업스트림 프록시가 WebSocket 메시지를 512KB 이하로 분할하는 이유는?
Q2. 초기화의 첫 단계인 prctl PR_SET_DUMPABLE(0)의 목적은?
PR_SET_DUMPABLE(0)은 Linux에서 프로세스의 코어 덤프 생성을 비활성화합니다. CCR 환경에서 프록시 프로세스는 API 토큰과 사용자 데이터를 메모리에 보유하므로, 크래시 시 이 정보가 /tmp/ 등의 덤프 파일로 유출되는 것을 방지합니다.Q3. Protobuf를 라이브러리 대신 수작업으로 구현한 이유는?
.proto 파일 컴파일이 필요합니다. 사용하는 메시지 타입이 3-4개뿐이므로 varint 인코딩과 wire type 처리를 50줄 미만으로 직접 구현하는 것이 번들 크기와 의존성 측면에서 더 효율적입니다.Q4. 킵얼라이브의 30초 핑 간격과 50초 유휴 타임아웃의 관계는?
Q5. 2단계 상태 머신에서 handshake 단계의 인증이 실패하면 어떻게 되나요?