Skip to main content

再現性のある Canister のビルド

コンセンサスプロトコルのおかげで、 Internet Computer は常に Canister のコードを正しく実行することができます。しかし、これは Canister の 正しい コードが実行されているという意味ではありません。誰かが開発した Canister を使用する場合、 Canister に重要な決定(例えば、ICP を Canister に送るなど)をさせる前に、その Canister が本当に意図したコードを実行しているか確認したいと思うかもしれません。これを確認するためには、ふたつの質問に答えることで検証できます。

  1. Canister のために実行されている WebAssembly(Wasm)コードはどれですか?

  2. Canister は通常、Motoko や Rust などの高級言語で書かれており、Wasm で直接書かれているわけではありません。よって、実行されている Wasm は本当にソースコードと称するものをコンパイルした結果なのか?

この後は、これらの質問にお答えします。最初の質問に対しては、 Internet Computer がどのように Canister コードに関する情報を提供するかを見ていきます。2番目の質問に答えられるように、 Canister の作者はソースから信頼できる、Wasm コードの再現可能なビルドを保証しなければなりません。このようなビルドを使えば、誰でも Canister の作者と同じ手順を踏んで、まったく同じ Wasm コードを得ることができ、それを Internet Computer 上の Canister で実行されている Wasm コードと比較することができます。

再現性のあるビルドの話題に慣れているせっかちな読者は、そのまままとめに飛べばよいでしょう。

Internet Computer が実行している Canister コードを調べる

Internet Computer では、任意の Canister の Wasm コードにアクセスすることはできません。これは、開発者が一部のコードを非公開にしたい場合があるため、設計上の決定です。ただし、 Internet Computer では、Canister の Wasm コードの SHA-256 にアクセスすることができます。

このハッシュを取得するには、まず、コードを確認したい Internet Computer Canister の Principal を記録する必要があります。たとえば、Prinsipal が rdmx6-jaaaa-aaa-aaadq-cai である Internet Identity Canister のコードに関心があるとします。このサービスにアクセスする最も簡単な方法は、ターミナルから dfx ツールを使用することです。ターミナルを開いて、以下を実行してください。

$ dfx canister --network ic info rdmx6-jaaaa-aaaaa-aaadq-cai
Controllers: r7inp-6aaaa-aaaaa-aaabq-cai
Module hash: 0x2d95e90de5d7de11f25ac256690aff44c6685a1570b1becdf6e50192e983e103

古いバージョンの dfx を使用している場合は、有効な dfx.json ファイルを含むディレクトリからこのコマンドを実行する必要があります。もし、そのようなディレクトリがなければ、dfx new を使って作成することができます。

ここで、Internet Computer は、rdmx6-jaaaa-aaa-aadq-cai Canister (これはたまたま Internet Identity Canister である)のWasm モジュールのハッシュが0x2d95e90de5d7de11f25ac256690aff44c6685a1570b1becdf6e50192e983e103 だと教えてくれてます。

上記のチェックで、 Canister の Wasm モジュールの現在のハッシュがわかりますが、 Internet Computer の Canister のコントローラーは、いつでもコードを変更できます(たとえば、 Canister をアップグレードする場合など)。しかし、コントローラーのリストが空だったり、コントローラーがブラックホール Canister だけだったりすると、誰もコードを変更する権限を持っていないので、その Canister はイミュータブルであることがわかります。

このハッシュを手に入れたら、次にそれが与えられたソースコードに対応しているかどうかをチェックすることができます。これは、そのコードのビルドプロセスが再現可能である場合にのみ機能します。

再現性のあるビルド

Canister の作成者として、ユーザーがあなたのビルドを再現できるようにするために、提供しなければならないものがいくつかあります:

  • Canister の Wasm モジュールを作成したときの同じソースコード。

  • ビルド環境を再構築する方法の説明。

  • ソースコードから Wasm を構築するプロセスを繰り返す方法に関する説明。ここで重要なのは、このプロセスが決定論的であり、まったく同じ Wasm になることを保証する必要があることです。また、Wasm がソースコードの忠実な翻訳であり、悪意のあるビルドツールの成果物ではないことをユーザーが確信できるよう、信頼できるものでなければなりません。特に、.dfxnode_modulestarget ディレクトリにはビルド前のファイルが含まれている可能性があるため、注意が必要です。

次に、それぞれのポイントについて詳しく見ていきます。

ソースコードを提供する

一般的に、git やその他のバージョン管理システムでコードをバージョン管理し、バージョン管理されたコードは GitHub などの公開リポジトリで利用できるでしょう。この場合、Internet Computer にデプロイするコードを作成する際に使用した特定のコミットを記録し、あなたの Canister を検証しようとしているユーザーに伝える必要があります。また、Canister のビルドに使用したソースコードを含むパッケージ(zip ファイルや tarball など)を提供することもできます。

ビルド環境を再現する

コードをビルドする前に、使用しているビルド環境を詳細に文書化する必要があります。特に、Internet Computer SDK がサポートする言語については:

  • Canister をビルドするために使用している OS とそのバージョンをメモして下さい。

  • もし、dfx を使用している場合は、dfx.json で指定されている使用するバージョンをメモしてください。任意のバージョンの dfx をインストールするには、 dfx toolchain install <version> を使用するか、環境変数 DFX_VERSION に任意のバージョンを設定してインストールスクリプトを実行します。

  • もし、dfx build 以外の方法で Motoko コードをビルドしている場合は、使用している moc のバージョンをメモしてください。

  • Rust をビルドしている場合は、使用している cargo のバージョンをメモしてください。

  • フロントエンドの開発に Node.js や webpack を使用している場合、それらのバージョンをメモしてください。

  • ビルドプロセスが環境変数(タイムゾーンやロケールなど)に依存する場合はメモしておきます。

これら全てを説明書の中でユーザーに伝える必要があります。理想的には、Docker や Nix などのツールを使って、ビルド環境を再現するための実行可能なレシピを提供することで行います。Docker を使用すると、ソフトウェアのビルドに使用されたオペレーティングシステムを特定することができるので、Docker の使用をお勧めします。

Docker を用いた環境をビルドする

Docker コンテナは、ビルド環境を提供するための一般的なソリューションです。OS X を使用している開発者には、lima を使用して Docker をインストールすることをお勧めします。これは我々の経験上、Docker Desktop や Docker Machine よりも安定していることが証明されています(特に、Apple M1 マシンにおけるいくつかの QEMU バグを回避することができます)。Docker をセットアップした後、以下のような Dockerfile を使用して、ユーザに特定のバージョンの OS、dfx、Node.js、Rust ツールチェインを提供することが可能です。Docker コンテナの実行には、必ず x86_64 を使用してください。一般的に、ビルドはアーキテクチャ間で再現性がありません。ホスト環境が x86_64 でない場合のクロスプラットフォームな Docker コンテナのセットアップについては docs を参照してください。

FROM ubuntu:22.04

ENV NVM_DIR=/root/.nvm
ENV NVM_VERSION=v0.39.1
ENV NODE_VERSION=18.1.0

ENV RUSTUP_HOME=/opt/rustup
ENV CARGO_HOME=/opt/cargo
ENV RUST_VERSION=1.62.0
ENV IC_CDK_OPTIMIZER_VERSION=0.3.4

ENV DFX_VERSION=0.11.0

# Install a basic environment needed for our build tools
RUN apt -yq update && \
apt -yqq install --no-install-recommends curl ca-certificates \
build-essential pkg-config libssl-dev llvm-dev liblmdb-dev clang cmake rsync

# Install Node.js using nvm
ENV PATH="/root/.nvm/versions/node/v${NODE_VERSION}/bin:${PATH}"
RUN curl --fail -sSf https://raw.githubusercontent.com/creationix/nvm/${NVM_VERSION}/install.sh | bash
RUN . "${NVM_DIR}/nvm.sh" && nvm install ${NODE_VERSION}
RUN . "${NVM_DIR}/nvm.sh" && nvm use v${NODE_VERSION}
RUN . "${NVM_DIR}/nvm.sh" && nvm alias default v${NODE_VERSION}

# Install Rust and Cargo
ENV PATH=/opt/cargo/bin:${PATH}
RUN curl --fail https://sh.rustup.rs -sSf \
| sh -s -- -y --default-toolchain ${RUST_VERSION}-x86_64-unknown-linux-gnu --no-modify-path && \
rustup default ${RUST_VERSION}-x86_64-unknown-linux-gnu && \
rustup target add wasm32-unknown-unknown
RUN cargo install --version ${IC_CDK_OPTIMIZER_VERSION} ic-cdk-optimizer

# Install dfx
RUN sh -ci "$(curl -fsSL https://internetcomputer.org/install.sh)"

COPY . /canister
WORKDIR /canister

Rust プロジェクトをビルドするスクリプトの例は、以下のようになります:

#!/bin/bash
#
# additional setup, e.g., build frontend assets:
# ...
# Rust build:
export RUSTFLAGS="--remap-path-prefix $(readlink -f $(dirname ${0}))=/build --remap-path-prefix ${CARGO_HOME}=/cargo"
cargo build --locked --target wasm32-unknown-unknown --release
ic-cdk-optimizer target/wasm32-unknown-unknown/release/example_backend.wasm -o example_backend.wasm

このようなビルドスクリプトは、以下のように dfx.json にカスタムビルドスクリプトとして設定することも可能です:

"canisters": {
"example_backend": {
"candid": "src/example_backend/example_backend.did",
"package": "example_backend",
"type": "custom",
"wasm": "./example_backend.wasm",
"build": "./build_script.sh"
}
}

この Dockerfile には、いくつか注目すべき点があります:

  • 公式の Docker イメージから起動します。さらに、インストールされているツールはすべて標準的なものであり、標準的なソースから提供されています。このことは、ビルド環境が改ざんされていないこと、つまり Docker を使ったビルドプロセスが信頼できることをユーザに確信させるものです。

  • 特定のバージョンのビルドツールを確実にインストールするために、apt(コンテナ内で動作する Linux ディストリビューション、Ubuntu のパッケージマネージャ)経由ではなく、直接インストールするようになっています。このようなパッケージマネージャは、通常、ビルドツールを特定のバージョンに固定する方法を提供しません。

この Dockerfile を使用するには、Docker を up and running して、Dockerfile を Canister のプロジェクトディレクトリに配置し、実行して Docker コンテナを作成する必要があります:

$ docker build -t mycanister .

これは mycanister という Docker コンテナイメージを作成し、その中に Node.js、 Rust、 dfx をインストールし、Canister のソースコードを /canister にコピーします(Canister プロジェクトディレクトリから docker build を起動することを思い出してください)。そして、コンテナ内でインタラクティブなシェルを実行することができます:

$ docker run -it --rm mycanister

ここから、Canister をビルドするために必要なステップをテストすることができます。ステップが決定論的であると確信が持てたら、それらを Dockerfile に記述して(例えば、ビルドスクリプト ./build_script.sh に対して RUN ./build_script.sh として)ユーザーが自動的にあなたの Canister のビルドを再現できるようにすることも可能です。Internet Identity Canister の Dockerfile で例を見ることができます。次に、ビルドを決定論的にするために必要なことを調査します。

ビルドプロセスの決定性を確保する

ビルドプロセスが決定論的であるためには:

  1. Canister の依存関係が常に同じ方法で解決されていることを確認する必要があります。現在、ほとんどのビルドツールは、依存関係を特定のバージョンに固定する方法をサポートしています。

    • npm の場合、 npm install を実行すると、 package.json で指定した要件を満たす、プロジェクトの全ての(推移的な)依存パッケージの固定バージョンを含む package-lock.json ファイルが作成されます。しかし、 npm install は実行するたびに package-lock.json ファイルを上書きします。そのため、最終的な Canister を作成する準備ができたら、 npm install を一度だけ実行してください。その後、 package-lock.json をバージョン管理システムへコミットします。最後に、ビルドの再現性を確認する際には、 npm install の代わりに npm ci を使ってください。

    • Rust のコードの場合、Cargo は自動的に Cargo.lock ファイルを生成し、固定バージョンの(推移的な)依存性を持たせます。package-lock.json と同様に、Canister の最終版を作成する準備ができたら、このファイルをバージョン管理システムへコミットしてください。さらに、Cargo はデフォルトで依存関係のロックされたバージョンを無視します。ロックされた依存関係を使用するようにするには、 cargo コマンドに --locked フラグを渡してください。

    • Canister は Identity でお互いを参照するため、あらかじめ Canister ID を割り当てておく必要があります。

  2. あなた自身のビルドスクリプトには、非決定性を導入してはいけません。非決定性の明らかな原因は、ランダム性、タイムスタンプ、並行処理、コードの難読化などです。あまり明らかではありませんが、ロケール、ファイルの絶対パス、ディレクトリ内のファイルの順序、内容が変更される可能性のあるリモート URL なども含まれます。さらに、サードパーティのビルドプラグインに依存すると、これらによってもたらされる非決定性にもさらされることになります。

  3. 同じ依存関係と決定論的なビルドスクリプトを考えると、ビルドツール自体(Motoko では moc、Rust では cargo、フロントエンド開発ではデフォルトで webpack)も決定論的である必要があります。良いニュースは、これらのツールはすべて決定論的であることを目指していることです。しかし、これらのツールは複雑なソフトウェアであり、決定性を確保することは自明ではありません。したがって、非決定論的なバグは起こり得ますし、実際に起こっています。Rust については、Rust における現在の潜在的な非決定性問題のリストを参照してください。さらに、Linux と MacOS で Wasm にコンパイルされた Rust コードの違いが観察されたので、ビルドプラットフォームとそのバージョンを固定することをお勧めします。webpack については、バージョン5から使うべきモジュールとチャンクの ID の決定論的命名が導入されました。Motoko コンパイラは、決定論的かつ再現可能であることを目指しています。再現性に問題がある場合は、new issue を提出していただければ、可能な範囲で対処します。

再現性をテストする

もし、あなたのコードに再現性が不可欠であるなら、ビルドをテストして再現性に対する自信を深めるべきです。このようなテストは自明ではありません。私たちは、 Canister ビルドの非決定性が現れるまでに1ヶ月かかったという実例を見たことがあります!幸いなことに、Debian Reproducible Builds プロジェクトは reprotest というツールを作成し、再現性テストの自動化を手助けしてくれます。これは、パスや時間、ファイルの順序などの特性が異なるふたつの異なる環境でビルドを実行し、その結果を比較することでビルドをテストします。reprotest を使ってビルドをチェックするには、Dockerfile に以下の行を追加してください:

RUN apt -yqq install --no-install-recommends reprotest disorderfs faketime rsync sudo wabt

dfx build --network ic を使用する場合、フロントエンドの依存関係をプレビルドする必要があります(例えば、dfx build --network ic の前に npm ci を実行するか、ビルドスクリプトで dfx.json にカスタムビルドタイプを設定し npm ci を実行してください)。またプロジェクトディレクトリには Internet Computer 上の Canister ID を含む canister_ids.json ファイルを保存しておくとよいでしょう。`canister_ids.json`ファイルの例は以下のようになります:

{
"example_backend": {
"ic": "rrkah-fqaaa-aaaaa-aaaaq-cai"
},
"example_frontend": {
"ic": "ryjl3-tyaaa-aaaaa-aaaba-cai"
}
}

Canister プロジェクトのルートディレクトリから、以下のように dfx のビルドの再現性をテストすることができます:

$ docker build -t mycanister .
...
$ docker run --rm --privileged -it mycanister
/canister# mkdir artifacts
/canister# reprotest -vv --store-dir=artifacts --variations '+all,-time' 'dfx build --network ic' '.dfx/ic/canisters/*/*.wasm'

最初のコマンドは、先に提供された Dockerfile を使って Docker コンテナをビルドします。2つ目のコマンドは、コンテナ内でインタラクティブなシェル(-it フラグ)を開きます。ここでは特権モードで実行します(--privileged フラグ)。これは、reprotest がカーネルモジュールを使用して、ビルド環境のバリエーションを増やしているためです。また、いくつかのバリエーションを除外することで、非特権モードで実行することもできます。reprotest manual を参照してください。--rm フラグは、シェルを閉じた後にコンテナを破壊します。最後に、コンテナ内にビルド用のディレクトリを作成し、冗長モードで reprotest を起動します(-vv フラグ)。最初の引数として、実行したいビルドコマンドを与える必要があります。ここでは、dfx build --network ic とします。もし、別のビルドプロセスを使用している場合には、調整してください。これで、ふたつの異なる環境でビルドが実行されます。最後に、ふたつのビルドの最後に比較するパスを reprotest に指定する必要があります。ここでは、.dfx/ic ディレクトリにある、すべての Canister 用の Wasm コードを比較します。Rust コンパイラは動的なメモリ割り当てに jemalloc を使用しており、このライブラリは reprotest が時間変化の実装に使用している faketimecompatible はないため、時間変化の実装を省略しています。しかし、手動でシステム時刻を変更しながら、 reprotest が生成する成果物を比較することをお勧めします。

比較の結果、差異が見つからなかった場合は、このような出力が表示されます:

=======================
Reproduction successful
=======================
No differences in ./.dfx/ic/canisters/*/*.wasm
6b2a15a918219138836e88e9c95f9c5d2d7b6d465df83ae05d6fd2b0f14f8a97 ./.dfx/ic/canisters/example_backend/example_backend.wasm
a047686c1d517e21d447bcd42c9394a12cdb240e06425b830c99d3a689b5ee20 ./.dfx/ic/canisters/example_frontend/assetstorage.wasm
a047686c1d517e21d447bcd42c9394a12cdb240e06425b830c99d3a689b5ee20 ./.dfx/ic/canisters/example_frontend/example_frontend.wasm

おめでとうございます。これは、ビルドが環境の影響を受けていないことを示す良い指標です! reprotest では、依存関係が適切に固定されているかどうかをチェックすることはできませんので注意ください。さらに、コンテナ reprotest のビルドを複数のホスト OS で実行し、結果を比較することをお勧めします。比較の結果、ふたつのビルドで生成された Wasm コードの間に違いが見つかった場合、差分が出力されます。このとき、 reprotest--store-dir フラグを使用して、出力と diff を分析できる場所に保存したいと思うことでしょう。もし、再現性の確保に苦労しているのであれば、 DetTrace の使用も検討してください。これは、任意のビルドを決定的にするためのコンテナ抽象化機能です。

最後に、ビルドの再現性を確保した後でも、長期的に考慮すべきことがあります。

長期的な視点での考察

Canister コードを何年も使用し、再現性を維持することを期待する場合、再現性はより厳しいものになります。最大の課題は以下のとおりです:

  1. ビルドツールチェーンは今後も利用可能であるか

  2. 依存関係が有効か

  3. ツールチェーンはまだ動作し、依存関係を正しくビルドするか

ディストリビューションやパッケージアーカイブは、あなたのツールチェーンとその依存関係を含む、古いバージョンのパッケージを削除することがあります。ウェブサイトはオフラインになり、URL が機能しなくなるかもしれません。したがって、あなたのツールチェーンと依存関係をすべてバックアップしておくことが賢明です。Software Heritage のような、大規模なプロジェクトに参加することを検討すべきです。後日、ある時点で、 Canister がまだビルドできるように、ビルドプロセスを調整しなければならないかもしれません(例えば、URL の変更など)。ビルドが変更されても、同じ結果が得られるのであれば、ユーザーは Canister が正しいコードを実行していると確信することができます。Software Heritage プロジェクトのような、信頼できるソースからの依存関係があれば、信頼性の議論はより簡単になります。

まとめ

Canister の作成者への推奨事項をまとめます:

  • 理想的には、コンテナコードの最終版を制作する際に、Docker または同様の技術を使用して、OS とビルドツールを有効にセットアップし、ユーザーのためにそれらのバージョンを修正することです。使用しているビルドツールが完全に再現可能なビルドを保証しない場合、Docker はパスや環境変数などの違いを最小化することでも支援することができます。

  • ビルドツールとベースとなる Docker イメージは、ユーザーが信頼できるところから提供されるべきです。

  • Rust と Motoko のコンパイラは決定論的であることを目指しており、そのため再現性のあるビルドをサポートしています。もし、非決定論に気づいたら、バグレポートをしてください。

  • NPM を使用する場合、すべての依存関係のバージョンを正確に指定してください(package_lock.json を git リポジトリにコミットしてください!)。ビルドを再現するために、install ではなく、ci コマンドを使って NPM を起動します。同様に、Rust パッケージの場合は、 Cargo.lock をリポジトリにコミットし、パッケージのビルド時に cargo build --locked を使用します。

  • Webpack のビルドは決定論的であるべきですが、obfuscators や類似のツールは再現性を損なう可能性があります。決定論的なチャンクとモジュール ID を使用するようにしてください。

  • ビルドツールは完璧ではないので、再現性のあるビルドを保証できない場合があります。もし、あなたの Canister にとって再現性が重要であれば(例えば、他のユーザーの資金を保管しているなど)、 テストしてください。Reprotest はこの目的のために有用なツールです。

  • 理想的には、依存関係の数は最小限にしたいものです。完全な監査を行うためには、ユーザーは依存関係もすべて(再現可能な形で)再構築しなければならないかもしれないからです。

  • 再現性の確保は、時間軸が長くなると難しくなります。これは主に、依存関係やビルドツールの信頼できるソースを利用し続ける必要があるためです。

最後に、ビルドが再現可能であれば、出来上がった Wasm コードのハッシュと、 Canister で実行中のコードのハッシュを比較し、以下のように取得することが可能です:

$ dfx canister --network ic info <canister-id>

コントローラーが Canister コードをアップグレードした場合、このハッシュが変更される可能性がありますので注意してください。