업스트림 프록시 시스템

CCR 컨테이너를 위한 MITM 가능 WebSocket 터널.

01

개요

업스트림 프록시는 CCR(Claude Code Remote) 컨테이너에서 실행되며, 클라이언트와 API 서버 사이의 WebSocket 트래픽을 중계합니다. 핵심 과제는 GKE(Google Kubernetes Engine) L7 로드 밸런서를 통해 WebSocket 연결을 안정적으로 유지하는 것입니다.

다루는 소스 파일: proxy/server.tsproxy/connection.tsproxy/protobuf.tsproxy/keepalive.tsproxy/env.ts

핵심 도전 과제:

02

문제 정의: GKE L7 LB를 통한 WebSocket

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) 위치에서 트래픽을 관찰하여 모니터링 데이터를 수집할 수 있습니다.

03

6단계 초기화

프록시 서버의 시작은 보안 설정부터 서버 바인딩까지 6단계로 구성됩니다.

  1. prctl PR_SET_DUMPABLE(0): 코어 덤프를 비활성화하여 메모리에 있는 토큰이 덤프 파일로 유출되는 것을 방지
  2. 환경 변수 로드: 8개의 환경 변수에서 포트, 타임아웃, 인증 정보 등을 읽음
  3. Protobuf 스키마 초기화: 수작업으로 작성된 인코더/디코더 로드
  4. 킵얼라이브 타이머 설정: 30초 핑 간격, 50초 유휴 타임아웃
  5. HTTP 서버 생성: WebSocket 업그레이드 핸들러 등록
  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 컨테이너는 다수의 사용자 세션을 처리하므로 이 보호가 특히 중요합니다.

04

연결 처리: 2단계 상태 머신

각 WebSocket 연결은 두 단계의 상태를 거칩니다.

stateDiagram-v2 [*] --> Handshake : WebSocket 업그레이드 Handshake --> Relay : 인증 성공 Handshake --> [*] : 인증 실패 Relay --> [*] : 연결 종료 Relay --> Relay : 메시지 중계 state Handshake { [*] --> 토큰_검증 토큰_검증 --> 업스트림_연결 업스트림_연결 --> 준비_완료 } state Relay { [*] --> Bidirectional Bidirectional --> Bidirectional : 클라이언트 ↔ 업스트림 }

Phase 1: Handshake

클라이언트의 첫 메시지에서 인증 토큰을 추출하고 검증합니다. 유효한 경우 업스트림 API 서버로의 WebSocket 연결을 수립합니다.

Phase 2: Relay

양방향 메시지 중계가 시작됩니다. 클라이언트 → 업스트림과 업스트림 → 클라이언트 방향의 메시지를 투명하게 전달하며, 필요 시 청크 분할과 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)
    }
  }
}
05

Protobuf 인코딩: 수작업 구현

업스트림 프록시는 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인가?

protobuf-js는 약 200KB의 런타임 코드를 추가하며, .proto 파일 컴파일 단계가 필요합니다. CCR 컨테이너는 최소한의 이미지 크기를 목표로 하며, 사용하는 메시지 타입이 3-4개에 불과하므로 수작업 구현이 더 효율적입니다. varint 인코딩과 wire type 처리는 Protobuf의 핵심이지만 코드량은 50줄 미만입니다.

06

Bun vs Node 런타임

업스트림 프록시는 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을 사용합니다.

07

환경 변수 전파 (8개) & 보안 모델

프록시는 8개의 환경 변수에서 설정을 읽습니다.

  1. PROXY_PORT — 프록시 서버 바인딩 포트
  2. UPSTREAM_URL — API 서버 WebSocket URL
  3. PING_INTERVAL_MS — 킵얼라이브 핑 간격 (기본 30,000ms)
  4. IDLE_TIMEOUT_MS — 유휴 연결 타임아웃 (기본 50,000ms)
  5. MAX_CHUNK_SIZE — 최대 청크 크기 (기본 524,288 = 512KB)
  6. AUTH_TOKEN — 프록시 인증 토큰
  7. TLS_CERT_PATH — TLS 인증서 경로 (선택적)
  8. LOG_LEVEL — 로깅 수준 (debug/info/warn/error)

보안 모델

프록시는 여러 보안 계층을 적용합니다:

08

킵얼라이브: 30s 핑 / 50s 유휴 타임아웃

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 타임아웃 전에 프록시 측에서 정리할 수 있는 버퍼를 제공합니다. 핑 간격과 유휴 타임아웃의 비율은 중요합니다 — 핑 간격이 유휴 타임아웃에 너무 가까우면 일시적 네트워크 지연으로 불필요한 연결 종료가 발생합니다.

09

핵심 요약

핵심 포인트

  • 업스트림 프록시는 GKE L7 LB의 512KB 청크 제한, 유휴 타임아웃, WebSocket 업그레이드 지연을 해결합니다
  • 6단계 초기화에서 prctl PR_SET_DUMPABLE(0)이 첫 단계로, 코어 덤프를 통한 토큰 유출을 방지합니다
  • 각 연결은 2단계 상태 머신(handshake → relay)을 따르며, handshake에서 인증 실패 시 즉시 종료됩니다
  • Protobuf는 수작업으로 구현되어 번들 크기를 줄이고 의존성 문제를 피합니다 (메시지 타입 3-4개)
  • Bun과 Node.js 이중 런타임을 지원하며, CCR 환경에서는 Bun이 기본입니다
  • 8개 환경 변수가 프록시 동작을 제어하며, PROXY_PORTUPSTREAM_URL이 필수입니다
  • 킵얼라이브는 30초 핑 + 50초 유휴 타임아웃으로, GKE LB의 60초 기본 타임아웃에 대한 안전 마진을 확보합니다
  • 보안 모델은 코어 덤프 비활성화, 토큰 검증, 선택적 TLS, 청크 크기 제한의 4계층으로 구성됩니다
10

지식 확인

퀴즈 — 5문제

Q1. 업스트림 프록시가 WebSocket 메시지를 512KB 이하로 분할하는 이유는?

  • A) Node.js의 Buffer 크기 제한 때문
  • B) 클라이언트 메모리 절약을 위해
  • C) GKE L7 로드 밸런서(Envoy)가 512KB를 초과하는 단일 프레임에서 연결을 끊기 때문
  • D) Protobuf 인코딩의 최대 메시지 크기 제한 때문
GKE의 Envoy 기반 L7 LB는 512KB를 초과하는 단일 WebSocket 프레임을 처리하지 못하고 연결을 종료합니다. 프록시가 중간에서 큰 메시지를 512KB 이하 청크로 분할하여 이 제한을 우회합니다.

Q2. 초기화의 첫 단계인 prctl PR_SET_DUMPABLE(0)의 목적은?

  • A) 코어 덤프를 비활성화하여 메모리의 인증 토큰이 덤프 파일로 유출되는 것을 방지
  • B) 프로세스의 CPU 우선순위를 높여 성능을 향상
  • C) 다른 프로세스가 이 프로세스에 시그널을 보내는 것을 차단
  • D) 프로세스 메모리 제한을 설정
PR_SET_DUMPABLE(0)은 Linux에서 프로세스의 코어 덤프 생성을 비활성화합니다. CCR 환경에서 프록시 프로세스는 API 토큰과 사용자 데이터를 메모리에 보유하므로, 크래시 시 이 정보가 /tmp/ 등의 덤프 파일로 유출되는 것을 방지합니다.

Q3. Protobuf를 라이브러리 대신 수작업으로 구현한 이유는?

  • A) protobuf-js가 WebSocket을 지원하지 않기 때문
  • B) 성능이 수십 배 더 빠르기 때문
  • C) Node.js에서 protobuf 라이브러리가 동작하지 않기 때문
  • D) 메시지 타입이 3-4개로 적어 수작업이 효율적이고, 번들 크기와 의존성 문제를 줄이기 위해
protobuf-js는 약 200KB의 런타임 코드를 추가하며 .proto 파일 컴파일이 필요합니다. 사용하는 메시지 타입이 3-4개뿐이므로 varint 인코딩과 wire type 처리를 50줄 미만으로 직접 구현하는 것이 번들 크기와 의존성 측면에서 더 효율적입니다.

Q4. 킵얼라이브의 30초 핑 간격과 50초 유휴 타임아웃의 관계는?

  • A) 30초 핑이 실패하면 즉시 연결을 종료하기 위한 설계
  • B) 핑이 GKE LB의 60초 기본 타임아웃 전에 트래픽을 유지하고, 50초 유휴 타임아웃은 핑 실패 시에도 LB 타임아웃 전에 정리할 수 있는 버퍼
  • C) 30초와 50초는 임의의 값이며 특별한 관계가 없음
  • D) 클라이언트 측 타임아웃(30초)과 서버 측 타임아웃(50초)을 분리하기 위한 설계
30초 핑은 GKE LB의 60초 유휴 타임아웃에 대한 안전 마진으로, 활성 연결을 LB가 종료하기 전에 킵얼라이브를 보냅니다. 50초 유휴 타임아웃은 핑이 실패하더라도 LB의 60초 타임아웃 전에 프록시 측에서 자원을 정리할 수 있는 버퍼입니다.

Q5. 2단계 상태 머신에서 handshake 단계의 인증이 실패하면 어떻게 되나요?

  • A) WebSocket 코드 4001과 함께 즉시 연결을 종료
  • B) 클라이언트에 재인증 요청 메시지를 보내고 대기
  • C) relay 상태로 전환되지만 메시지 전달이 차단됨
  • D) 3회 재시도 후 연결을 종료
handshake 단계에서 인증이 실패하면 WebSocket 종료 코드 4001(커스텀 코드)과 함께 즉시 연결을 종료합니다. relay 상태로 전환되지 않으며, 재시도 기회는 주어지지 않습니다. 이는 인증되지 않은 연결이 프록시 자원을 점유하는 것을 방지합니다.
0 / 5