홈서버(kubeadm) + SK브로드밴드 환경에서 Chrome 브라우저 간헐적 404 오류 해결


홈서버 인프라 환경#

1.1 전체 네트워크 아키텍처#

flowchart TB
    subgraph Internet["인터넷"]
        CF["Cloudflare Edge<br/>(서울 PoP)"]
        ISP["SK브로드밴드<br/>공인 IP: 동적<br/>(39.119.192.15)"]
    end

    subgraph HomeNetwork["홈 네트워크 (192.168.45.0/24)"]
        subgraph Router["SK브로드밴드 공유기"]
            NAT["NAT/DHCP"]
            FW_R["방화벽"]
            PF["포트포워딩<br/>:80 → .154:80<br/>:443 → .154:443"]
            DHCP_RES["DHCP 예약<br/>mini-gmk: .123<br/>mini-might: .154"]
        end

        subgraph ControlPlane["mini-gmk (Control Plane)<br/>192.168.45.123"]
            UFW_C["ufw 방화벽<br/>22, 6443, 10250-10252<br/>2379-2380, 179"]
            K8S_CP["Kubernetes Control Plane<br/>kube-apiserver<br/>etcd, scheduler<br/>controller-manager"]
            ISTIOD["istiod (pilot)"]
            CERTM["cert-manager"]
            DDNS["ddns-cloudflare<br/>CronJob"]
            ESO["external-secrets-operator"]
            CALICO_CP["calico-typha<br/>calico-kube-controllers"]
        end

        subgraph Worker["mini-might (Worker)<br/>192.168.45.154"]
            UFW_W["ufw 방화벽<br/>22, 80, 443<br/>10250, 30000-32767"]
            INGRESS["istio-ingressgateway<br/>externalIPs: .154"]
            GW["java-cloud-gateway<br/>:8085"]
            AUTH["auth-guard (x2)<br/>:8080"]
            APPS["order-core :8083<br/>seat :8082<br/>queue :8081"]
            CALICO_W["calico-node"]
        end

        subgraph VPN_Access["팀원 접속"]
            VPN["Tailscale/WireGuard<br/>VPN"]
            SSH["SSH 터널<br/>:22"]
        end
    end

    subgraph Clients["클라이언트"]
        Chrome["Chrome<br/>(QUIC/HTTP3)"]
        Safari["Safari<br/>(HTTP/2)"]
        Firefox["Firefox<br/>(HTTP/2)"]
        Mobile["모바일 LTE"]
        TeamMember["팀원 (VPN)"]
    end

    Chrome -->|"QUIC/UDP:443<br/>❌ 간헐적 실패"| ISP
    Safari -->|"HTTP/2/TCP:443"| ISP
    Firefox -->|"HTTP/2/TCP:443"| ISP
    Mobile -->|"HTTP/2"| ISP

    Chrome -->|"QUIC/UDP:443<br/>✅ 안정적"| CF
    CF -->|"HTTP/2/TCP:443"| ISP

    ISP --> NAT
    NAT --> FW_R
    FW_R --> PF
    PF -->|":443"| UFW_W
    UFW_W --> INGRESS

    INGRESS --> GW
    GW --> AUTH
    AUTH --> APPS

    TeamMember -->|"VPN"| VPN
    VPN --> SSH
    SSH --> UFW_C
    SSH --> UFW_W

    DDNS -->|"API 호출"| CF

    ISTIOD -.->|"xDS"| INGRESS
    CERTM -.->|"TLS 인증서"| INGRESS
    ESO -.->|"Secret 동기화"| DDNS

    classDef problem fill:#ff6b6b,stroke:#c0392b,color:#fff
    classDef solution fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef infrastructure fill:#3498db,stroke:#2980b9,color:#fff

    class Chrome problem
    class CF solution
    class Router,ControlPlane,Worker infrastructure

1.2 SK브로드밴드 공유기 설정#

flowchart LR
    subgraph SKRouter["SK브로드밴드 공유기 설정"]
        direction TB

        subgraph DHCP["DHCP 설정"]
            DHCP_RANGE["DHCP 범위: 192.168.45.100 ~ .200"]
            DHCP_RES1["예약 1: mini-gmk<br/>MAC: XX:XX:XX:XX:XX:XX<br/>IP: 192.168.45.123"]
            DHCP_RES2["예약 2: mini-might<br/>MAC: YY:YY:YY:YY:YY:YY<br/>IP: 192.168.45.154"]
        end

        subgraph PortForward["포트포워딩"]
            PF_HTTP["외부 :80 → 192.168.45.154:80"]
            PF_HTTPS["외부 :443 → 192.168.45.154:443"]
            PF_VPN["외부 :51820 → 192.168.45.123:51820<br/>(WireGuard, 선택)"]
        end

        subgraph Firewall["방화벽 설정"]
            FW_IN["인바운드: 80, 443 허용"]
            FW_OUT["아웃바운드: 전체 허용"]
            FW_ICMP["ICMP: 허용 (ping)"]
        end

        subgraph NAT_Config["NAT 설정"]
            NAT_TYPE["NAT 타입: 대칭형 (Symmetric)"]
            NAT_UDP["UDP 타임아웃: 30초 (문제 원인)"]
            NAT_TCP["TCP 타임아웃: 3600초"]
        end
    end

    style NAT_UDP fill:#ff6b6b,stroke:#c0392b,color:#fff

공유기 설정 상세:

설정 항목비고
공유기 모델SK브로드밴드 기본 제공ipTIME 또는 SK 자체 모델
내부 IP192.168.45.1게이트웨이
DHCP 범위192.168.45.100 ~ .200고정 IP는 범위 외 사용
DNS자동 (SK DNS)168.126.63.1, 168.126.63.2
UDP NAT 타임아웃30초 (추정)QUIC 문제의 원인
TCP NAT 타임아웃3600초HTTP/2는 안정적

1.3 DHCP 예약 (IP 고정)#

# 공유기 관리 페이지에서 DHCP 예약 설정
# 또는 각 노드에서 고정 IP 설정

# mini-gmk (Control Plane)
# /etc/netplan/00-installer-config.yaml (Ubuntu 22.04)
network:
  version: 2
  ethernets:
    enp2s0:
      addresses:
        - 192.168.45.123/24
      routes:
        - to: default
          via: 192.168.45.1
      nameservers:
        addresses:
          - 168.126.63.1
          - 8.8.8.8

# mini-might (Worker)
network:
  version: 2
  ethernets:
    enp3s0:
      addresses:
        - 192.168.45.154/24
      routes:
        - to: default
          via: 192.168.45.1
      nameservers:
        addresses:
          - 168.126.63.1
          - 8.8.8.8

1.4 Linux 방화벽 (ufw) 설정#

flowchart TB
    subgraph UFW_Rules["ufw 방화벽 규칙"]
        direction LR

        subgraph MiniGMK["mini-gmk (Control Plane)"]
            direction TB
            UFW_G1["22/tcp - SSH"]
            UFW_G2["6443/tcp - K8s API Server"]
            UFW_G3["2379-2380/tcp - etcd"]
            UFW_G4["10250-10252/tcp - kubelet, scheduler, controller"]
            UFW_G5["179/tcp - Calico BGP"]
            UFW_G6["4789/udp - Calico VXLAN"]
            UFW_G7["51820/udp - WireGuard VPN"]
        end

        subgraph MiniMight["mini-might (Worker)"]
            direction TB
            UFW_M1["22/tcp - SSH"]
            UFW_M2["80/tcp - HTTP"]
            UFW_M3["443/tcp - HTTPS"]
            UFW_M4["10250/tcp - kubelet"]
            UFW_M5["30000-32767/tcp - NodePort"]
            UFW_M6["4789/udp - Calico VXLAN"]
        end
    end

    style UFW_M2 fill:#2ecc71,stroke:#27ae60,color:#fff
    style UFW_M3 fill:#2ecc71,stroke:#27ae60,color:#fff

mini-gmk (Control Plane) ufw 설정:

# 기본 정책
sudo ufw default deny incoming
sudo ufw default allow outgoing

# SSH
sudo ufw allow 22/tcp comment 'SSH'

# Kubernetes Control Plane
sudo ufw allow 6443/tcp comment 'K8s API Server'
sudo ufw allow 2379:2380/tcp comment 'etcd'
sudo ufw allow 10250/tcp comment 'kubelet'
sudo ufw allow 10251/tcp comment 'kube-scheduler'
sudo ufw allow 10252/tcp comment 'kube-controller-manager'

# Calico CNI
sudo ufw allow 179/tcp comment 'Calico BGP'
sudo ufw allow 4789/udp comment 'Calico VXLAN'
sudo ufw allow from 192.168.45.0/24 comment 'Internal network'

# VPN (선택)
sudo ufw allow 51820/udp comment 'WireGuard'

# 활성화
sudo ufw enable

mini-might (Worker) ufw 설정:

# 기본 정책
sudo ufw default deny incoming
sudo ufw default allow outgoing

# SSH
sudo ufw allow 22/tcp comment 'SSH'

# Web Traffic (Istio IngressGateway)
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'

# Kubernetes Worker
sudo ufw allow 10250/tcp comment 'kubelet'
sudo ufw allow 30000:32767/tcp comment 'NodePort Services'

# Calico CNI
sudo ufw allow 4789/udp comment 'Calico VXLAN'
sudo ufw allow from 192.168.45.0/24 comment 'Internal network'

# 활성화
sudo ufw enable

1.5 팀원 VPN/SSH 접속 구조#

sequenceDiagram
    participant TM as 팀원
    participant TS as Tailscale/WireGuard<br/>VPN 서버
    participant GMK as mini-gmk<br/>192.168.45.123
    participant MIGHT as mini-might<br/>192.168.45.154
    participant K8S as Kubernetes<br/>API Server

    Note over TM,K8S: VPN 연결 및 SSH 접속 흐름

    TM->>TS: VPN 연결 요청
    TS-->>TM: VPN 터널 수립<br/>(10.x.x.x 대역)

    TM->>GMK: SSH 접속 (VPN 터널 경유)<br/>ssh admin@192.168.45.123
    GMK-->>TM: SSH 세션 수립

    TM->>GMK: kubectl 명령어
    GMK->>K8S: API 요청 (:6443)
    K8S-->>GMK: API 응답
    GMK-->>TM: 결과 반환

    Note over TM,K8S: k9s, helm, argocd CLI 등 사용

    TM->>MIGHT: SSH 접속 (필요시)<br/>ssh admin@192.168.45.154
    MIGHT-->>TM: SSH 세션 수립

Tailscale 설정 (권장):

# 각 노드에 Tailscale 설치
curl -fsSL https://tailscale.com/install.sh | sh

# 로그인 (각 노드에서)
sudo tailscale up --ssh

# 팀원은 자신의 PC에서
tailscale up
ssh admin@mini-gmk  # Tailscale MagicDNS 사용

문제 상황#

2.1 증상#

flowchart LR
    subgraph TestResults["테스트 결과"]
        direction TB

        subgraph Success["✅ 정상"]
            CURL["curl (CLI)"]
            SAFARI["Safari"]
            FIREFOX["Firefox"]
            MOBILE["모바일 LTE"]
            INCOGNITO["Chrome 시크릿"]
        end

        subgraph Fail["❌ 간헐적 404"]
            CHROME["Chrome (일반)"]
            CHROME_TEAM["팀원들 Chrome"]
        end
    end

    CURL -->|"HTTP/2"| OK1["200 OK"]
    SAFARI -->|"HTTP/2"| OK2["200 OK"]
    FIREFOX -->|"HTTP/2"| OK3["200 OK"]
    MOBILE -->|"HTTP/2"| OK4["200 OK"]
    INCOGNITO -->|"QUIC 시도"| RANDOM1["랜덤 404"]

    CHROME -->|"QUIC/HTTP3"| RANDOM2["랜덤 404"]
    CHROME_TEAM -->|"QUIC/HTTP3"| RANDOM3["랜덤 404"]

    style CHROME fill:#ff6b6b,stroke:#c0392b,color:#fff
    style CHROME_TEAM fill:#ff6b6b,stroke:#c0392b,color:#fff
    style RANDOM2 fill:#ff6b6b,stroke:#c0392b,color:#fff
    style RANDOM3 fill:#ff6b6b,stroke:#c0392b,color:#fff
테스트결과프로토콜
curl -I https://api.dev.goormgb.space/swagger-ui/index.html200 OKHTTP/2
Safari정상HTTP/2
Firefox정상HTTP/2
모바일 LTE 네트워크정상HTTP/2
Chrome (같은 네트워크)간헐적 404QUIC/HTTP3
팀원들 Chrome동일 증상QUIC/HTTP3

2.2 영향 범위#

# 영향받는 도메인 (모두 홈서버로 연결)
- api.dev.goormgb.space    # 백엔드 API (Swagger UI)
- argocd.goormgb.space     # GitOps
- grafana.goormgb.space    # 모니터링
- kiali.goormgb.space      # Service Mesh 시각화
- cloudbeaver.goormgb.space # DB Admin

2.3 증상 상세#

sequenceDiagram
    participant User as 사용자
    participant Chrome as Chrome
    participant Router as SK 공유기
    participant GW as IngressGateway

    Note over User,GW: F5 연타 시 랜덤 404 발생

    User->>Chrome: F5 (새로고침 1)
    Chrome->>Router: QUIC/UDP:443
    Router->>GW: 포워딩
    GW-->>Router: 200 OK
    Router-->>Chrome: 200 OK
    Chrome-->>User: ✅ 정상

    User->>Chrome: F5 (새로고침 2)
    Chrome->>Router: QUIC/UDP:443
    Note over Router: UDP NAT 상태 불안정
    Router--xChrome: 타임아웃/드롭
    Chrome->>Chrome: HTTP/2 fallback 시도
    Note over Chrome: 연결 상태 혼란
    Chrome-->>User: ❌ 404 Not Found

    User->>Chrome: F5 (새로고침 3)
    Chrome->>Router: HTTP/2/TCP:443
    Router->>GW: 포워딩
    GW-->>Router: 200 OK
    Router-->>Chrome: 200 OK
    Chrome-->>User: ✅ 정상

    User->>Chrome: F5 (새로고침 4)
    Chrome->>Router: QUIC/UDP:443 (재시도)
    Router--xChrome: 또 실패
    Chrome-->>User: ❌ 404 Not Found

원인 분석#

3.1 Chrome QUIC/HTTP3 동작 방식#

flowchart TB
    subgraph Chrome["Chrome 브라우저"]
        direction TB
        REQ["요청 시작"]
        ALT_SVC["Alt-Svc 헤더 확인"]
        QUIC_TRY["QUIC 연결 시도<br/>(UDP:443)"]
        H2_FALLBACK["HTTP/2 Fallback<br/>(TCP:443)"]
        POOL["Connection Pool"]
    end

    subgraph HomeRouter["SK브로드밴드 공유기"]
        direction TB
        NAT_UDP["UDP NAT 테이블"]
        NAT_TCP["TCP NAT 테이블"]
        UDP_TIMEOUT["UDP 타임아웃: 30초"]
        TCP_TIMEOUT["TCP 타임아웃: 3600초"]
    end

    REQ --> ALT_SVC
    ALT_SVC -->|"h3=':443' 발견"| QUIC_TRY
    QUIC_TRY -->|"UDP:443"| NAT_UDP
    NAT_UDP --> UDP_TIMEOUT
    UDP_TIMEOUT -->|"30초 후 만료"| QUIC_TRY
    QUIC_TRY -->|"실패"| H2_FALLBACK
    H2_FALLBACK --> NAT_TCP
    NAT_TCP --> TCP_TIMEOUT

    QUIC_TRY -->|"성공 시"| POOL
    H2_FALLBACK -->|"성공 시"| POOL
    POOL -->|"재사용"| REQ

    style UDP_TIMEOUT fill:#ff6b6b,stroke:#c0392b,color:#fff
    style QUIC_TRY fill:#f39c12,stroke:#d68910,color:#fff

3.2 문제의 근원: UDP NAT 타임아웃#

flowchart LR
    subgraph Problem["문제 발생 지점"]
        direction TB

        subgraph QUIC_Conn["QUIC 연결"]
            Q1["1. Chrome이 QUIC 연결 수립"]
            Q2["2. SK 공유기 UDP NAT 테이블에 등록"]
            Q3["3. 30초간 트래픽 없음"]
            Q4["4. NAT 테이블에서 항목 삭제"]
            Q5["5. 다음 QUIC 패킷 드롭"]
            Q6["6. Chrome 혼란 → 404"]
        end

        Q1 --> Q2 --> Q3 --> Q4 --> Q5 --> Q6
    end

    subgraph Compare["TCP vs UDP 비교"]
        direction TB
        TCP["TCP (HTTP/2)<br/>타임아웃: 3600초<br/>Keep-Alive 지원<br/>상태 추적 안정적"]
        UDP["UDP (QUIC)<br/>타임아웃: 30초<br/>연결 상태 없음<br/>NAT 통과 불안정"]
    end

    style Q4 fill:#ff6b6b,stroke:#c0392b,color:#fff
    style Q5 fill:#ff6b6b,stroke:#c0392b,color:#fff
    style Q6 fill:#ff6b6b,stroke:#c0392b,color:#fff
    style UDP fill:#ff6b6b,stroke:#c0392b,color:#fff
    style TCP fill:#2ecc71,stroke:#27ae60,color:#fff

3.3 SK브로드밴드 환경 특수성#

flowchart TB
    subgraph ISP_Issues["SK브로드밴드 환경 이슈"]
        direction TB

        ISP1["1. 대칭형 NAT (Symmetric NAT)<br/>QUIC에 불리한 NAT 유형"]
        ISP2["2. UDP 포트 필터링<br/>일부 ISP는 UDP:443 제한"]
        ISP3["3. 짧은 UDP 세션 타임아웃<br/>가정용 공유기 기본값: 30초"]
        ISP4["4. 동적 공인 IP<br/>IP 변경 시 연결 끊김"]
    end

    subgraph HomeLab_Issues["홈랩 환경 이슈"]
        direction TB

        HL1["1. 이중 NAT 가능성<br/>공유기 + 모뎀"]
        HL2["2. 포트포워딩 한계<br/>UDP 상태 추적 어려움"]
        HL3["3. 방화벽 설정<br/>ufw에서 UDP 처리"]
        HL4["4. externalIPs 사용<br/>LoadBalancer 없음"]
    end

    ISP1 --> ISP2 --> ISP3 --> ISP4
    HL1 --> HL2 --> HL3 --> HL4

    ISP3 -.->|"조합"| PROBLEM["QUIC 연결 불안정"]
    HL2 -.->|"조합"| PROBLEM

    style PROBLEM fill:#ff6b6b,stroke:#c0392b,color:#fff

3.4 Istio IngressGateway와 QUIC#

flowchart TB
    subgraph Istio["Istio IngressGateway"]
        direction TB

        SVC["Service: istio-ingressgateway<br/>type: ClusterIP<br/>externalIPs: [192.168.45.154]"]
        ENVOY["Envoy Proxy<br/>HTTP/2, QUIC 지원"]
        GW_RES["Gateway Resource<br/>TLS Termination"]
        VS["VirtualService<br/>라우팅 규칙"]
    end

    subgraph TLS_Config["TLS 설정"]
        CERT["Let's Encrypt<br/>Wildcard 인증서"]
        SECRET["Secret: goormgb-tls<br/>*.goormgb.space<br/>*.dev.goormgb.space"]
    end

    subgraph Problem["문제 지점"]
        EXT_IP["externalIPs 바인딩<br/>UDP 트래픽도 수신"]
        UDP_FW["ufw에서 443/udp 열림?<br/>→ 열려 있어도 NAT 문제"]
        QUIC_HANDSHAKE["QUIC 핸드셰이크<br/>NAT 타임아웃 전 완료 필요"]
    end

    SVC --> ENVOY --> GW_RES --> VS
    CERT --> SECRET --> GW_RES
    EXT_IP -.-> UDP_FW -.-> QUIC_HANDSHAKE

    style QUIC_HANDSHAKE fill:#f39c12,stroke:#d68910,color:#fff

해결#

4.1 가능한 해결책들#

flowchart TB
    subgraph Solutions["해결 방안"]
        direction TB

        subgraph Client["클라이언트 측 (비추천)"]
            C1["Chrome QUIC 비활성화<br/>chrome://flags/#enable-quic"]
            C2["DoH 비활성화<br/>chrome://flags/#dns-over-https"]
        end

        subgraph Network["네트워크 측 (어려움)"]
            N1["공유기 교체<br/>UDP NAT 타임아웃 늘리기"]
            N2["ISP 변경<br/>비현실적"]
        end

        subgraph Server["서버 측 (채택)"]
            S1["Cloudflare Proxy<br/>QUIC → HTTP/2 변환"]
            S2["QUIC 비활성화<br/>서버에서 Alt-Svc 제거"]
        end
    end

    C1 -->|"팀원 전체 설정 필요"| BAD1["❌ 비추천"]
    C2 -->|"업데이트 시 초기화"| BAD2["❌ 비추천"]
    N1 -->|"비용 + 설정 복잡"| BAD3["△ 차선책"]
    N2 -->|"비현실적"| BAD4["❌ 불가"]
    S1 -->|"무료 + 추가 혜택"| GOOD1["✅ 채택"]
    S2 -->|"QUIC 장점 포기"| BAD5["△ 차선책"]

    style GOOD1 fill:#2ecc71,stroke:#27ae60,color:#fff
    style BAD1 fill:#e74c3c,stroke:#c0392b,color:#fff
    style BAD2 fill:#e74c3c,stroke:#c0392b,color:#fff
    style BAD4 fill:#e74c3c,stroke:#c0392b,color:#fff

4.2 Cloudflare Proxy 선택 이유#

flowchart LR
    subgraph Before["Before: 직접 연결"]
        B_Chrome["Chrome"]
        B_ISP["SK브로드밴드"]
        B_Router["공유기<br/>UDP NAT 불안정"]
        B_Ingress["IngressGateway"]

        B_Chrome -->|"QUIC/UDP"| B_ISP
        B_ISP -->|"UDP"| B_Router
        B_Router -->|"❌ 간헐적 드롭"| B_Ingress
    end

    subgraph After["After: Cloudflare Proxy"]
        A_Chrome["Chrome"]
        A_CF["Cloudflare Edge<br/>(서울 PoP)"]
        A_ISP["SK브로드밴드"]
        A_Router["공유기<br/>TCP 안정적"]
        A_Ingress["IngressGateway"]

        A_Chrome -->|"QUIC/UDP"| A_CF
        A_CF -->|"HTTP/2/TCP"| A_ISP
        A_ISP -->|"TCP"| A_Router
        A_Router -->|"✅ 안정적"| A_Ingress
    end

    Before -.->|"마이그레이션"| After

    style B_Router fill:#ff6b6b,stroke:#c0392b,color:#fff
    style A_CF fill:#2ecc71,stroke:#27ae60,color:#fff
    style A_Router fill:#2ecc71,stroke:#27ae60,color:#fff

Cloudflare 장점:

항목설명
QUIC 처리Edge에서 QUIC ↔ HTTP/2 변환
연결 안정성Origin까지 TCP 연결 (NAT 친화적)
클라이언트 설정변경 불필요
DDoS 보호기본 제공
글로벌 CDN서울 PoP 활용
SSL 인증서Universal SSL 자동 관리
비용무료 플랜으로 충분

Cloudflare 마이그레이션#

5.1 전체 마이그레이션 흐름#

flowchart TB
    subgraph Step1["Step 1: Cloudflare 설정"]
        CF_SIGNUP["Cloudflare 가입"]
        CF_DOMAIN["도메인 추가<br/>goormgb.space"]
        CF_RECORDS["DNS 레코드 설정"]
        CF_SSL["SSL/TLS 설정<br/>Full (strict)"]
    end

    subgraph Step2["Step 2: 네임서버 변경"]
        PORKBUN["Porkbun (레지스트라)"]
        NS_CHANGE["네임서버 변경<br/>Route53 → Cloudflare"]
        NS_VERIFY["전파 확인<br/>(최대 48시간)"]
    end

    subgraph Step3["Step 3: DDNS 마이그레이션"]
        DDNS_OLD["ddns-route53<br/>(삭제)"]
        DDNS_NEW["ddns-cloudflare<br/>(신규)"]
        SECRET_NEW["ExternalSecret<br/>Cloudflare API Token"]
    end

    subgraph Step4["Step 4: 검증"]
        DNS_CHECK["DNS 전파 확인"]
        CF_HEADER["Cloudflare 헤더 확인"]
        BROWSER_TEST["Chrome 테스트"]
    end

    CF_SIGNUP --> CF_DOMAIN --> CF_RECORDS --> CF_SSL
    CF_SSL --> PORKBUN --> NS_CHANGE --> NS_VERIFY
    NS_VERIFY --> DDNS_OLD --> DDNS_NEW --> SECRET_NEW
    SECRET_NEW --> DNS_CHECK --> CF_HEADER --> BROWSER_TEST

    style DDNS_OLD fill:#e74c3c,stroke:#c0392b,color:#fff
    style DDNS_NEW fill:#2ecc71,stroke:#27ae60,color:#fff

5.2 Cloudflare DNS 레코드 설정#

flowchart LR
    subgraph CloudflareDNS["Cloudflare DNS 레코드"]
        direction TB

        subgraph Proxied["Proxied (주황색 구름)"]
            A1["A | @ | 39.119.192.15"]
            A2["A | api.dev | 39.119.192.15"]
            A3["A | argocd | 39.119.192.15"]
            A4["A | grafana | 39.119.192.15"]
            A5["A | kiali | 39.119.192.15"]
            A6["A | cloudbeaver | 39.119.192.15"]
        end

        subgraph DNSOnly["DNS Only (회색 구름)"]
            CNAME1["CNAME | dev | cname.vercel-dns.com"]
        end
    end

    A1 --> CF_EDGE["Cloudflare Edge<br/>QUIC → HTTP/2"]
    A2 --> CF_EDGE
    A3 --> CF_EDGE
    A4 --> CF_EDGE
    A5 --> CF_EDGE
    A6 --> CF_EDGE

    CF_EDGE --> HOME["홈서버<br/>39.119.192.15"]

    CNAME1 --> VERCEL["Vercel<br/>(프론트엔드)"]

    style Proxied fill:#f39c12,stroke:#d68910,color:#fff
    style DNSOnly fill:#95a5a6,stroke:#7f8c8d,color:#fff
TypeNameContentProxyTTL용도
A@39.119.192.15ProxiedAuto루트 도메인
Aapi.dev39.119.192.15ProxiedAuto백엔드 API
Aargocd39.119.192.15ProxiedAutoGitOps
Agrafana39.119.192.15ProxiedAuto모니터링
Akiali39.119.192.15ProxiedAutoService Mesh
Acloudbeaver39.119.192.15ProxiedAutoDB Admin
CNAMEdevcname.vercel-dns.comDNS onlyAuto프론트엔드

중요: dev 서브도메인은 Vercel에서 호스팅하므로 DNS only (회색 구름)

5.3 SSL/TLS 설정#

flowchart LR
    subgraph CloudflareSSL["Cloudflare SSL/TLS"]
        direction TB

        MODE["Encryption Mode:<br/>Full (strict)"]
        EDGE_CERT["Edge Certificate:<br/>Cloudflare Universal SSL"]
        ORIGIN_CERT["Origin Certificate:<br/>Let's Encrypt (기존 유지)"]

        SETTINGS["Edge Certificate Settings:"]
        S1["Always Use HTTPS: On"]
        S2["Minimum TLS: 1.2"]
        S3["TLS 1.3: Enabled"]
        S4["Automatic HTTPS Rewrites: On"]
    end

    CLIENT["클라이언트"]
    CF_EDGE["Cloudflare Edge"]
    ORIGIN["홈서버 (Origin)"]

    CLIENT -->|"HTTPS<br/>Cloudflare SSL"| CF_EDGE
    CF_EDGE -->|"HTTPS<br/>Let's Encrypt"| ORIGIN

    style MODE fill:#2ecc71,stroke:#27ae60,color:#fff

Full (strict) 모드 요구사항:

  • Origin 서버에 유효한 SSL 인증서 필요
  • 우리는 Let’s Encrypt wildcard 인증서 사용 중 → 충족

5.4 네임서버 변경#

sequenceDiagram
    participant User as 관리자
    participant Porkbun as Porkbun<br/>(레지스트라)
    participant R53 as Route53<br/>(기존)
    participant CF as Cloudflare<br/>(신규)

    Note over User,CF: 네임서버 마이그레이션

    User->>CF: 1. 도메인 추가 요청
    CF-->>User: 2. 네임서버 정보 제공<br/>jacob.ns.cloudflare.com<br/>rita.ns.cloudflare.com

    User->>Porkbun: 3. 네임서버 변경
    Note over Porkbun: Route53 NS → Cloudflare NS

    Porkbun->>CF: 4. NS 전파 시작
    Note over CF: 최대 48시간 소요<br/>(보통 1-2시간)

    CF-->>User: 5. 도메인 활성화 완료

    Note over R53: Route53 Hosted Zone<br/>더 이상 사용 안 함<br/>(cert-manager 백업용 유지)

Porkbun에서 네임서버 변경:

# 기존 (Route53)
ns-xxx.awsdns-xx.com
ns-xxx.awsdns-xx.net
ns-xxx.awsdns-xx.org
ns-xxx.awsdns-xx.co.uk

# 신규 (Cloudflare)
jacob.ns.cloudflare.com
rita.ns.cloudflare.com

5.5 DDNS Helm Chart 마이그레이션#

flowchart TB
    subgraph Before["Before: Route53 DDNS"]
        direction TB
        CHART_OLD["dev/charts/core/ddns-route53/"]
        IMAGE_OLD["amazon/aws-cli:2.15.0"]
        SECRET_OLD["route53-ddns-credentials<br/>(AWS IAM)"]
        API_OLD["aws route53 change-resource-record-sets"]
    end

    subgraph After["After: Cloudflare DDNS"]
        direction TB
        CHART_NEW["dev/charts/core/ddns/"]
        IMAGE_NEW["curlimages/curl:8.5.0"]
        SECRET_NEW["cloudflare-ddns-credentials<br/>(ExternalSecret)"]
        API_NEW["Cloudflare API<br/>PATCH /zones/{zone_id}/dns_records"]
    end

    CHART_OLD -->|"삭제"| REMOVED["삭제됨"]
    CHART_NEW -->|"신규 생성"| ACTIVE["활성화"]

    style CHART_OLD fill:#e74c3c,stroke:#c0392b,color:#fff
    style REMOVED fill:#e74c3c,stroke:#c0392b,color:#fff
    style CHART_NEW fill:#2ecc71,stroke:#27ae60,color:#fff
    style ACTIVE fill:#2ecc71,stroke:#27ae60,color:#fff

values-ddns.yaml#

# 303-goormgb-k8s-helm/dev/values/core/values-ddns.yaml

domain: "goormgb.space"
zoneID: "f9fdc81712963738bc9d5ee628849e53"

records:
  - ""          # goormgb.space (루트)
  - "api.dev"   # api.dev.goormgb.space
  - "argocd"    # argocd.goormgb.space
  - "grafana"   # grafana.goormgb.space
  - "kiali"     # kiali.goormgb.space
  - "cloudbeaver"  # cloudbeaver.goormgb.space

ttl: 60         # 빠른 IP 변경 반영
schedule: "*/5 * * * *"  # 5분마다 체크

secret:
  name: cloudflare-ddns-credentials
  awsSecretKey: dev/infra/cloudflare  # AWS Secrets Manager 경로

image:
  repository: curlimages/curl
  tag: "8.5.0"

CronJob Template#

# 303-goormgb-k8s-helm/dev/charts/core/ddns/templates/cronjob.yaml

apiVersion: batch/v1
kind: CronJob
metadata:
  name: ddns-cloudflare-updater
  namespace: {{ .Release.Namespace }}
  labels:
    app.kubernetes.io/name: ddns-cloudflare
spec:
  schedule: {{ .Values.schedule | quote }}
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          serviceAccountName: default
          containers:
            - name: ddns-updater
              image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
              env:
                - name: CF_API_TOKEN
                  valueFrom:
                    secretKeyRef:
                      name: {{ .Values.secret.name }}
                      key: api-token
                - name: CF_ZONE_ID
                  value: {{ .Values.zoneID | quote }}
                - name: DOMAIN
                  value: {{ .Values.domain | quote }}
                - name: TTL
                  value: {{ .Values.ttl | quote }}
              command:
                - /bin/sh
                - -c
                - |
                  set -e

                  echo "=========================================="
                  echo "DDNS Cloudflare Updater"
                  echo "Started at: $(date -Iseconds)"
                  echo "=========================================="

                  # 1. 현재 공인 IP 조회 (폴백 포함)
                  echo "[Step 1] Getting current public IP..."
                  CURRENT_IP=$(curl -sf --max-time 10 https://api.ipify.org || \
                               curl -sf --max-time 10 https://checkip.amazonaws.com || \
                               curl -sf --max-time 10 https://ifconfig.me)

                  if [ -z "$CURRENT_IP" ]; then
                    echo "ERROR: Failed to get public IP from all sources"
                    exit 1
                  fi
                  echo "Current Public IP: ${CURRENT_IP}"
                  echo ""

                  # 2. 각 레코드 업데이트
                  echo "[Step 2] Processing DNS records..."
                  {{- range .Values.records }}
                  RECORD="{{ . }}"
                  if [ -z "$RECORD" ]; then
                    FULL_NAME="${DOMAIN}"
                  else
                    FULL_NAME="${RECORD}.${DOMAIN}"
                  fi

                  echo "----------------------------------------"
                  echo "Processing: ${FULL_NAME}"

                  # 2-1. Cloudflare에서 현재 레코드 조회
                  RESPONSE=$(curl -sf -X GET \
                    "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records?type=A&name=${FULL_NAME}" \
                    -H "Authorization: Bearer ${CF_API_TOKEN}" \
                    -H "Content-Type: application/json")

                  RECORD_ID=$(echo "$RESPONSE" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
                  CURRENT_CONTENT=$(echo "$RESPONSE" | grep -o '"content":"[^"]*"' | head -1 | cut -d'"' -f4)
                  PROXIED=$(echo "$RESPONSE" | grep -o '"proxied":[^,]*' | head -1 | cut -d':' -f2)

                  if [ -z "$RECORD_ID" ]; then
                    echo "  WARNING: Record not found for ${FULL_NAME}, skipping..."
                    continue
                  fi

                  echo "  Record ID: ${RECORD_ID}"
                  echo "  Current IP in Cloudflare: ${CURRENT_CONTENT}"
                  echo "  Proxied: ${PROXIED}"

                  # 2-2. IP 변경 여부 확인
                  if [ "$CURRENT_CONTENT" = "$CURRENT_IP" ]; then
                    echo "  Status: UNCHANGED (no update needed)"
                    continue
                  fi

                  # 2-3. IP 업데이트
                  echo "  Updating: ${CURRENT_CONTENT} → ${CURRENT_IP}"
                  UPDATE_RESULT=$(curl -sf -X PATCH \
                    "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records/${RECORD_ID}" \
                    -H "Authorization: Bearer ${CF_API_TOKEN}" \
                    -H "Content-Type: application/json" \
                    --data "{\"content\":\"${CURRENT_IP}\",\"ttl\":${TTL},\"proxied\":true}")

                  if echo "$UPDATE_RESULT" | grep -q '"success":true'; then
                    echo "  Status: UPDATED successfully"
                  else
                    echo "  Status: FAILED"
                    echo "  Response: $UPDATE_RESULT"
                  fi
                  {{- end }}

                  echo ""
                  echo "=========================================="
                  echo "DDNS update completed at $(date -Iseconds)"
                  echo "=========================================="

ExternalSecret Template#

# 303-goormgb-k8s-helm/dev/charts/core/ddns/templates/external-secret.yaml

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: cloudflare-ddns-credentials
  namespace: {{ .Release.Namespace }}
  labels:
    app.kubernetes.io/name: ddns-cloudflare
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: aws-secrets-manager
  target:
    name: {{ .Values.secret.name }}
    creationPolicy: Owner
  data:
    - secretKey: api-token
      remoteRef:
        key: {{ .Values.secret.awsSecretKey }}
        property: api-token

5.6 AWS Secrets Manager 설정#

flowchart LR
    subgraph AWS["AWS Secrets Manager"]
        SECRET["dev/infra/cloudflare"]
        JSON["{<br/>  'api-token': 'cf_xxx...'<br/>}"]
    end

    subgraph K8s["Kubernetes"]
        ESO["External Secrets Operator"]
        K8S_SECRET["Secret:<br/>cloudflare-ddns-credentials"]
        CRONJOB["CronJob:<br/>ddns-cloudflare-updater"]
    end

    SECRET --> JSON
    ESO -->|"1h 주기 동기화"| SECRET
    ESO --> K8S_SECRET
    CRONJOB -->|"env: CF_API_TOKEN"| K8S_SECRET

    style SECRET fill:#f39c12,stroke:#d68910,color:#fff
# AWS Secrets Manager에 Cloudflare API Token 저장
aws secretsmanager create-secret \
  --name dev/infra/cloudflare \
  --secret-string '{"api-token":"YOUR_CLOUDFLARE_API_TOKEN"}' \
  --region ap-northeast-2

# Cloudflare API Token 권한:
# - Zone > DNS > Edit
# - Zone > Zone > Read
# - 적용 범위: goormgb.space 도메인만

6. 전체 트래픽 흐름 (마이그레이션 후)#

6.1 요청 흐름 상세#

sequenceDiagram
    participant Chrome as Chrome<br/>(클라이언트)
    participant CF_Edge as Cloudflare Edge<br/>(서울 PoP)
    participant ISP as SK브로드밴드<br/>(공인 IP)
    participant Router as 홈 공유기<br/>(NAT)
    participant UFW as ufw 방화벽<br/>(mini-might)
    participant Ingress as IngressGateway<br/>(Istio)
    participant GW as java-cloud-gateway<br/>(:8085)
    participant Backend as 백엔드 서비스

    Note over Chrome,Backend: https://api.dev.goormgb.space/swagger-ui/index.html

    Chrome->>CF_Edge: 1. QUIC/HTTP3 요청<br/>(UDP:443)
    Note over CF_Edge: QUIC 연결 종료<br/>HTTP/2로 변환

    CF_Edge->>ISP: 2. HTTP/2 요청<br/>(TCP:443)
    Note over ISP: TCP 연결<br/>(안정적)

    ISP->>Router: 3. 포트포워딩<br/>:443 → 192.168.45.154:443
    Note over Router: TCP NAT 테이블<br/>(타임아웃 3600초)

    Router->>UFW: 4. 방화벽 통과<br/>(443/tcp 허용)
    UFW->>Ingress: 5. Envoy Proxy로 전달

    Note over Ingress: TLS Termination<br/>(Let's Encrypt)

    Ingress->>GW: 6. VirtualService 라우팅<br/>→ java-cloud-gateway:8085
    GW->>Backend: 7. 내부 라우팅<br/>→ swagger-ui 서비스

    Backend-->>GW: 8. 응답 (200 OK)
    GW-->>Ingress: 9. 응답 전달
    Ingress-->>Router: 10. HTTPS 응답
    Router-->>ISP: 11. NAT 역변환
    ISP-->>CF_Edge: 12. HTTP/2 응답
    CF_Edge-->>Chrome: 13. QUIC/HTTP3 응답<br/>(200 OK)

    Note over Chrome,Backend: ✅ 모든 요청 안정적으로 처리

6.2 DDNS 업데이트 흐름#

sequenceDiagram
    participant Cron as CronJob<br/>(mini-gmk)
    participant IPify as api.ipify.org
    participant CF_API as Cloudflare API
    participant CF_DNS as Cloudflare DNS

    Note over Cron,CF_DNS: 5분마다 실행 (*/5 * * * *)

    Cron->>IPify: 1. GET / (공인 IP 조회)
    IPify-->>Cron: 39.119.192.15

    loop 각 레코드에 대해
        Cron->>CF_API: 2. GET /zones/{zone}/dns_records<br/>?name=api.dev.goormgb.space
        CF_API-->>Cron: record_id, current_ip

        alt IP 변경됨
            Cron->>CF_API: 3. PATCH /dns_records/{id}<br/>{"content": "39.119.192.15", "proxied": true}
            CF_API-->>Cron: {"success": true}
            CF_API->>CF_DNS: DNS 레코드 업데이트
            Note over Cron: "api.dev: updated 1.2.3.4 → 39.119.192.15"
        else IP 동일
            Note over Cron: "api.dev: unchanged (39.119.192.15)"
        end
    end

    Note over Cron,CF_DNS: TTL: 60초로 빠른 전파

7. 검증#

7.1 DNS 전파 확인#

# Cloudflare 네임서버 확인
dig NS goormgb.space +short
# 예상 결과:
# jacob.ns.cloudflare.com.
# rita.ns.cloudflare.com.

# A 레코드 확인 - Cloudflare Anycast IP 반환
dig api.dev.goormgb.space +short
# 예상 결과 (원본 IP가 아닌 Cloudflare IP):
# 104.21.x.x
# 172.67.x.x

7.2 Cloudflare 헤더 확인#

curl -I https://api.dev.goormgb.space/swagger-ui/index.html

# 예상 응답:
HTTP/2 200
date: Mon, 17 Mar 2026 10:00:00 GMT
content-type: text/html
server: cloudflare
cf-ray: 8xxxxx-ICN              # ICN = 서울 Edge
cf-cache-status: DYNAMIC
alt-svc: h3=":443"; ma=86400    # HTTP/3 지원 (CF↔클라이언트)

7.3 DDNS CronJob 확인#

# CronJob 상태
kubectl get cronjob -n core
# NAME                      SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE
# ddns-cloudflare-updater   */5 * * * *   False     0        2m

# 최근 Job 로그
kubectl logs -n core -l app.kubernetes.io/name=ddns-cloudflare --tail=50

# 예상 출력:
# ==========================================
# DDNS Cloudflare Updater
# Started at: 2026-03-17T10:05:00+09:00
# ==========================================
# [Step 1] Getting current public IP...
# Current Public IP: 39.119.192.15
#
# [Step 2] Processing DNS records...
# ----------------------------------------
# Processing: goormgb.space
#   Record ID: abc123...
#   Current IP in Cloudflare: 39.119.192.15
#   Proxied: true
#   Status: UNCHANGED (no update needed)
# ----------------------------------------
# Processing: api.dev.goormgb.space
#   Record ID: def456...
#   Current IP in Cloudflare: 39.119.192.15
#   Proxied: true
#   Status: UNCHANGED (no update needed)
# ...
# ==========================================
# DDNS update completed at 2026-03-17T10:05:03+09:00
# ==========================================

# 수동 실행 테스트
kubectl create job ddns-test --from=cronjob/ddns-cloudflare-updater -n core
kubectl logs -n core job/ddns-test -f
kubectl delete job ddns-test -n core

7.4 Chrome 테스트#

flowchart TB
    subgraph TestSteps["Chrome 테스트 단계"]
        direction TB

        STEP1["1. 캐시 초기화<br/>chrome://net-internals/#dns<br/>→ Clear host cache"]
        STEP2["2. 소켓 초기화<br/>chrome://net-internals/#sockets<br/>→ Close idle sockets"]
        STEP3["3. 접속 테스트<br/>https://api.dev.goormgb.space/swagger-ui/index.html"]
        STEP4["4. F5 연타 테스트<br/>(10회 이상)"]
        STEP5["5. 프로토콜 확인<br/>DevTools → Network → Protocol"]
    end

    STEP1 --> STEP2 --> STEP3 --> STEP4 --> STEP5

    STEP5 --> RESULT["결과 확인"]
    RESULT --> H2["h2 (HTTP/2): CF↔Origin"]
    RESULT --> H3["h3 (HTTP/3): Client↔CF"]

    style RESULT fill:#2ecc71,stroke:#27ae60,color:#fff

예상 결과:

  • 모든 요청 200 OK
  • Protocol 컬럼: h2 또는 h3
  • cf-ray 헤더에 ICN (서울) 표시

8. 삭제된 리소스#

flowchart LR
    subgraph Deleted["삭제된 리소스"]
        D1["ddns-route53 Helm Chart"]
        D2["ddns-route53 ArgoCD App"]
        D3["route53-ddns-credentials Secret"]
    end

    subgraph Kept["유지된 리소스"]
        K1["Route53 Hosted Zone<br/>(cert-manager 백업용)"]
    end

    D1 -->|"rm -rf"| REMOVED["제거됨"]
    D2 -->|"kubectl delete app"| REMOVED
    D3 -->|"ESO로 대체"| REMOVED

    K1 --> BACKUP["백업 유지"]

    style Deleted fill:#e74c3c,stroke:#c0392b,color:#fff
    style REMOVED fill:#e74c3c,stroke:#c0392b,color:#fff
    style Kept fill:#f39c12,stroke:#d68910,color:#fff
리소스상태비고
ddns-route53 Helm Chart삭제dev/charts/core/ddns-route53/
ddns-route53 ArgoCD App삭제kubectl delete app ddns-route53 -n argocd
route53-ddns-credentials삭제ESO로 대체
Route53 Hosted Zone유지cert-manager DNS-01 백업용

9. 핵심 포인트 요약#

mindmap
  root((Chrome QUIC<br/>404 트러블슈팅))
    근본 원인
      Chrome QUIC/HTTP3
        UDP:443 사용
        Alt-Svc 헤더로 활성화
      SK브로드밴드 공유기
        UDP NAT 타임아웃 30초
        대칭형 NAT
      홈서버 환경
        포트포워딩
        externalIPs
    해결책
      Cloudflare Proxy
        QUIC → HTTP/2 변환
        Edge에서 연결 종료
        Origin은 TCP만
      DDNS 마이그레이션
        Route53 → Cloudflare
        CronJob 재구현
    추가 혜택
      DDoS 보호
      글로벌 CDN
      자동 SSL
      무료 플랜
    변경 파일
      values-ddns.yaml
      cronjob.yaml
      external-secret.yaml

10. 참고 자료#


부록 A: 트러블슈팅 명령어 모음#

# === DNS 확인 ===
dig NS goormgb.space +short
dig api.dev.goormgb.space +short
dig api.dev.goormgb.space +trace

# === Cloudflare 헤더 확인 ===
curl -I https://api.dev.goormgb.space/swagger-ui/index.html
curl -svo /dev/null https://api.dev.goormgb.space/ 2>&1 | grep -i "cf-"

# === DDNS CronJob ===
kubectl get cronjob -n core
kubectl logs -n core -l app.kubernetes.io/name=ddns-cloudflare --tail=50
kubectl create job ddns-test --from=cronjob/ddns-cloudflare-updater -n core

# === Istio IngressGateway ===
kubectl get svc -n istio-system istio-ingressgateway -o yaml
kubectl logs -n istio-system -l app=istio-ingressgateway --tail=100

# === Gateway/VirtualService ===
kubectl get gw,vs -n istio-system
kubectl describe gw api-gateway -n istio-system

# === 인증서 확인 ===
kubectl get certificate -n istio-system
kubectl describe certificate goormgb-tls -n istio-system

# === ufw 상태 ===
# mini-gmk
sudo ufw status verbose
# mini-might
sudo ufw status verbose

# === 네트워크 연결 테스트 ===
# 내부에서 외부 접근
curl -I https://api.dev.goormgb.space/swagger-ui/index.html
# 클러스터 내부 서비스 직접 접근
kubectl exec -n istio-system deploy/istio-ingressgateway -- \
  curl -I http://java-cloud-gateway.dev-webs.svc.cluster.local:8085/swagger-ui/index.html

부록 B: 공유기 UDP NAT 타임아웃 확인 방법#

# 1. 외부 서버에서 UDP 리스닝 (있다면)
nc -lu 12345

# 2. 홈서버에서 UDP 패킷 전송
echo "test" | nc -u 외부서버IP 12345

# 3. 30초 후 다시 전송 → 연결 끊김 확인

# 또는 STUN 서버로 NAT 타입 확인
# https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/

작성일: 2026-03-17 환경: kubeadm 클러스터 + SK브로드밴드 + Cloudflare 관련 레포: 302-goormgb-k8s-bootstrap, 303-goormgb-k8s-helm