Chrome QUIC/HTTP3 간헐적 404 트러블슈팅
홈서버(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 자체 모델 |
| 내부 IP | 192.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.html | 200 OK | HTTP/2 |
| Safari | 정상 | HTTP/2 |
| Firefox | 정상 | HTTP/2 |
| 모바일 LTE 네트워크 | 정상 | HTTP/2 |
| Chrome (같은 네트워크) | 간헐적 404 | QUIC/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
| Type | Name | Content | Proxy | TTL | 용도 |
|---|---|---|---|---|---|
| A | @ | 39.119.192.15 | Proxied | Auto | 루트 도메인 |
| A | api.dev | 39.119.192.15 | Proxied | Auto | 백엔드 API |
| A | argocd | 39.119.192.15 | Proxied | Auto | GitOps |
| A | grafana | 39.119.192.15 | Proxied | Auto | 모니터링 |
| A | kiali | 39.119.192.15 | Proxied | Auto | Service Mesh |
| A | cloudbeaver | 39.119.192.15 | Proxied | Auto | DB Admin |
| CNAME | dev | cname.vercel-dns.com | DNS only | Auto | 프론트엔드 |
중요:
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. 참고 자료#
- Cloudflare: Understanding HTTP/3
- Chrome QUIC Protocol
- Cloudflare API: DNS Records
- Istio: Gateway Configuration
- NAT Traversal for QUIC
부록 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