Anubis에 WebAssembly 기반 작업 증명 검사를 도입하면서 wasm2js를 재현 가능하게 번들링하려다 마주친 컴파일러의 비결정성과 빌드 재현성 문제를 다룬다.
2026-06-18에 게시됨, 1665단어, 읽는 데 7분
같은 입력 바이트를 주면 같은 출력 바이트가 나올 거라고 생각할 것이다. ㅋㅋ. ㅎㅎ. 아니다, 그렇지 않다. 복잡하다.
Anubis에는 곧 WebAssembly 기반 작업 증명 검사가 들어갈 예정이라서, 관리자가 SHA256이 아닌 작업 증명 방식으로 웹사이트를 보호할 수 있게 된다. 이 작업의 구현 목표 중 일부는 검사 로직이 클라이언트와 서버 양쪽에서 한 곳에 정의되도록 하는 것이다. 그러면 클라이언트와 서버는 WebAssembly에 연결되어 서로 완전히 같은 보조를 맞춰 실행되고 있는지 확인할 수 있다.
하지만 작은 문제가 하나 생긴다. 클라이언트에서 WebAssembly가 비활성화되어 있으면 어떻게 할까? 나는 사람들이 사실상 웹사이트에서 배제되는 상황을 정말 원하지 않는다. Anubis는 사용자 경험, 관리자 경험, 개발자 경험 사이의 불가능한 균형 위에 존재하며, 이 요소들 중 하나라도 바꾸면 다른 요소들의 균형이 깨진다.
이 문제를 우회하면서도 검사 로직을 한 번만 정의한다는 목표를 이루기 위해, 나는 전설적인 발표 The Birth and Death of JavaScript에서 영감을 받아 WebAssembly를 JavaScript로 그냥 다시 컴파일하기로 했다. 물론 결과로 나온 JavaScript는 동등한 WebAssembly보다 느릴 것이다. WebAssembly를 끄면 보통 JavaScript를 빠르게 만드는 JavaScript JIT도 함께 꺼지는 경우가 많기 때문에 더더욱 그렇다. 그래도 언젠가는 끝나긴 할 것이다. 아마도 기존 JavaScript가 저사양 하드웨어에서 보이는 성능보다는 효율적일 수도 있지만, 그건 연구가 더 필요하다.
다행히 내가 필요한 도구(binaryen project의 wasm2js)는 Linux 배포판에 패키징되어 있다. 나쁜 소식은 배포판들이 너무 오래된 버전을 제공해서, 내 개발 머신의 Homebrew 사본에 있는 버전과 같은 출력을 내지 못한다는 점이다.
이 출력이 정말 결정적이라는 것을 보장하려면(재현 가능한 빌드에 필수적이다), wasm2js 사본을 번들해야 한다. 그래서 나는 wasi-sdk로 WebAssembly로 컴파일된 wasm2js 버전을 빌드해서 그렇게 했다. 이 글의 나머지는 내가 결국 도달한 구현으로 이어진 재현성 비극의 이야기다. 안전벨트를 매고 즐겨보자!
잠깐만, 이건 내게 말이 안 된다. 컴파일러에 같은 입력 바이트 가 들어가면, 컴파일러 플래그와 타깃, 기타 플랫폼 세부 사항이 통제된다는 가정 아래 같은 출력 바이트 가 나와야 하는 것 아닌가? 컴파일러는 그저 입력 소스 코드가 출력 바이트코드로 바뀌는 결정적 함수 아닌가?
ㅋㅋ 그렇게 생각하겠지만, 아니다. 이론적으로는 그렇다(그리고 소규모 컴파일러는 분명히 그렇다). 하지만 실제로 컴파일러는 기괴하고 복잡한 짐승이며, 어느 한 인간도 혼자서는 완전히 이해할 수 없는 온갖 것들을 품고 있다.
C/C++ 개발을 할 때 실수로 비결정적 출력을 만들어내는 방법은 놀랄 만큼 많다. 가장 쉬운 예 중 하나는 내장 매크로 __DATE__ 와 __TIME__ 을 사용해 빌드에 컴파일러가 실행된 시각을 찍어 넣는 것이다:
// hello.cpp
#include <iostream>
int main() {
std::cout << __DATE__ << " " << __TIME__ << std::endl;
return 0;
}
한 번 빌드해서 실행하면 이렇게 나온다:
$ make clean && make hello.wasm && wasmtime run -W exceptions=y ./hello.wasm
rm -f hello.o hello.wasm
wasi-sdk-33.0-x86_64-linux/bin/wasm32-wasip1-clang++ -O3 -fwasm-exceptions -mllvm -wasm-use-legacy-eh=false -c hello.cpp -o hello.o
wasi-sdk-33.0-x86_64-linux/bin/wasm32-wasip1-clang++ -O3 -fwasm-exceptions -mllvm -wasm-use-legacy-eh=false -fwasm-exceptions -lunwind --no-wasm-opt hello.o -o hello.wasm
Jun 18 2026 00:00:59
다른 때에는 이렇게 나온다:
$ make clean && make hello.wasm && wasmtime run -W exceptions=y ./hello.wasm
rm -f hello.o hello.wasm
wasi-sdk-33.0-x86_64-linux/bin/wasm32-wasip1-clang++ -O3 -fwasm-exceptions -mllvm -wasm-use-legacy-eh=false -c hello.cpp -o hello.o
wasi-sdk-33.0-x86_64-linux/bin/wasm32-wasip1-clang++ -O3 -fwasm-exceptions -mllvm -wasm-use-legacy-eh=false -fwasm-exceptions -lunwind --no-wasm-opt hello.o -o hello.wasm
Jun 18 2026 00:01:11
소스 코드가 같은 바이트 였음에도, 컴파일러의 출력은 완전히 달라졌다.
사용자와 패키저가 내가 Anubis 저장소에 커밋하는 wasm2js 바이너리를 신뢰할 수 있게 하려면, 내가 빌드한 것과 완전히 같은 바이트 수준까지 같은 버전을 빌드할 수 있다는 점을 보장해야 한다. 추가 보너스로, 당신의 머신 에서도 이것을 빌드해 내가 얻은 것과 같은 바이트를 얻을 수 있어야 한다.
정말 멋진 이상처럼 들린다. 그걸 망쳐버릴 예상치 못한 일이 생기면 참 끔찍하겠네!
$PATH 의 wasm-opt 를 실행한다wasm2js 같은 도구들 외에도 binaryen에는 wasm-opt 같은 유용한 도구가 잔뜩 있다. wasm-opt 는 WebAssembly 컴파일러 출력을 최적화해서 성능을 조금 더 짜낼 수 있게 해준다. 이게 모든 상황에서 작동하는 것은 아니지만, 작동할 때는 차이가 엄청나게 크다. 그래서 clang는 빌드 중에 wasm-opt 를 셸로 호출한다.
보통은 이게 말이 되지만, 이번 경우에는 내 DGX Spark의 wasm-opt 버전이 너무 오래돼서 빌드 실패의 원인이 되었다:
$ uname -m && which wasm-opt && wasm-opt --version
aarch64
/usr/bin/wasm-opt
wasm-opt version 108
반면 내 워크스테이션은 Homebrew 에서 wasm-opt 를 설치한다:
$ uname -m && which wasm-opt && wasm-opt --version
x86_64
/home/linuxbrew/.linuxbrew/bin/wasm-opt
wasm-opt version 130
알고 보니 wasi-sdk와 binaryen은 WebAssembly Exceptions extension에 의존한다. wasi-sdk가 대체로 웹 브라우저용 빌드를 전제로 하고 있고 브라우저 사용자의 93.86%가 이를 지원할 만큼 충분히 새로운 브라우저 엔진을 쓰고 있으니, 이 가정은 합리적이다. 또한 C++는 예외가 많이 사용되는 대표적인 환경 중 하나이므로, WebAssembly 네이티브 예외 처리는 여기서 많은 반복 코드를 없애주는 것 같다.
wasmtime와 wazero 둘 다 예외 지원을 명시적으로 켜야 한다. 이건 괜찮다. wasmtime에는 그냥 -W exceptions=y 를 넘기고, wazero에는 사용자 정의 실행 하네스를 쓰면 된다. 짜증나는 부분은 내 arm 머신의 빈약한 wasm-opt 빌드가 예외 처리 명령어를 보자마자 종료해버리는 상황이다. 이 때문에 빌드가 실패했다.
해결책은 링크 단계에서 --no-wasm-opt 를 넘기는 것이었다. 이렇게 해서 재현 불가능성의 한 갈래를 제거했다.
아마 미래에는 방금 빌드한 wasm-opt 버전을 사용해 출력을 최적화하도록 만들 수도 있겠지만, 지금으로서는 너무 이른 최적화일지도 모르겠다.
내가 wasm2js 를 컴파일하는 데 쓰는 clang 버전에는 예외 처리 경로에 주소 민감적인 코드 생성이 숨어 있다. 몇 개 안 되는 try_table 블록의 출력 순서에 raw pointer 값이 스며든다. 그 결과 각 빌드는 다음 빌드와 약 29바이트 정도 차이가 난다:
-002a9af0: 2802 0441 0647 0d00 1f40 0103 0820 0241 (..A.G...@... .A
-002a9b00: 206a 2103 2002 4138 6a20 0141 086a 10b5 j!. .A8j .A.j..
-002a9b10: 8881 8000 2104 0b1f 4001 0304 2003 2004 ....!...@... . .
+002a9af0: 2802 0441 0647 0d00 1f40 0103 041f 4001 (..A.G...@....@.
+002a9b00: 0309 2002 4120 6a21 0320 0241 386a 2001 .. .A j!. .A8j .
+002a9b10: 4108 6a10 b588 8180 0021 040b 2003 2004 A.j......!.. . .
이걸 더 쉽게 보도록 부분 디스어셈블리를 보면 다음과 같다:
i32.load offset=4 ;; 28 02 04
i32.const 6 ;; 41 06
i32.ne ;; 47
br_if 0 ;; 0d 00
- try_table (catch_all_ref 8) ;; 1f 40 01 03 08
+ try_table (catch_all_ref 4) ;; 1f 40 01 03 04
+ try_table (catch_all_ref 9) ;; 1f 40 01 03 09
local.get 2 ;; 20 02
i32.const 32 ;; 41 20
i32.add ;; 6a
local.set 3 ;; 21 03
local.get 2 ;; 20 02
i32.const 56 ;; 41 38
i32.add ;; 6a
local.get 1 ;; 20 01
i32.const 8 ;; 41 08
i32.add ;; 6a
call 17461 ;; 10 b5 88 81 80 00
local.set 4 ;; 21 04
end ;; 0b
- try_table (catch_all_ref 4) ;; 1f 40 01 03 04
local.get 3 ;; 20 03
local.get 4 ;; 20 04
계산 자체는 거의 동일하지만, 바이트 순서가 미묘하게 달라서 catch 참조도 함께 달라진다. 이 고정된 버전의 wasm2js를 arm64 머신에서 빌드할 때도 이런 일이 발생하는데, 그 머신의 포인터 순회 순서가 내 워크스테이션과 다르기 때문이다.
이를 우회하기 위해 나는 두 단계를 밟았다:
setarch --addr-no-randomize 를 사용해 이 빌드에서 주소 공간 난수화를 비활성화한다.그리고 이를 보장하는 CI 작업도 추가했다:
- name: Ensure reproducibility
run: |
cd ./utils/wasm/wasm2js
./build.sh
if sha256sum -c --status shasums.x86_64; then
echo "OK: rebuilt modules match the recorded x86_64 checksums"
elif sha256sum -c --status shasums.arm64; then
echo "OK: rebuilt modules match the recorded arm64 checksums"
else
echo "::error::rebuilt wasm2js/wasm-opt match neither recorded checksum set on ${{ matrix.runner }}" >&2
sha256sum wasm-opt_130.wasm wasm2js_130.wasm
exit 1
fi
더 확실하게 하려고, 우리는 이 작업을 x86_64와 arm64 호스트 양쪽에서 모두 실행한다. 호스트를 가로질러서도 재현 가능했으면 정말 좋겠지만, 그건 내가 감당하기에는 너무 큰 상류 LLVM 버그다. LLVM에서 일하고 이 글을 읽고 있다면, 이런 순회 순서가 아키텍처 간에도 고정되도록 어떤 종류의 시드를 설정할 수 있으면 좋겠다.
적어도 아키텍처 내부에서는 빌드가 결정적이다. 지금으로서는 이 정도면 충분해야 할지도 모르겠다.
게시 이후 사실과 상황이 달라졌을 수 있다. 뭔가 잘못되었거나 불분명해 보인다면 성급한 결론을 내리기 전에 먼저 내게 연락해 달라.
태그:
저작권 2012-2026 Xe Iaso. 여기에 적힌 모든 의견은 전적으로 내 개인적인 것이며, 나의 과거, 미래 및/또는 현재 어느 고용주도 대변하지 않는다.
4b07cfe1 사이트 버전의 xesite v4 (/app/bin/xesite)로 제공됨, 소스 코드는 here에서 볼 수 있다.