열다섯 개의 초록 게이트, 그리고 아무도 묻지 않은 질문
컨테이너 스택 글을 올린 사흘 뒤, 남은 일을 찾아보았다. 모든 테스트가 피해 간 기본 디스크, 설명할 수 없어서 '보고'로만 남겨둔 숫자, 커널에 묻지 않은 채 '말이 안 된다'고 적은 기능, 옵션 하나를 문서에 적었더니 명령 두 개가 사라진 도움말. 넷 다 바로 옆에 검사가 있었고, 그 검사는 다른 검사들과 일치하고 있었다.
사흘 전, 직접 만든 언어로 쓴 컨테이너 스택 —— VMM, Docker Engine API를 말하는 데몬, OCI 런타임, 레지스트리 —— 에 대해 쓰고 마지막을 “실제로 써야만 나오는 버그들“이라는 절로 맺었다. 그러고 나서 돌아와 지루한 질문을 스스로에게 했다: 남은 게 있나?
넷 있었다. 함께 쓰고 싶은 이유는 넷 다 같은 모양이기 때문이고, 그 모양은 “테스트를 깜빡했다”가 아니다. 넷 다 바로 옆에 검사가 있었다. 검사는 초록이었다. 그리고 전부가, 조금 더 쉬운 질문에 조용히 합의하고 있었다.
전에 가드레일은 프로그램 하나를 지키고 게이트는 질문을 지킨다 고 썼다. 이것은 그 문장의 반대편에 있는 실패의 모양이다 —— 어떤 질문은 지키지만, 사람이 물을 질문은 지키지 않는 게이트.
1. 모든 테스트가 피해 간 길
mvm start를 인자 없이 치면 20 GiB 디스크가 만들어진다.
그 디스크는 mke2fs가 성공을 보고한 순간부터 망가져 있었다:
disk: mke2fs ok
EXT4-fs error (device vda): bg 32: bad block bitmap checksum
EXT4-fs error (device vda): ... bg 49 ... bg 64 ... bg 96 ... bg 128 ...
disk: done ← "됐다"고 말한다
다음 부팅에서 커널은 Block bitmap for group 0 not in group (block 4294967295)
라며 panic했다. 이 수는 2³² − 1이고, 망가진 group —— 32 / 49 / 64 / 96 / 128 ——
은 정확히 4 GiB 선 위에 사는 것들이다.
원인은 선언이었다. 내 언어의 int는 64비트지만 C 백엔드가 extern에 뱉는 선언은 맨 int
이고, shim 쪽은 long long으로 정의하고 있었다:
extern int hv_guest_to_file(const char*, int, const char*, int); /* 부르는 쪽이 믿는 것 */
long long hv_guest_to_file(const char*, int, const char*, long long); /* 정의하는 것 */
양쪽 파일이 경고 없이 컴파일된다. 그리고 부르는 쪽이 넘기는 도중에 인자를 자른다. 호출 지점에는 아무것도 보이지 않는다. 무엇을 고치기 전에 먼저 쟀다: 20 GiB 디스크로의 34,341번의 쓰기 중, 4 GiB를 넘은 것이 하나도 없다. 최대는 4,294,963,200 —— 2³²에서 페이지 하나를 뺀 값. 선 위로의 쓰기는 전부 파일 앞쪽으로 접혀 살아 있는 데이터를 덮어쓰고 있었다.
고치는 법은 관례 한 줄이다. 오프셋을 hex 문자열로 넘긴다 —— 같은 함수 옆에 있는 게스트 물리 주소가 처음부터 그렇게 하고 있던 것과 똑같이. 고친 뒤에는 34,397번 중 33,144번이 4 GiB를 넘고, format은 깨끗하며, 선 위에 쓴 데이터는 머신 재시작을 건너 살아남는다.
왜 아무것도 잡지 못했나. 게이트는 열네 개 있었다. 넘기는 인자는 이렇다:
test/lifecycle.sh --disk-size 2048
test/scale.sh --disk-size 4096
test/build.sh --disk-size 1024 / 2048 / 1024
test/outward.sh --disk-size 4096
test/vcpus.sh --disk-size 4096
하나도 생략하지 않는다. 전부가 기본값을 덮어쓴다. 작은 쪽이 빠르다는 당연한 이유로, 그리고 그 일관성이야말로 구멍을 숨기고 있었다. 한 번도 지나지 않은 것은, 사람이 아무것도 붙이지 않고 명령을 치는 길뿐이었다.
이것은 내 프로젝트를 넘어 일반화되므로 분명히 적어 둔다 ——
기본값이 미검사인 것은, 바로 모든 테스트가 그것을 덮어쓰기 때문이다.
열다섯 번째 게이트는 이제 사람과 똑같이 머신을 시작한다.
그리고 잘림을 되돌리는 sed를 함께 들고 있다.
2. 설명할 수 없어서 인쇄해 두었던 숫자
80 병렬 docker run에서, 올라오는 컨테이너가 72~79개 사이에서 흔들렸다.
클라이언트는 전부 성공을 보고한다. 네 번 재고 가설 두 개를 반증했으므로,
정직해 보이는 일을 했다 —— 숫자를 인쇄하고, 요구하지는 않았다.
note 72 of 80 came up -- reported, not required
이해하지 못하는 숫자에 대해 이렇게 하는 것은 변호할 수 있는 행동이다. 그리고 동시에, “알려진 미지”가 영주권을 얻는 경로이기도 하다.
깨뜨린 것은 다섯 번째의 같은 측정이 아니다. 네 번 다
“몇 개가 올라왔나” = 증상을 세고 있었다.
docker CLI와 데몬 사이에 기록하는 릴레이를 끼우고,
연결을 “열렸을 때에도” 기록하게 했다. 닫혔을 때만이 아니라.
이 두 번째가 전부이고, 닫힐 때만 기록하는 계기는 끝난 요청을 보여주고
끝나지 않은 것을 지운다 —— 그리고 찾는 것은 후자다.
한 번, 80 클라이언트:
POST /containers/create 열림 80 닫힘 80
POST /containers/ID/wait 열림 80 닫힘 0
POST /containers/ID/start 열림 32 닫힘 0
start가 32개. 80이 아니다.
32는 데몬이 가진 버퍼 슬롯의 수이고, 나머지는
내가 몇 달 전에 주석에 적어 둔 설계의 세부에서 따라 나온다 ——
docker CLI는 /wait를 자기 전용 연결로 열고, /start를 보내는 것은
/wait의 헤더가 돌아온 뒤다.
즉 80개의 wait가 오고, 32개가 슬롯을 얻어 헤더를 답하고,
그 32 클라이언트가 /start를 보내고 —— 그 start는 전부,
기다리는 /wait가 쥐고 있는 슬롯을 기다린다.
그 /wait는 시작될 수 없는 컨테이너가 끝나기를 기다리고 있다.
순환 대기이고, 게다가 고리가 같은 클라이언트의 연결 두 개를 지난다.
간헐적인 것은 경쟁이기 때문이다. 40 클라이언트라면 wait가 모든 슬롯을 메우기 전에 start가 끼어드는 일이 많다. 병렬 수를 올릴수록 나빠지고, 어떤 임계값도 깨끗해 보이지 않았던 이유가 이것이다.
고침은 네 줄 —— /wait는 폴링을 시작하기 전에 슬롯을 돌려준다.
그 뒤로 버퍼를 쓸 일은 없었다. 결과:
| 클라이언트 완료 | /wait |
/start |
|
|---|---|---|---|
| 고치기 전 | 0 / 80 | 열림 80·닫힘 0 | 열림 32·닫힘 0 |
| 고친 뒤 | 80 / 80 | 열림 80·닫힘 80 | 열림 80·닫힘 80 |
그리고 찾고 있지 않던 숫자가 하나 더 나왔다. 열다섯 게이트 전체가
1291초에서 561초로, scale 하나만으로 765초에서 54초가 되었다.
게이트는 매번 12분을 데드락 안에 앉아서 보내고 있었다.
나는 그것을 “느린 게이트”라고 부르고 있었다.
3. 묻지 않은 채 “말이 안 된다”고 적은 기능
원래 글에는 “하지 않는 것”이라는 절이 있고, 그 첫 항목은 이렇다:
외부로의 NAT —— 변환해 갈 상류 인터페이스가 존재하지 않는다
이 문장은 NAT에 대해서는 맞고, 그 뒤에 있는 것에 대해서는 틀렸다. 기억하고 있던 설계에서 추론했을 뿐, 게스트의 커널에 무엇이 있는지 한 번도 묻지 않았다. 물어보니:
| netfilter 핵심 | 있음(내장) |
ip_tables·nf_nat |
없음 |
/dev/net/tun |
없음 |
| 적재된 모듈 | 8개 —— Image 옆에 동봉한 전부 |
그러니 정직한 표현은 “말이 안 된다”가 아니라 “모듈이 여섯 개 모자라다“이다. 이것은 값이고, 값은 대안과 비교할 수 있다 —— 그리고 “말이 안 된다”는 바로 그 비교를 세 번의 작업 시간 동안 막고 있었다.
값이 나오자 싼 쪽이 보였다. 정말 원하는 기능은 임의의 아웃바운드 TCP이고,
SOCKS5는 그것을 거의 공짜로 산다. 이미 돌고 있는 CONNECT 프록시와
같은 소켓 두 개, 같은 복사 루프이며, 첫 바이트가 어느 프로토콜인지 말해 주므로
하나의 포트로 둘 다 답할 수 있다. 그 한 바이트를 소비하지 않고 엿보는 것이 핵심인데 ——
SOCKS5의 인사는 3바이트이고 줄바꿈이 없으므로, 먼저 한 줄을 읽으면 10초를 기다린 끝에
올바르게 말한 클라이언트에게 405를 돌려주게 된다.
$ docker run --rm alpine sh -c 'printf "QUIT\r\n" | curl -sS telnet://smtp.gmail.com:25'
220 smtp.gmail.com ESMTP ...
네트워크 인터페이스를 한 장도 갖지 않은 컨테이너가, HTTP를 들어본 적도 없는 상대와 맨 TCP로 대화하고 있다.
그래도 나르지 못하는 것 —— 이것이 틀렸던 문장의 대체다 ——
은 어느 프로토콜도 말하지 않는 상대: nc·ping·psql.
이들은 경로를 원하고, 경로는 그 여섯 개를 원한다.
게이트는 페이지가 아니라 바이트로 묻는다.
무엇이든 받아들이는 프록시와 제대로 도는 프록시는,
페이지 한 장을 가져오는 검사로는 둘 다 초록이 되기 때문이다:
CONNECT는 0x00, 구현하지 않은 명령은 0x07,
존재하지 않는 이름은 0x04, 그리고 **프록시가 갖지 않은 인증 방식만 제시한
클라이언트에게는 상냥하게 “인증 불필요”가 아니라 0xFF**를 돌려준다.
4. 옵션 하나를 적었더니 명령 두 개가 사라졌다
가장 작고, 모양으로는 가장 순수한 것이다.
mvm --help는 스크립트 맨 위의 주석 블록이고, 이렇게 잘라내고 있었다:
usage() { sed -n '3,14p' "$0" | sed 's/^# \{0,1\}//'; }
그 블록에 한 줄을 더했다 —— 새 옵션을 설명하는 줄이다.
그것이 mvm status와 mvm doctor를 14행 밖으로 밀어냈다.
도구는 둘 다 계속 받아들였고, 말하기를 그만두었다.
어떤 테스트도 눈치챌 수 없다, 거기가 흥미로운 지점이다 —— 단지 문서에 없을 뿐인 옵션은, 이미 존재를 아는 사람에게는 완벽하게 동작한다. 어디에도 실패하는 동작이 없다. 찾는 방법은, 한 번도 맞춰 본 적 없는 둘을 맞춰 보는 것뿐이다: 인자 파서가 받아들이는 것과, 도움말이 인쇄하는 것.
그래서 맞춰 보는 것을 썼다. 쓴 직후에 문서에 없는 옵션을 네 개 찾았다:
--publish·--version·--kversion·--from-dir.
그리고 처음 썼을 때 나는 한 단 위에서 같은 종류의 실수를 했다 ——
파일 인자를 받으니 범용처럼 보이는데, 써서 맞춘 상대 하나에서만 동작했다.
두 번째 도구에 대어 보니, 다섯 옵션을 전부 올바르게 적어 둔 스크립트에 대해
“추출이 망가졌다”고 보고했다. 첫 번째 사용자로는 범용인 척을 간파할 수 없다.
모양
네 개의 결함, 하나의 모양:
- 게이트는 있었다. 그리고 문제가 되는 입력을 덮어쓰고 있었다
- 숫자는 설명할 수 없다는 이유로 요구가 아니라 보고로 되어 있었다
- 경계는 재어지지 않고 추론되고 있었다
- 일치해야 할 산출물 둘이 한 번도 맞춰지지 않았다
어느 것도 “테스트가 없었다”가 아니다. 넷 다 초록인 스위트 안에 있었고, 서로 일치하는 검사들 옆에 있었다. 매번 빠져 있던 것은 질문이고, 빠져 있던 이유는 매번 같다 —— 쉬운 쪽 질문도 같은 초록을 낸다.
나온 습관이 셋 있다. 전부 싸다:
- 자기 테스트 중 몇 개가 기본값을 덮어쓰는지 센다.
전부라면, 그 기본값은 아무도 한 번도 시도하지 않은 유일한 입력이다.
grep -c가 1초에 알려준다. - 설명할 수 없는 숫자가 나오면, 그 한 단 아래를 계기로 삼는다. 같은 숫자를 다섯 번째 재지 말고. “몇 개가 올라왔나”는 결과이고, “몇 개의 연결이 열리고 몇 개가 닫혔나“는 그것을 만드는 것이다. 그리고 양쪽 끝을 기록한다 —— 완료만 기록하는 계기는 보고 싶은 실패를 꼭 집어 숨긴다.
- “불가능”이라고 적기 전에 기계에 묻는다. 없는 부품을 이름으로 대고, 각각에 값을 매기고, 그러고 나서 정한다. 값이야말로 싼 대안을 보이게 한다.
원래 글은, 이 스택의 가장 값진 산물은 언어에서 찾아낸 결함의 목록이라고 하며 끝났다. 여기 첫 버그는 그 목록에 더해진다 —— 컴파일러가 64비트 정수에 대해 뱉는 C 선언은 언어 계약의 일부이고, 내 것은 조용히 폭을 좁히고 있었다. 하지만 나머지 셋은 만든 것이 아니라 확인하는 방식의 결함이었다. 그쪽이 더 멀리 옮겨 갈 수 있다고 생각한다. 이 스택은 내 것이고 별나다. “흥미로운 입력을 피하는 데 전원이 합의해 버린 테스트 모음”은 그렇지 않다.