리눅스에서 멀티스레드 프로그램의 경쟁 상태(race condition)를 재현하고 고치는 일은 오래된 난제다. 버그가 스케줄링 타이밍에 의존하기 때문에, 어제는 터지던 문제가 오늘은 멀쩡히 통과하고, 그 사이에서 개발자는 무엇이 달라졌는지조차 알기 어렵다. Rewind VM은 이 문제를 정면으로 겨눈 결정론적 가상머신으로, Nix 빌드의 모든 실행을 입력값의 순수 함수로 다룬다. 여기서 핵심은 '입력값'에 스레드 스케줄까지 포함된다는 점이다. 같은 입력이면 종료 코드 하나하나까지 동일하게 재생되고, 스케줄을 의도적으로 흔들면 평소 숨어 있던 버그가 드러난다.
재현되지 않던 버그를 재현한다
저자가 든 예시는 교과서적이다. 두 스레드가 한 은행 계좌에 입금한다. 각 입금은 잔액을 읽고, 장부에 한 줄을 쓰고, 읽은 값에 입금액을 더해 저장한다. 한 창구가 다른 창구의 '읽기'와 '저장' 사이에 끼어들면, 낡은 잔액을 덮어써서 돈이 사라진다. 16코어 노트북에서 1,000번을 돌리면 396번이나 돈을 잃었지만, taskset으로 단일 코어에 고정하자 1,000번 중 한 번도 잃지 않았다. 코어 하나로는 입금 도중에 스레드가 바뀌는 일이 드물기 때문이다. 바로 이 지점이 Rewind가 스케줄을 교란하는 이유다.
Rewind의 VM은 CPU가 하나뿐이라 첫 실행은 단일 코어처럼 통과한다. 대신 rewind check가 빌드를 여러 교란된 스케줄로 다시 돌려, 게스트 커널에게 서로 다른 단계에서 재스케줄을 요청한다. 두 실행은 step 3237까지 완전히 동일하게 흘러가다가, 실패하는 쪽만 그 지점에서 재스케줄을 받는다. 노트북에서 이 검사는 11초가 걸렸다. 결과적으로 '첫 실패가 일어나는 단 하나의 단계'를 집어내 준다.
어려운 일은 이미 Nix가 끝내놨다
저자가 반복해서 마주친 깨달음은, 기능 하나하나가 큰 작업일 줄 알았는데 정작 어려운 부분은 Nix가 이미 해결해 두었다는 것이다. 디버거가 제대로 돌려면 프로그램의 정확한 입력, 디버그 심볼, 소스, 그 아래 모든 라이브러리의 소스, 그리고 남의 컴퓨터에서도 이 전부를 똑같이 재현할 방법이 필요하다. 그것이 바로 derivation이 담고 있는 정보다. check에 넘기는 인자는 derivation이며 flake 참조가 될 수 있는데, Nix가 소스·컴파일러·라이브러리·커널·VM 설정이라는 입력 전체를 알고 있으므로 재현에 더 필요한 것이 없다. rewind nix는 derivation의 입력을 실현해 클로저를 읽기 전용 erofs 이미지로 묶고 그 위에서 VM을 부팅한다. 실행 id는 store path처럼 입력의 해시이고, rewind show는 그 실행을 다시 만드는 명령을 출력한다.
신뢰성 검증도 같은 토대 위에 있다. 빌드가 성공하면 게스트가 각 출력의 NAR 해시를 보고하고, rewind nix는 이를 호스트의 사본과, Nix가 치환해 오는 모든 바이너리 캐시에 대해 .narinfo만 가져와 대조한다. VM 안의 빌드가 노트북의 빌드와 같고, VM의 실행이 Nix 기준으로 재현 가능함을 이렇게 확인한다.
소스·심볼·상태까지 공짜로
소스를 보며 디버깅하는 것이 당연히 편하다. Rewind 앱의 새 패널이나 터미널의 rewind where는 재생 위치(playhead)에서의 소스와 그것을 호출한 스택 프레임을 보여준다. 소스의 위치 역시 Nix가 알려준다. nixpkgs는 separateDebugInfo로 패키지를 빌드하고 디버그 정보는 cache.nixos.org에 debug 출력으로 캐시되므로, debuginfod로 build ID만 가지고 디버그 정보와 소스를 가져올 수 있다. 덕분에 VM 안 어떤 바이너리는 물론 리눅스 커널의 소스까지 들여다볼 수 있다. 소스만으로 부족할 때는 rewind gdb가 playhead 지점에서 실행을 포크해 모든 스레드를 띄운 gdb를 연다. 중단점·감시점을 걸고 메모리·레지스터·변수를 볼 수 있으며, gdb가 무엇을 하든 녹화본은 바뀌지 않아 같은 단계로 되감아 다시 포크하거나 다른 단계에서 포크해도 동일한 상태가 재현된다. 그래도 모자라면 rewind shell --with nixpkgs#strace처럼 nixpkgs의 임의 패키지를 PATH에 올린 셸을 특정 단계에서 열 수 있는데, 이 또한 이미지 하나를 더 묶는 일일 뿐이다.
Rewind는 두 실행을 나란히 비교하기도 쉽다. Compare 탭 또는 rewind compare는 두 실행이 공유한 마지막 이벤트들과 처음으로 갈라지는 이벤트를 보여준다. 실패한 실행에서는 창구 2가 150부터, 통과한 실행에서는 200부터 시작하는 식으로 차이가 표시된다. 더 흥미로운 것은 스레드 레인이다. VM이 단일 CPU라 어떤 순간에도 정확히 한 스레드만 돈다. 경쟁 상태는 결국 '누가 언제 돌았는가'의 순서 문제인데, 트레이스는 쓰기·open·fork·exit·시그널 같은 '무엇을 했는지'만 기록할 뿐 그 사이 공백에 누가 CPU를 쥐었는지는 말해주지 않는다. Rewind는 새로 아무것도 녹화하지 않고 실행을 한 단계씩 되걸으며 매 단계마다 게스트 커널에게 지금 어느 스레드가 CPU에 있는지 물어 그 공백을 채운다. 실패한 실행에서 창구 1(스레드 140)은 잔액을 읽고 저장하기 전인 step 3238에서 끊기고, 창구 2(스레드 141)가 step 3239 단 한 단계 동안 CPU를 쥐어 잔액을 읽은 뒤 창구 1이 마무리하는 장면이 그대로 드러난다.
얼마나 흔한 버그인가
나쁜 인터리빙을 하나 찾으면 다음 질문이 따라온다. 그게 얼마나 일어날 법한 일인가, 통과한 실행이 정상인가 아니면 운이 좋았던 것인가. rewind check는 보통 derivation 전체를 여러 스케줄로 다시 빌드해 답하지만, --run을 쓰면 이미 가진 실행에서, 그것도 원하는 단계부터 시작한다. 그 지점에서 스케줄마다 한 번씩 포크해 이후 스레드 순서를 다르게 돌리고, 몇 개가 다르게 끝나는지 센다. 그 단계 이전은 전혀 손대지 않는다. 은행 예시에서 두 실행이 갈라진 step 3221부터 16개 스케줄을 돌리자, 자기 자신인 스케줄 0을 제외한 나머지가 모두 돈을 잃었다. 앱에서는 playhead의 컨텍스트 메뉴에 있는 'Check from here'가 같은 일을 한다. 또한 b 키로 playhead의 단계에 메모를 담은 북마크를 남길 수 있고, 북마크는 실행과 함께 .rwd로 내보내져 실행을 건네받은 사람이 타임라인 위 메모와 함께 열어 본다.
실무자 입장에서 이 글의 메시지는 분명하다. 디버거가 짊어지던 '어려운 일'의 상당 부분은 재현 가능한 입력, 소스, 디버그 심볼, 그리고 그 전부를 남에게 그대로 전달할 방법인데, Nix derivation처럼 밀폐(hermetic)된 지점에서 출발하면 그것들이 거의 거저 주어진다. 다만 분명한 전제도 있다. 이 접근은 Nix 생태계와 결정론적 단일 CPU VM 위에서 성립하므로, 빌드를 Nix로 밀폐하지 않은 환경이나 실제 멀티코어에서만 드러나는 하드웨어 수준의 비결정성에는 그대로 옮겨오기 어렵다. 그럼에도 '빌드를 재현 가능하게 만드는 투자'가 디버깅 도구의 토대까지 공짜로 만들어 준다는 통찰은, Nix를 쓰지 않는 팀에도 한 번쯤 곱씹어 볼 만한 관점이다.