aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research/101-remaining-limits.md
blob: 5a1ddc89a439059beaea465061a03db90abe3a04 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# 101 — 남은 한계 넷: 블랙홀, 스트림 이름, 메모 없는 재시작, ngtcp2

2026-09-30. [100](100-moved-receiver.md)이 남긴 한계를 푼다. [설계 011](../design/011-pure-transport.md)의 5단계(채택과 명세)
앞이다.

착수 기준:

- **바꿀 결정.** 알려진 한계를 풀거나, 풀 필요가 없음을 재서 보인다. 그다음에 채택한다.
- **최소 확인.**
  - 경로 MTU 블랙홀: 결정적 검사로 재현하고 고친다. M6 시뮬레이터의 결과는 바뀌지 않아야 한다.
  - 스트림 이름: 패킷마다 싣지 않으면 CPU와 바이트가 얼마나 주는지 잰다. 2 %가 안 되면 코드를 더하지 않는다.
  - 메모 없는 재시작: M2 `--no-memo`가 에폭 전(095)과 같은 수준으로 돌아온다.
  - ngtcp2: M5를 quinn과 같은 의미로 잰다.
- **중단 기준.** 어느 것이든 보안 검사(098, 099)를 깨면 되돌린다.

## 쉬운 말로

- 알아낸 것:
  - **블랙홀.** 경로가 1,452 B를 나르다가 소리 없이 큰 패킷만 버리기 시작하면, 링크가 멈췄다(3,000건 가운데 8건).
    - 이제 두 경우에 1,200 B로 돌아간다. 큰 패킷이 잇따라 사라지는데 작은 것만 도착할 때, 그리고 탐침 두 번이 연달아 답을
      받지 못할 때다.
    - 그 검사는 가상 1.2초에 끝난다. M6 시뮬레이터의 결과는 여덟 경로 모두 바이트까지 같다.
  - **스트림 이름.** 096은 61 B에서 남은 차이를 패킷마다 싣는 스트림 이름 탓으로 봤다. 틀렸다.
    - 이름을 빼 봤더니 61 B 패킷에 기록이 하나도 더 들어가지 않았다. 21 B가 비어도 62 B짜리 기록은 들어가지 않는다.
    - M5의 CPU도 잡음 안이었다(61 B 0.290 → 0.286 µs, 1 KiB 같음). 새 프레임, 두 쪽의 상태, 복구 길을 더할 값이 없어 되돌렸다.
  - **메모 없는 재시작.** 받는 쪽이 모르는 에폭의 패킷에 LOST로 답한다. QUIC의 stateless reset과 같은 자리다. 보내는 쪽은
    run을 새로 시작하듯 이어 간다.
    - M2(기기 1만 개)에서 저장이 멈춘 100 ms 칸이 8에서 0이 됐다.
    - 따라잡기는 7.7초에서 0.4–0.5초가 됐다. 에폭 전(095의 0.3초)과 거의 같다.
  - **ngtcp2.** 1 KiB와 16 KiB에서 Pure가 quinn과 ngtcp2 모두보다 25 % 넘게 가볍다. 61 B에서는 ngtcp2가 조금
    가볍다(0.270 대 0.280 µs).
    - 차이는 받는 쪽에 있었다. 순서대로 온 기록을 프레임째 줄에 붙이는 길을 넣어, 0.292에서 0.280으로 좁혔다.
- 아직 모르는 것: 손실과 재정렬이 있는 경로(M6)에서 ngtcp2와는 대 보지 않았다.
- 사용 경험의 차이:
  - 노드가 메모 파일을 잃고 다시 떠도 기기들은 거의 모른다.
  - 경로 MTU가 줄어도 링크가 멈추지 않는다.

## 한 것

### 경로 MTU 블랙홀 (RFC 8899 §4.3)

- test-link에 경우를 더했다. 보내는 쪽 데이터그램이 1,452 B로 오르는 순간부터, 경로가 1,200 B보다 큰 패킷을 모두 버린다.
- 고치기 전에는 10분 안에 3,000건 가운데 8건만 저장됐다. 큰 패킷을 잃을 때마다 다시 큰 패킷으로 보냈기 때문이다.
- 이제 두 경우에 1,200 B로 돌아가고, 1초 뒤 다시 탐침한다. 탐침이 실패하면 기존 백오프(3번 뒤 10분)를 따른다.
  - 큰 패킷이 확인 없이 연달아 3개 넘게 사라졌는데, 확인이 작은 패킷만 덮고 큰 패킷은 하나도 덮지 않았을 때.
  - 탐침 두 번이 연달아 답을 받지 못했을 때.
- 혼잡으로 잃은 것이라면 큰 패킷도 사이사이 확인된다. 그래서 첫 조건에 걸리지 않는다. M6 시뮬레이터로 확인했다. 병목,
  손실, 흔들림이 있는 여덟 경로에서 결과가 바이트까지 같았다.

### 스트림 이름을 빼 보았다

- 설계는 이랬다.
  - 받는 쪽이 이 run의 스트림을 안다는 증거(같은 run의 답에 실린 STORED)를 들었고, 1초 안에 답을 들었다면 STREAM 프레임을
    뺀다.
  - 모르는 run의 이름 없는 기록에는 받는 쪽이 NAME으로 "이름을 대 달라"고 답한다.
  - 쉬던 기기의 첫 패킷에는 늘 이름을 실어, 1 RTT 깨움을 지킨다.
- 결과는 이랬다.
  - 모든 검사를 통과했고, 깨끗한 경우의 패킷이 1.6 % 줄었다.
  - 하지만 61 B 기록의 패킷 수는 그대로였고(93,755), M5 CPU는 잡음 안이었다.
- 되돌렸다. 096의 "남은 차이는 스트림 이름"이라는 추정은 이것으로 틀렸음이 밝혀졌다.

### LOST

- **받는 쪽.** 모르는 run의 패킷이 에폭 1 이상이라 열 수 없으면 LOST로 답한다.
  - 에폭 0(두 노드의 정적 키)으로 봉인하고, 열지 못한 패킷의 번호를 싣는다.
  - 그 run의 답 run을 새로 뽑아 봉인하므로 논스가 겹치지 않는다.
- **보내는 쪽.** LOST가 이름 댄 패킷이 지금 날아가는 것일 때만 받아들인다. 받아들이면 run을 새로 시작할 때처럼 한다.
  - 에폭 0으로 돌아가 에폭 1을 새로 제안한다.
  - 저장되지 않은 기록을 곧바로 다시 보낸다. 날아가던 것은 혼잡 신호 없이 버린다.
- **전방 비밀성의 규칙이 넓어졌다.** 전에는 "run이 처음 시작될 때만 정적 키로 기록이 간다"였다. 이제 "받는 쪽이 에폭을
  잃었다고 인증된 말로 할 때"가 더해졌다. 그래도 다음 셋은 막혀 있다.
  - LOST는 정적 키로 봉인되므로 경로 위 공격자가 만들 수 없다.
  - 지난 LOST를 다시 보내면 이미 날아가지 않는 패킷을 이름 대므로 받아들이지 않는다.
  - 답을 막아서 생기는 조용한 복구(2초와 탐침 4번)는 지금처럼 기록 없이 제안만 한다. 공격자가 전방 비밀성을 떨어뜨리는 길은
    여전히 없다.
- **QUIC의 stateless reset 규칙을 따랐다(RFC 9000 §10.3).**
  - LOST는 열 수 없는 패킷에 대한 답이므로, 받은 패킷보다 작을 때만 보낸다(46 B).
  - 초당 4,096개로 따로 묶는다.
  - 098이 "변조된 패킷에는 아무 답도 없다"고 했던 것이 여기서 달라진다. 이제 "받은 것보다 작은 LOST 말고는 없다"다.
- **토큰 규칙을 다듬었다.** 처음에는 LOST를 넣어도 M2가 나아지지 않았다(따라잡기 8초).
  - 메모를 잃은 뒤의 패킷은 모두 "열리지 않는 새 run"이라 폭주 방어 토큰(초당 256)을 썼다. 그래서 초당 256대만 LOST를 받았다.
  - 이제 에폭을 몰라 키를 만들지 않은 실패는 토큰을 쓰지 않는다. 그 보내는 이의 정적 비밀은 메모에 남으므로 X25519는 보내는
    이마다 한 번이다.
  - 메모 없이 도는 받는 쪽은 비밀을 남기지 못하므로 전처럼 토큰을 쓴다.
- **검사.**
  - 변조: 받은 것보다 작은 LOST만 허용한다.
  - 폭주: 가짜 1만 개의 절반을 에폭 1로 보냈다. LOST 267개, 공격자에게 간 것은 12 KB였다. 공격자가 보낸 것은 6 MB다.
  - 잊는 받는 쪽: 정적 키로 기록이 가는 것은 잊은 뒤부터 새 에폭이 시작되기 전까지만이다.

### ngtcp2

- **하네스.** ngtcp2 1.25.0을 OpenSSL 3.5의 QUIC TLS(ossl 백엔드)로 빌드한다([build.sh](../../research/experiments/019-idle-links/ngtcp2/build.sh)).
  [drain.c](../../research/experiments/019-idle-links/ngtcp2/drain.c)는 quinn 하네스와 같은 일을 C로 한다.
  - 연결 하나, 스트림 하나로 "pure" 뒤에 길이를 앞에 붙인 메시지를 보낸다. 서비스는 세기만 한다.
  - 패킷은 GSO로 묶어 보낸다.
  - ngtcp2는 스트림 바이트를 복사하지 않고, 확인될 때까지 앱이 들고 있어야 한다. 하네스의 바이트는 오프셋만으로 정해지므로
    변하지 않는 패턴 버퍼를 가리킨다.
- **받는 쪽의 빠른 길.** 처음 재니 61 B에서 Pure가 ngtcp2의 1.08배였다. 두 쪽으로 나누니 보내는 쪽은 같았고(0.14 µs),
  받는 쪽이 0.15 대 0.13 µs였다.
  - 순서대로 온 RECORDS 프레임은 쥔 기록도 꼬리 조각도 없으면, 프레임 단위로 한 번 검사하고 기록을 줄에 바로 이어 붙인다.
  - 그 뒤 받는 쪽은 0.133–0.143 µs다.
  - Pure 쪽 호스트는 기록마다 보낸 그대로인지 대조한다(0.007 µs). QUIC 서비스 둘은 세기만 한다.

## 결과

**M2, 메모 없이 다시 뜬 받는 쪽** (기기 1만 개, 80초, 60초에 kill -9, 두 라운드,
`research/experiments/019-idle-links/2026-09-30-lost-linux-aarch64.jsonl`):

| | 099(LOST 전) | 이 문서 |
|---|---|---|
| 저장이 멈춘 100 ms 칸 | 8 | 0 |
| 따라잡기 | 7.7–7.8초 | 0.4–0.5초 |
| 저장까지 p99 / 최대 | 4.1–4.2초 / 7.7–7.8초 | 5.8–5.9 ms / 48–57 ms |
| 다시 뜬 뒤 10초의 CPU, 노드 / 생성기 | 5.4–5.6 / 4.7–4.8초 | 3.8–3.9 / 2.2–2.4초 |
| 다시 보낸 기록 | 61–62k | 10k(기기마다 하나) |

**M5, 세 구현** (두 쪽 CPU 합, 다섯 라운드의 중앙값, µs/메시지,
`research/experiments/019-idle-links/2026-09-30-transport-three-linux-aarch64.jsonl`):

| 크기 | Pure | quinn | ngtcp2 | 초당 전달 Pure / quinn / ngtcp2 |
|---|---:|---:|---:|---|
| 61 B | 0.280 | 0.310 | 0.270 | 581만 / 607만 / 617만 |
| 1 KiB | 2.62 | 3.94 | 3.54 | 75만 / 51만 / 54만 |
| 16 KiB | 40.2 | 60.2 | 54.4 | 5.0만 / 3.2만 / 3.6만 |

61 B에서 Pure는 라운드마다 0.274–0.321 µs로 흔들린다. ngtcp2는 0.27로 고르다.

## 판정

- 넷 가운데 셋을 풀었다(블랙홀, 메모 없는 재시작, ngtcp2 비교). 하나(스트림 이름)는 풀 필요가 없음을 쟀다.
- H4를 다시 본다. 두 QUIC 구현 모두와 대어, 1 KiB 이상에서 25 % 넘게 가볍다. 61 B에서는 ngtcp2의 1.04배, quinn의 0.90배다.
  011의 기준(10 %) 안이다. 61 B에서는 QUIC과 비긴다고 본다.
- 보안 검사는 모두 통과한다. LOST로 달라진 한 줄("변조된 패킷에 답 없음" → "받은 것보다 작은 LOST 말고는 없음")은 QUIC의
  stateless reset과 같은 자리다.

## 한계와 다음

- M6(손실, 재정렬, 병목)을 ngtcp2와는 대지 않았다.
- 61 B에서 남은 비용은 보내는 쪽의 기록마다의 일(패킷 채우기, 확인 처리)이다. 기록 단위로 정확히 한 번을 지키는 값이다.
  run 단위로 묶어 보았으나 벤치에서 차이가 없어 되돌렸다.
- 메모는 보내는 이마다 run 하나의 에폭만 적는다. 한 노드에서 링크 여럿이 오는 보내는 이는, 받는 쪽이 다시 뜨면 나머지 run이
  LOST로 에폭을 새로 연다. 이제는 멈추지 않는다.
- 다음은 011의 5단계, 채택과 명세다.