From 5f8c14f774b81bd6d65d531035eae61b79b1d12c Mon Sep 17 00:00:00 2001 From: eunhhu Date: Mon, 28 Sep 2026 19:57:27 +0100 Subject: Measure a restart under load and a first contact (095) M2 and M4 of design 011. Ten thousand devices send an order every five seconds (2,000 a second); the receiver is killed (-9) at about 21 s and started again at once. With its secrets remembered the node shows no 100 ms step without an order stored, catches up in 0.2 s, spends no more CPU than before and makes no X25519; 34 orders were sent again, and every order made was applied once (three rounds; on the SD card the same, with 0.2 s silent). Without the memo it makes 10,000 X25519, 1.6 s of CPU. quinn under the same load stops for 1.0 s and catches up after 10.9 s: 10,115 new connections, about 7 s more of the service's CPU and 8 s of the clients'. A first contact takes Pure one round trip and QUIC two, at 10, 50 and 100 ms of RTT. run.py --restart and --first-contact make them; the quinn harness gains a fixed port with a reset key from its key, clients that connect again, and a drain for M5. --- docs/research/094-idle-links.md | 4 +- docs/research/095-restart-and-first-contact.md | 131 +++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 docs/research/095-restart-and-first-contact.md (limited to 'docs/research') diff --git a/docs/research/094-idle-links.md b/docs/research/094-idle-links.md index 57fc736..573db98 100644 --- a/docs/research/094-idle-links.md +++ b/docs/research/094-idle-links.md @@ -133,8 +133,8 @@ H1의 뒷부분("프로세스 메모리는 활성 링크 수에만 비례")은 ## 한계와 다음 - **재시작의 비대칭 연산.** 활성 상태를 비우면 비밀도 잊는다. 그래서 재시작한 노드는 링크마다 X25519를 한 번 한다(169 µs). - 011의 M2 중단 기준("이어지는 데 링크 수에 비례하는 비대칭 연산")에 걸린다. 다음 일은 비밀의 메모다. 키 파일 옆 파일에 - 보내는 이마다 64 B 칸을 두고 매핑한다. 쉬는 링크의 칸은 상주하지 않는다. + 011의 M2 중단 기준("이어지는 데 링크 수에 비례하는 비대칭 연산")에 걸린다. [095](095-restart-and-first-contact.md)에서 비밀의 + 메모로 풀었다. 키 파일 옆 파일에 보내는 이마다 64 B 칸을 두고 매핑한다. 쉬는 링크의 칸은 상주하지 않는다. - **받는 쪽의 처리량.** 새 보내는 이를 받는 속도는 초당 약 3,700이다. 노드 CPU의 대부분이 X25519다. - **보내는 쪽.** 링크마다 스레드와 소켓이 하나씩이다. 노드가 기기 100만 대에 보내는 방향은 아직 안 된다. 보내는 쪽도 한 스레드와 한 소켓에 여러 링크를 둬야 한다. diff --git a/docs/research/095-restart-and-first-contact.md b/docs/research/095-restart-and-first-contact.md new file mode 100644 index 0000000..eb0249d --- /dev/null +++ b/docs/research/095-restart-and-first-contact.md @@ -0,0 +1,131 @@ +# 095 — 재시작과 처음 만남: 링크 1만 개가 kill -9를 모른다 + +2026-09-28. [설계 011](../design/011-pure-transport.md)의 M2와 M4다. [094](094-idle-links.md)의 부하 생성기와 quinn +하네스를 그대로 쓴다. + +착수 기준: + +- **바꿀 결정.** 011의 H2다. 어느 노드를 kill -9 해도 다시 뜬 뒤 첫 패킷으로 이어지고, 핸드셰이크도 잃거나 겹치는 기록도 없다. + 중단 기준은 둘이다. + - 재시작 뒤 핸드셰이크 없이 이어지지 않는다. + - 이어지는 데 링크 수에 비례하는 비대칭 연산이 든다. + - 둘 중 하나라도 그러면 다시 설계한다. +- **최소 확인.** + - M2: 기기 1만 개가 5초마다 주문 하나를 보낸다. 받는 쪽이 초당 2,000건을 받는다. 20초쯤에 받는 쪽을 kill -9 하고 곧바로 + 다시 띄운다. 저장이 멈춘 시간, 밀린 것을 따라잡는 시간, 양쪽 CPU, 비대칭 연산 수, 잃음과 겹침을 본다. quinn도 같은 부하로 + 한다. 연결 1만 개가 다시 맺어진다. + - M4: 처음 보는 상대에게 주문 하나를 보내 저장됐다는 말을 받기까지 걸리는 왕복 수를 본다. +- **중단 기준.** 위와 같다. + +## 쉬운 말로 + +- 알아낸 것: + - 받는 노드를 kill -9 하고 다시 띄워도 기기 1만 개는 거의 모른다. 100 ms 단위로 저장이 멈춘 때가 없었다. 밀린 것은 0.2초 + 만에 따라잡았다. 다시 보낸 주문은 34건이고, 8만 건이 모두 정확히 한 번 적용됐다. 재시작한 노드의 비대칭 연산은 0이다. + - quinn은 같은 일에서 1초 동안 멈췄다. 연결 1만 개를 다시 맺느라 10.9초 뒤에야 따라잡았다. 그동안 핸드셰이크에 서버가 + CPU를 약 7초, 클라이언트들이 약 8초 더 썼다. + - 처음 보는 상대와 Pure는 1 RTT에 끝나고, QUIC은 2 RTT가 든다. 첫 패킷이 이미 봉인된 기록이기 때문이다. +- 아직 모르는 것: 전달당 CPU와 처리량(M5), 손실과 병목 아래의 공정성(M6), 저장소 옮기기(M3). +- 사용 경험의 차이: 서버를 배포하거나 죽었다 살아나도, 붙어 있던 기기와 사용자는 모른다. 재연결 폭주도 없다. + +## 한 것 + +### 비밀의 메모 + +[094](094-idle-links.md)의 받는 쪽은 쉬는 보내는 이의 활성 상태를 비운다. 그러면 비밀도 잊는다. 그래서 재시작한 노드는 +돌아오는 보내는 이마다 X25519를 한 번 했다. 이 Pi의 OpenSSL로 169 µs다. 이것이 M2의 두 번째 중단 기준에 걸렸다. + +비밀은 이제 노드 키 옆의 파일(`KEY.links`, 0600)에 적어 둔다. + +- 보내는 이마다 허용한 순서대로 64 B 칸이 하나씩 있다. 칸에는 키의 앞 28 B, 검사값(키와 비밀의 FNV-1a), 비밀이 든다. +- 파일은 매핑한다. 쉬는 보내는 이의 칸은 메모리에 올라와 있지 않다. +- 비밀을 먼저 쓰고, 칸을 그 키의 것으로 만드는 키는 나중에 쓴다. 검사값이 맞지 않는 칸은 다시 만든다. 손으로 한 칸의 비밀을 + 바꿔 봤고, 그 칸만 X25519로 다시 만들어졌다. +- 노드 키가 바뀌면 파일을 처음부터 쓴다. +- 칸의 비밀은 노드 키와 같은 무게다. 그래서 키 파일과 같이 주인만 읽는다. + +연결 없는 모델에서는 이것이 자연스럽다. 비밀은 두 정적 키의 함수라서 언제 계산해도 같다. 메모는 그 함수의 기록일 뿐이다. +세션이 아니므로 틀려도 다시 계산하면 된다. + +### 꾸준히 보내는 기기들 + +부하 생성기에 `run`을 더했다([links.c](../../research/experiments/019-idle-links/links.c)). + +- 기기마다 소켓, 키, outbox가 있다. 주기마다 기록을 하나 만든다. +- 저장되지 않은 것은 다시 보낸다. 답이 없으면 200 ms에서 시작해 1초까지 기다림을 늘린다. +- 받는 쪽이 새로 뜨면 답의 run이 바뀐다. 기기는 그 run의 키를 만들어 연다. 기기 쪽의 일은 그것뿐이다. +- 100 ms마다 저장된 수를 센다. + +### quinn 쪽 + +QUIC에 가장 좋은 조건을 줬다. + +- 서비스는 같은 포트에 같은 키로 다시 뜬다. 상태 없는 리셋 키도 그 키에서 만든다. 그래서 새 서비스가 옛 연결의 패킷에 + 곧바로 리셋을 보낸다. +- 클라이언트는 1초 안에 답이 없거나 연결이 끊기면 다시 연결해 같은 주문을 보낸다. +- 서비스는 디스크에 저장하지 않는다. 메모리의 번호만 적는다. 그래서 정확히 한 번은 Pure 쪽만 확인한다. +- tokio 한 스레드다. Pure의 받는 쪽도 암호는 한 스레드(리스너)가 한다. + +## 결과 + +라즈베리 파이 5이고, 저장소는 tmpfs다(SD 카드는 따로 적었다). 원시 결과는 `research/experiments/019-idle-links/`의 +`2026-09-28-restart-*.jsonl`과 `2026-09-28-first-contact-linux-aarch64.jsonl`에 있다. + +**M2.** 기기 또는 클라이언트 1만 개가 5초마다 주문 하나를 보낸다(초당 2,000건). 받는 쪽을 약 21초에 kill -9 하고 곧바로 +다시 띄웠다. 세 라운드의 중앙값이다. SD 카드는 한 라운드다. + +| | 다시 뜨기 | 저장이 멈춘 100 ms 칸 | 따라잡기 | 받는 쪽 CPU(재시작 뒤 10초) | 비대칭 연산 | 보내는 쪽 CPU(같은 10초) | 다시 보냄 | 저장까지 p99 / 최대 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| Pure, 메모 | 14 ms | 0 | 0.2초 | 1.08초 | 0 | 0.99초 | 34건 | 4.1 ms / 206 ms | +| Pure, 메모 없음 | 15 ms | 0 | 0.3초 | 2.73초 | X25519 10,000 | 0.86초 | 34건 | 4.6 ms / 221 ms | +| Pure, 메모, SD 카드 | 29 ms | 2 | 0.3초 | 1.06초 | 0 | 0.94초 | 269건 | 111 ms / 267 ms | +| quinn | 1.5 ms | 10 | 10.9초 | 8.30초 | 핸드셰이크 10,115 | 9.14초 | 연결 10,115개 | 5.3초 / 11.2초 | + +- 평소에는 받는 쪽도 보내는 쪽도 초당 약 0.1초의 CPU를 쓴다. 재시작 뒤 10초는 그 10배 안팎이 평소 몫이다. + - Pure는 메모가 있으면 재시작 뒤 CPU가 평소와 같다. + - 메모가 없으면 1.6초 더 든다. X25519 1만 번(각 0.165 ms)의 값이다. + - quinn은 받는 쪽이 7.2초, 보내는 쪽이 8.0초 더 쓴다. 우리 클라이언트는 인증서를 확인하지 않으므로, 실제 클라이언트는 더 + 든다. +- Pure의 모든 라운드에서 만든 주문, 저장됐다는 말을 받은 주문, 받는 앱이 적용한 주문의 수가 같았다(8만여 건). 잃거나 겹친 것이 + 없다. +- Pure의 기기는 재시작을 알아채지 않는다. 답이 오지 않은 주문을 다시 보낼 뿐이다. 1만 개 가운데 그때 답을 기다리던 30여 개다. +- SD 카드에서는 저장마다 동기화가 느려 p50이 4.1 ms다. 재시작 중에 밀린 것이 조금 더 많았다(269건). 그래도 0.3초 만에 + 따라잡았다. +- quinn은 새 서비스가 옛 연결에 리셋을 보낼 수 있는데도, 첫 1초 동안 저장이 하나도 없었다. 클라이언트의 1초 시한과 같은 + 길이다. 리셋이 닿지 않은 연결이 시한을 기다렸을 것으로 보지만, 원인은 확인하지 않았다. 그 뒤 핸드셰이크가 서비스의 한 + 스레드를 채웠다. 스레드를 늘리면 따라잡는 시간은 줄지만, 핸드셰이크 1만 번의 CPU는 그대로다. 서버는 핸드셰이크마다 X25519 + 둘(임시 키와 합의)과 ECDSA 서명 하나를 한다. + +**M4.** 처음 보는 상대 100개에게 하나씩 주문 하나를 보내고, 저장됐다는 말(quinn은 답)을 받기까지를 쟀다. 비특권 네트워크 +이름공간에서 루프백의 UDP만 netem으로 늦췄다. + +| 한쪽 지연 | RTT | Pure p50 | Pure 왕복 | quinn p50 | quinn 왕복 | +|---:|---:|---:|---:|---:|---:| +| 5 ms | 10 ms | 10.6 ms | 1.06 | 21.0 ms | 2.10 | +| 25 ms | 50 ms | 50.9 ms | 1.02 | 101.2 ms | 2.02 | +| 50 ms | 100 ms | 100.7 ms | 1.01 | 201.2 ms | 2.01 | + +- Pure는 첫 패킷이 이미 봉인된 기록이다. 받는 쪽이 저장하고 답하면 끝이라 1 RTT다. 받는 쪽의 X25519와 저장은 1 ms 안쪽이다. +- QUIC은 핸드셰이크에 1 RTT, 주문에 1 RTT가 든다. 세션 티켓의 0-RTT는 다시 만날 때만 되고, 재전송 공격에 열려 있다. + 재시작한 서비스는 티켓 키를 저장해 두지 않는 한 티켓을 받지 못한다. + +## 판정 + +- **M2는 통과다.** 받는 쪽을 kill -9 해도 핸드셰이크 없이 이어진다. 메모가 있으면 재시작에 비대칭 연산이 들지 않는다. 기록을 + 잃거나 겹치지 않았다. 011의 두 중단 기준 모두에 걸리지 않는다. +- **M4는 1 RTT다.** QUIC의 절반이다. 봉인한 첫 패킷이 기록이기 때문이다. 011이 비교 대상으로 적은 QUIC의 1-RTT는 핸드셰이크만의 + 값이고, 기록까지는 2 RTT다. +- H2가 섰다. 093은 정확성을, 여기서는 값을 봤다. 재시작의 값은 연결 없는 모델에서 거의 0이다. 연결이 있는 모델에서는 연결 수에 + 비례한다. + +## 한계와 다음 + +- **처음 만남은 여전히 X25519 한 번이다.** 새 보내는 이를 받는 속도는 초당 약 3,700이다(094). 쓰레기 패킷으로 이것을 강요하는 + 공격은 메모로 알려진 키에 대해서는 사라졌다. 모르는 run의 첫 패킷마다 드는 HKDF 두 번은 남는다. 비율 제한은 보안 단계에서 + 둔다. +- **메모는 허용 순서에 묶인다.** 허용 목록의 순서가 바뀌면 칸이 맞지 않아 다시 계산한다. 정확성에는 영향이 없다. +- **QUIC 비교는 quinn 하나다.** ngtcp2는 아직이다. +- **보내는 쪽.** 링크마다 스레드 하나다(094). +- 다음은 011의 순서대로다. + - 에폭 바꿈(전방 비밀성), 경로 바뀜, 저장소 옮기기(H3, M3), 보안 검사를 한다. + - 그 뒤 M5와 M6 가드레일을 잰다. -- cgit v1.2.3