効果的なRust のキャニスタースマートコントラクトの開発: 有用なパターン(日本語訳)

tokuryoo
DfinityJP
Published in
42 min readSep 29, 2023

DFINITY 公式の記事 Building Effective Rust Canister Smart Contracts: Useful Patterns(2022/10/10) の日本語訳です。

Internet Computer ブロックチェーンで Rust キャニスターを作成・デプロイするための技術的な考慮事項。

このドキュメントの読み方

この文書は、私が Internet Computer(IC) 上で動作する Rust コードで観察した有用なパターンと典型的な落とし穴をまとめたものです。この文書に書かれていることは、すべて大目に見てください。私の問題ではうまくいった解決策が、あなたの問題では最適でない可能性があります。すべてのアドバイスには、あなた自身の判断を助けるための説明がついています。

私がより良いパターンを発見したり、エコシステムの状態が改善されたりすれば、いくつかの推奨事項は変更されるかもしれません。私はこの文書を常に最新の状態に保つよう努力するつもりです。

コード構成

キャニスターのステート

IC のキャニスターの構造上、開発者はグローバルなミュータブルステートを使うことを余儀なくされます。一方、Rust は意図的にグローバルなミュータブル変数を使いにくくしており、いくつかの選択肢が与えられています。どの選択が一番良いのでしょうか?

ステート変数に Cell/RefCellthread_local! を使用する。

この選択肢は最も安全なものです。メモリ破壊や非同期性の問題を回避できます。

thread_local! {
static NEXT_USER_ID: Cell<u64> = Cell::new(0);
static ACTIVE_USERS: RefCell<UserMap> = RefCell::new(UserMap::new());
}

それでは、他の選択肢も含めて、何が問題なのかを見ていきましょう。

  1. let state = ic_cdk::storage::get_mut<MyState>;
    Rust CDK は、型をインデックスとしたミュータブル参照を取得する storage の抽象化を提供しています。この抽象化の導入は、設計上あまり良い選択ではなかったと私は考えています。get-mut では、同じオブジェクトに対する複数の非排他的なミュータブル参照を取得することができますが、これでは言語保証が壊れてしまいます。これは簡単に自分の足を引っ張ることになります。
  2. static mut STATE: Option<State> = None;
    単純なグローバル変数。この方法では、グローバルなステートにアクセスするために定型文を書かなければならず、前の選択肢と同じ安全性の問題に悩まされます。
  3. lazy_static! { static STATE: RwLock<MyState> = RwLock::new(MyState::new()); }
    このアプローチはメモリーセーフですが、私は混乱すると思っています。キャニスターではスレッドが使えないので、すでにロックされているオブジェクトのロックを取得しようとするとどうなるかは明らかではありません。キャニスターはブロックできないので、ロックの取得に失敗すると trap します。つまり、コンパイル対象によってプログラムの意味が変わってしまうのです(wasm32-unknown-unknown vs ネイティブコード)。それは困ります。[以下の キャニスターコードのほとんどをターゲットに依存しないようにするを参照してください]。

非排他的なミュータブル参照がどのように追跡困難なバグにつながるかを見てみましょ う。最初は、並行処理がなければ害はないように思えるかもしれません。残念ながら、それは真実ではありません。例を見てみましょう。

#[update]
fn register_friend(uid: UserId, friend: User) -> Result<UserId, Error> {
let mut_user_ref = storage::get_mut<Users>() ①
.find_mut(uid)
.ok_or(Error::NotFound)?;let friend_id = storage::get_mut<Users>().add_user(&friend); ②mut_user_ref.friends.insert(friend_id); ③Ok(friend_id)
}

ここでは、ストレージの抽象化を使用する関数がありますが、単なる古いミュータブルなグローバルにも同じ問題があります。

  1. データ構造を指すミュータブルな参照を得ます。
  2. データ構造を変更する関数を呼び出します。この呼び出しによって、ステップ①で取得した参照が無効化されたかもしれないので、参照先がガーベージになっているか、他の有効なオブジェクトの途中を指している可能性があります。
  3. オブジェクトを変更するために、元のミュータブル参照を使用します。これはヒープの破壊につながるかもしれません。

実際のコードはもっと複雑で、キャニスターのメソッドから呼び出された関数で変異が起こるかもしれない、などです。この問題は、キャニスターが実際に使用され、重要なユーザーデータを保存する(そして破損する)までは、検出されないままかもしれません。RefCellを使用した場合、このコードが出荷される前にパニックを起こすでしょう(出荷前に少なくとも一度はコードを実行したと仮定して)。

これで、グローバル変数の宣言の方法がはっきりわかったと思います。では、どこに置くかについて説明しよう。

すべてのグローバルを1つのバスケットに入れる。

すべてのグローバル変数をプライベートにして、単一のファイル、つまりキャニスターの main ファイルに配置することを検討してください。この方法には、下記のいくつかの利点があります。

  • ほとんどのコードがグローバルに直接触れないので、テストが容易になります。
  • グローバルなステートがどのように使用されるかを理解することが容易になります。たとえば、すべてのステーブルデータがアップグレードの際に適切に持続されることを確認するのは、簡単です。

以下の例のように、どの変数がステーブルなのかを明確にするコメントを追加することも検討してください。

thread_local! {
/* stable ① */ static USERS: RefCell<Users> = … ;
/* flexible ② */ static LAST_ACTIVE: Cell<UserId> = …;
}

ここで Motoko 用語 を拝借します。

  1. ステーブル変数とは、システムがアップグレード後も保持するグローバル変数のことです。たとえば、ユーザーのデータベースはおそらくステーブルであるべきです。
  2. フレキシブル変数は、コードのアップグレード時にシステムが破棄するグローバル変数です。たとえば、キャッシュをホット(利用頻度が高い)にしておくことがプロダクトにとって重要ではない場合、キャッシュをフレキシブルにすることは合理的です。

キャニスターのコードをテストしようとしたことがある人は、開発ワークフローのこの部分がまだ洗練されていないことに気づいたのではないでしょうか。手っ取り早い方法は、既存の Rust インフラストラクチャにおんぶに抱っこすることです。これは、キャニスターをネイティブターゲットと WebAssembly の両方にコンパイルできる場合にのみ可能です。

キャニスターコードのほとんどをターゲットに依存しないようにする。

キャニスターのコードの大部分を疎結合のモジュールとパッケージに組み込み、それらを独立にテストするのが得策です。システム API に依存するコードの大半は、main ファイルに入れるべきです。

また、System API を薄く抽象化し、偽の、しかし忠実な実装であなたのコードをテストすることも可能です。例えば、以下のような trait を使って、stable memory API を抽象化できます。

pub trait Memory {
fn size(&self) -> WasmPages;
fn grow(&self, pages: WasmPages) -> WasmPages;
fn read(&self, offset: u32, dst: &mut [u8]);
fn write(&self, offset: u32, src: &[u8]);
}

非同期

キャニスターの panictrapはやや特殊です。コードが trap または panic に陥った場合、システムはキャニスターのステートを直近のワーキングスナップショットにロールバックします。残念ながら、これは、キャニスターが呼び出しを行い、コールバックで panic を起こした場合、キャニスターが呼び出し用に割り当てたリソースを解放しない可能性があることを意味します。

await後に panic しない

例を見てみましょう。

#[update]
async fn update_avatar(user_id: UserId, pic: ByteBuf ① ) {
let key = store_async(user_id, &pic)
.await ②
.unwrap(); ③
USERS.with(|users| set_avatar_key(user_id, key));
}
  1. このメソッドは、アバター画像のバイトバッファを受信します。
  2. このメソッドは、ストレージキャニスターへの呼び出しを発行します。バイトバッファは、ヒープ上に確保された Rust の future に取り込まれます。
  3. 呼び出しに失敗すると、キャニスターは panic に陥ります。システムは、キャニスターのステートを、コールバック呼び出しの直前に作成されたスナップショットにロールバックします。キャニスターの観点では、応答を待ち、ヒープ上に future とバッファを保持します。

破損はなく、キャニスターはまだ有効な状態ですが、メモリーなど一部のリソースは次のアップグレードまで解放されないことに注意してください。

システム API は最近、この問題に対処するために拡張されました(Internet Internet Computer Interface Specificationic0.call_on_cleanupを参照)。この問題は、Rust CDK の将来のバージョンで修正される可能性があります。

非同期とテストでのミスを経験する可能性のあるもう一つの問題は、あるリソースへの排他的なアクセスを長期間にわたって取得する future です。

await の境界を越えて共有リソースをロックしない。

#[update]
async fn refresh_profile_bad(user_id: UserId) {
let users = USERS_LOCK.write().unwrap(); ①
if let Some(user) = users.find_mut(user_id) {
if let Ok(profile) = async_get_profile(user_id).await { ②
user.profile = profile;
}
}
}#[update]
fn add_user(user: User) {
let users = USERS_LOCK.write().unwrap(); ③
// ...
}
  1. users マップへの排他的アクセスを取得し、非同期呼び出しを行っています。
  2. キャニスターのステートは、呼び出しが一時停止した直後にコミットされます。user マップはロックされたままです。
  3. ステップ②で発行された呼び出しが完了するまで、マップにアクセスしようとする他のメソッドは panic になります。

この問題は、panic と組み合わせると非常に厄介になります。重要なリソースをロックした後に panic になると、そのリソースは永遠にロックされたままになってしまいます。

これで、グローバル変数に thread_local! を使うことのもう一つの利点を理解する準備ができました。もし thread_local!を使っていたら、上記のコードはコンパイルできなかったでしょう。スレッドローカルの変数にアクセスするクロージャから非同期関数を await できないからです。

#[update]
async fn refresh_profile(user_id: UserId) {
USERS.with(|users| {
if let Some(user) = users.borrow_mut().find_mut(user_id) {
if let Ok(profile) = async_get_profile(user_id).await {
// The closure is synchronous, cannot await ^^^
// …
}
}
});
}

コンパイラは、コードを正しく書くように(確かに、エレガントではありませんが)働きかけてくれます。

#[update]
async fn refresh_profile(user_id: UserId) {
if !USERS.with(|users| users.borrow().has_user(user_id)) {
return;
}
if let Ok(profile) = async_get_profile(user_id).await {
USERS.with(|users| {
if let Ok(user) = users.borrow_mut().find_user(user_id) {
user.profile = profile;
}
})
}
}

キャニスターのインターフェイス

Motoko コンパイラがサポートするコードファーストアプローチを多くの人が楽しんでいます。パブリック関数を持つアクターを書くと、コンパイラーは自動的に対応する Candid ファイルを生成します。これは特に開発の初期段階では便利な機能です。

私は、クライアントを持つキャニスターの場合は、その逆であるべきだと説得してみます:Candid ファイルは、実装ではなく、真の情報源であるべきです。

.didファイルを真の情報源とする。

Candid ファイルは、あなたのキャニスターと対話したい人たち (フロントエンドで働くチームメンバーも含む) のための主なドキュメントソースとなります。インターフェイスは安定していて、簡単に見つけられ、よく文書化されている必要があります。これは、自動的に得られるものではありません。

type TransferError = variant {
// The debit account didn't have enough funds
// for completing the transaction.
InsufficientFunds : Balance;
// ...
};type TransferResult =
variant { Ok : BlockHeight; Err : TransferError; };service {
// Transfer funds between accounts.
transfer : (TransferArgs) -> (TransferResult);
}

「.did ファイルと実装が同期していることを確認するにはどうしたらよいですか?」と聞かれることがあります。これは素晴らしい質問です。その答えは、「Candid ツールを使う」です。

  • Rust CDKには、Canister のメソッドにアノテーションを付け、.didファイルを抽出するためのマクロがあります。
  • candid パッケージの最新版では、あるインタフェースが他のインタフェースのサブタイプであることをチェックする機能を備えています。これは Candid 用語で「後方互換性」を意味します。

エラーケースを示すためにvariant 型を使用する。

Rust のエラー型によって API 利用者がエラーから正しく回復することが容易になるように、Candid のバリアントによってクライアントがエッジケースを優雅に処理できるようになります。variant 型の使用は、Motoko のエラー処理の好ましい方法でもあります。

type CreateEntityResult = variant {
Ok : record { entity_id : EntityId; };
Err : opt variant {
EntityAlreadyExists : null;
NoSpaceLeftInThisShard : null;
}
};service : {
create_entity : (EntityParams) -> (CreateEntityResult);
}

しかし、サービスメソッドが result 型を返しても、呼び出しを拒否できることに注意してください。InvalidArgumentまたは Unauthorizedのようなエラー variant を追加しても、 おそらくあまりメリットはないでしょう。そのようなエラーからプログラム的に回復する意味のある方法はありません。ですから、不正なリクエストや無効なリクエスト、未承認のリクエストを拒否することは、 おそらくほとんどの場合において正しいことでしょう。

そこであなたはアドバイスに従って、エラーをvariantとして表現することにしました。インターフェイスの進化に合わせて、どのようにエラーコンストラクタを追加していくのでしょうか?

バリアントタイプを拡張可能にする。

Candid の variant 型は、後方互換性のある方法で進化させるのが難しいです。一つのアプローチは variant フィールドをオプションにすることです。

type CreateEntityResult = variant {
Ok : record { /* */ };
Err : opt variant { /* * /}
};

キャニスターの一部のクライアントが古いバージョンのインターフェイスを使用している場合、Candid デコーダーは未知のコンストラクタをnullに置き換えることができます。この方法には主に2つ問題があります。

  • Candid デコーダーはまだこのマジックを実装していません(dfinity/candid#295を参照)。
  • nullが表示されるだけで、問題を診断するのは大変な作業かもしれません。

代替案としては、エラー型をイミュータブルにして、拡張性を緩い型付けのキャッチオールケース(とドキュメント)に依存することです。

type CreateEntityResult = variant {
Ok : record { /* */ };
Err : variant {
EntityAlreadyExists : null;
NoSpaceLeftInThisShard : null;
// Currently defined errors
// ========================
// error_code = 401 : Unauthorized.
// error_code = 429 : Too many requests.
// error_code = 503 : Canister overloaded.
Other : record { error_code : nat; error_message : text }
}
};

このアプローチに従えば、新しく導入されたエラーが発生したときに、 クライアントはきれいなテキストによる説明を見ることができます。残念ながら、一般的なエラーをプログラムで処理するのは、よく型付けされた拡張可能なバリアントと比較して、より面倒でエラーが発生しやすいものです。

最適化

サイクル消費の削減

The first step towards an optimized system is profiling.

最適化されたシステムへの第一歩は、プロファイリングです。

エンドポイントが消費する命令数を測定する。

instruction_counter API は、直近のエントリーポイントからコードが消費した命令数を知ることができます。命令は、IC ランタイムの内部通貨です。1つの IC 命令は、メモリーのアドレスから 32 ビット整数をロードするような、システムが行うことができる仕事のです。システムは、各 webassembly 命令とシステムコールに相当する命令コストを割り当てます。また、そのすべての制限を命令数で定義しています。2022 年 7 月現在、これらの制限は以下のとおりです。

  • 1メッセージの実行:50 億命令
  • 1ラウンド:70 億命令。(コンセンサスによって生成された各ブロックが 1ラウンドの実行を開始します)。
  • キャニスターのアップグレード:2,000 億命令。

命令は cycle ではありませんが、命令を cycle に変換する簡単な一次関数が存在します。2022 年 7 月現在、アプリケーションサブネットでは、10 命令が 4 サイクルに相当します。

performance_counter が返す値は、単一の実行の中でのみ意味を持つことに注意してください。非同期境界を越えて測定された命令カウンターの値を比較するべきではありません。

#[update]
async fn transfer(from: Account, to: Account, amount: Nat) -> Result<TxId, Error> {
let start = ic_cdk::api::instruction_counter();let tx = apply_transfer(from, to, amount)?;
let tx_id = archive_transaction(tx).await?;// BAD: the await point above resets the instruction counter.
let end = ic_cdk::api::instruction_counter();
record_measurement(end - start);Ok(tx_id)
}

serde_bytes パッケージを使用してバイト配列をエンコードする。

Candid は、IC における標準的なインタフェース定義言語です。Candid の Rust 実装は、一般的な serde フレームワークに依存しており、serde の癖を全て受け継いでいます。その癖の一つは、ほとんどのシリアライズ形式におけるバイト配列(Vec<u8>[u8])の非効率なエンコーディングです。Rust の制限により、serde はバイト配列を特別に扱うことができず、各バイトを generic 配列の個別の要素としてエンコードするため、メッセージのエンコードまたはデコードに必要な命令数が(しばしば 10 倍以上)増加します。

キャニスターの HTTP プロトコルの HttpResponse はその好例です。

#[derive(CandidType, Deserialize)]
struct HttpResponse {
status_code: u16,
headers: Vec<(String, String)>,
// BAD: inefficient
body: Vec<u8>,
}

bodyフィールドは大きくなる可能性があります。serde にwith 属性を使って、このフィールドをより効率的にエンコードするように指示しましょう。

#[derive(CandidType, Deserialize)]
struct HttpResponse {
status_code: u16,
headers: Vec<(String, String)>,
// OK: encoded efficiently
#[serde(with = “serde_bytes”)]
body: Vec<u8>,
}

あるいは、このフィールドに ByteBuf 型を使用することもできます。

#[derive(CandidType, Deserialize)]
struct HttpResponse {
status_code: u16,
headers: Vec<(String, String)>,
// OK: also efficient
body: serde_bytes::ByteBuf,
}

節約を計測するために、小さなキャニスターを書きました。

#[query(manual_reply = true)] 
fn http_response() -> ManualReply<HttpResponse> {
let start = ic_cdk::api::instruction_counter();
let reply = ManualReply::one(HttpResponse {
status_code: 200,
headers: vec![(“Content-Length”.to_string(), “1000000”.to_string())],
body: vec![0; 1_000_000],
});
let end = ic_cdk::api::instruction_counter();
ic_cdk::api::print(format!(“Consumed {} instructions”, end — start));
reply }

(HTTP レスポンスをエンコードするのに必要な命令数を計測するキャニスターエンドポイント。エンコード時間を計測するためにManualReplyを使用する必要があります。)

最適化されていないバージョンでは、1メガバイトをエンコードするのに1億3000万命令を消費しますが、serde_bytesを使用したバージョンでは1200万命令で済みます。

Internet Identity キャニスターの場合、この変更だけで HTTP クエリーにおける命令消費量が桁違いに減少しました。Candid としてエンコードする型だけでなく、serde の Serialize および Deserialize トレイトを派生しているすべての型に、同じ手法を適用する必要があります。同様の変更により、ICP 台帳アーカイブのアップグレードが強化されました(Canister はステートのシリアライゼーションに cbor を使用します)。

大きな値をコピーすることは避ける。

経験上、キャニスターはその命令の多くをバイトのコピーに費やしています。(memcpymemsetに多くの時間を費やすことは、多くのWebAssemblyプログラムに共通する特徴です。この観察は、WebAssembly 2.0リリースに含まれるバルクメモリ操作の提案に繋がりました)。不要なコピーの数を減らすことは、しばしばサイクル消費に影響します。

ここでは、1つのダイナミックアセットに対応するキャニスターに取り組んでいると想像してみましょう。

thread_local!{
static ASSET: RefCell<Vec<u8>> = RefCell::new(init_asset());
}#[derive(CandidType, Deserialize)]
struct HttpResponse {
status_code: u16,
headers: Vec<(String, String)>,
#[serde(with = "serde_bytes")]
body: Vec<u8>,
}#[query]
fn http_request(_request: HttpRequest) -> HttpResponse {
// NOTE: we are making a full copy of the asset.
let body = ASSET.with(|cell| cell.borrow().clone());HttpResponse {
status_code: 200,
headers: vec![("Content-Length".to_string(), body.len().to_string())],
body
}
}

http_requestエンドポイントは、リクエストごとにアセットのディープコピーを作成します。CDK はエンドポイントが戻るとすぐにレスポンスをリプライバッファにエンコードするので、このコピーは不要です。エンコーダーがボディを所有する必要はないのです。残念なことに、現在のマクロ API はコピーを排除することを不必要に難しくしています。 返信の型は 'static lifetime でなければなりません。この問題を回避する方法はいくつかあります。

一つの解決策は、アセット本体を参照カウント式スマートポインターにラップすることです。

thread_local!{
static ASSET: RefCell<RcBytes> = RefCell::new(init_asset()); }struct RcBytes(Arc<serde_bytes::ByteBuf>);impl CandidType for RcBytes { /* */ }
impl Deserialize for RcBytes { /* */ }#[derive(CandidType, Deserialize)]
struct HttpResponse {
status_code: u16,
headers: Vec<(String, String)>,
body: RcBytes, }
}

(大きな値に対して参照カウント式ポインタを使用。ASSET変数の型が変わらなければならないことに注意:データの全てのコピーはスマートポインタの後ろになければならない。)

このアプローチにより、コードの全体的な構造を変えることなく、コピーを節約できます。同様の変更により、認定アセットキャニスターの命令消費量が30%削減されました。

もう一つの解決策は、ライフタイムで型を充実させ、ManualReply APIを使うことです。

use std::borrow::Cow;
use serde_bytes::Bytes;#[derive(CandidType, Deserialize)]
struct HttpResponse<'a> {
status_code: u16,
headers: Vec<(Cow<'a, str>, Cow<'a, str>)>,
body: Cow<'a, serde_bytes::Bytes>,
}#[query(manual_reply = true)]
fn http_response(_request: HttpRequest) -> ManualReply<HttpResponse<'static>> {
ASSET.with(|asset| {
let asset = &*asset.borrow();
ic_cdk::api::call::reply((&HttpResponse {
status_code: 200,
headers: vec![(
Cow::Borrowed("Content-Length"),
Cow::Owned(asset.len().to_string()),
)],
body: Cow::Borrowed(Bytes::new(asset)),
},));
});
ManualReply::empty()
}

このアプローチでは、すべての不要なコピーを取り除くことはできますが、コードが著しく複雑になります。すでに明示的な寿命を持つデータ構造(ic-certified-mapパッケージのHashTreeが良い例です)を扱う必要がない限り、参照カウント式のアプローチを選ぶべきです。

1メガバイトのアセットで実験したところ、ディープコピーに依存するオリジナルコードでは 1600 万命令を消費しました。一方、参照カウントと明示的なライフタイムを使用したバージョンでは、1200 万命令しか必要としませんでした。(この 25% の向上は、我々のコードがバイトのコピーしかしていないことを示しています。① thread_localからHttpResponseへ、②HttpResponseから candid の値バッファへ、③candidの値バッファから呼び出しの引数バッファへ、少なくとも3つのコピーを行なっていました。コピーを⅓削除した結果、命令消費量が¼改善されました。つまり、アセットのバイト配列のコピーに関係ない作業に貢献したのは、命令の1/4だけです。)

モジュールサイズの縮小

デフォルトでは、cargo は巨大な WebAssembly モジュールを吐き出します。小さなカウンターキャニスターでさえ、デフォルトの cargo releaseプロファイルでは、なんと 2.2 MiB の巨大なサイズにコンパイルされます。このセクションでは、キャニスターのサイズを小さくするための簡単なテクニックを紹介します。

サイズおよびリンクタイムの最適化でキャニスターモジュールをコンパイルする。

Rust コンパイラが高速とみなすコードは、必ずしも最もコンパクトなコードとは限りません。opt-level = 'z'オプションで、コードのサイズを最適化するようコンパイラに依頼できます。残念ながら、このオプションだけでは、カウンターキャニスターモジュールのサイズに影響はありません。

リンクタイム最適化は、モジュールの境界を越えて最適化を適用するようコンパイラに要求する、より積極的なオプションです。この最適化はコンパイルを遅くしますが、不要なコードを切り捨てることができるため、コンパクトなキャニスターモジュールを得るのに非常に重要です。ビルドプロファイルに lto = true を追加すると、カウンターキャニスターモジュールが 2.2MiB から 820KiB に縮小されます。Rust プロジェクトのルートにある Cargo.toml ファイルに以下のセクションを追加して、サイズの最適化を有効にします。

[profile.release]
lto = true
opt-level = ‘z’

もう1つのオプションは、codegen-unitsです。このオプションを小さくすると、コード生成パイプラインの並列性が低下しますが、コンパイラーはより強力に最適化できます。cargo release profile で codegen-units = 1 に設定すると、カウンターモジュールのサイズが 820KiB から 777KiB に縮小されます。

未使用のカスタムセクションを削る。

デフォルトでは、Rust コンパイラーはデバッグ情報を出力し、WebAssembly 命令を関数名などのソースレベルの構成要素にリンクバックできるようにします。この情報は、Rust コンパイラがモジュールに付加するいくつかのカスタム WebAssembly セクションにまたがります。現在のところ、IC 上のデバッグ情報は何の役にも立ちません。ic-wasm ツールを使用して、未使用のセクションを安全に削除できます。

$ cargo install ic-wasm
$ ic-wasm -o counter_optimized.wasm counter.wasm shrink

ic-admin shrink ステップは、カウンターキャニスタのサイズを 820KiB から 340KiB に縮小します。ic-wasmic が理解するカスタムセクションを保存するのに十分な賢さがあります。

twiggyツールを使って、コードの肥大化の原因を探る。

Rust の言語設計の中には、実行速度とバイナリサイズを引き換えにするものがあります(例えば、単一形化)。コードの設計を変更したり、ライブラリを変更したりすることで、モジュールサイズを大幅に削減できる場合があります。どのような最適化プロセスでもそうですが、実験の指針としてプロファイラが必要です。twiggy ツールは、WebAssembly モジュールの中で最も大きな関数を見つけるのに優れています。(twiggyは関数名を表示するためにデバッグ情報を必要とします。ic-wasmでモジュールを縮小する前に実行してください)。

カウンターキャニスターの WebAssembly モジュールのサイズに対する上位の貢献者。デバッグ情報を持つカスタムセクションが出力を支配していますが、twiggyの出力で関数名を見るには、これらのセクションを残しておかなければなりません。Serde ベースの candid デシリアライザーは、コードサイズに関して最も悪い影響を与えます。

コードの肥大化に最も寄与しているライブラリを特定したら、より肥大化しない代替品を探してみましょう。例えば、ICP ledger canister モジュールでは、CBOR のデシリアライゼーションをserde_cborからciboriumに変更することで 600KiB 縮小できました。

GZip 圧縮されたキャニスターモジュール。

IC には、OSにおける実行ファイルに相当するキャニスターモジュールという概念があります。IC specification のバージョン 0.18.4 から、キャニスターモジュールはバイナリエンコードされた WebAssembly ファイルだけでなく、GZip 圧縮された WebAssembly ファイルであることも可能です。

圧縮されたアセットを埋め込まない典型的な WebAssembly ファイルでは、GZip 圧縮によってモジュールサイズが半分になることがよくあります。カウンターキャニスターを圧縮すると、モジュールサイズが 340KiB から 115KiB に縮小します(最初に作成した 2.2MiB のモジュールの約 5% です!)。

インフラストラクチャ

ビルド

あなたのキャニスターを使用する人は、キャニスターが主張することを実行するかどうかを検証したいと思うかもしれません(特に、トークンを移動させる場合)。Internet Computer では、誰でもキャニスターの WebAssembly モジュールの SHA256 ハッシュサムを検査できます。しかし、モジュールがビルドされたソースコードを検査するための良いツールはまだありません。公開されたソースから WebAssembly モジュールをビルドする再現可能な方法を提供するのは、開発者の責任です。

キャニスターのビルドを再現できるようにする。

偶然に再現性のあるビルドを得ることは、ランダムに分子を投げ合って生物を構築するのと同じぐらいの確率です。ビルドの再現性を高めるのに役立つ、少なくとも2つの一般的な技術があります。Linux コンテナNix です。コンテナはより主流の技術で、通常セットアップが簡単ですが、Nix にもファンがいます。私の経験では、Nix のビルドはより再現性が高い傾向にあります。どのような技術であれ、自分が使いやすいものを使いましょう。重要なのは最終的な結果なのですから。

また、公開されている継続的インテグレーションシステムを使用してモジュールを構築すると、モジュールを生成する手順を追ったり、最終的な成果物をダウンロードしたりするのが容易になります。

最後に、もしあなたのコードがまだ進化しているなら、モジュールのハッシュとソースコードのバージョンの関連付けを簡単にできるようにしましょう。たとえば、GitHub のリリースを使う場合は、リリースノートにモジュールのハッシュを記載します。

再現性のあるキャニスタービルドに関する詳しいアドバイスは、再現性のあるキャニスタービルドの記事をお読みください。

アップグレード

アップグレードの仕組みについて説明します。

  1. キャニスターで定義されている場合、システムはpre_upgradeフックを呼び出します。
  2. システムは、キャニスターメモリを破棄し、新しいバージョンのモジュールをインスタンス化します。新しいバージョンで利用できるステーブルメモリーをシステムは保持します。
  3. システムは、キャニスターでpost_upgradeが定義されている場合、新しく作成されたインスタンスに対してpost_upgradeフックを呼び出します。init 関数は実行されません。

上記のいずれかのステップでキャニスターが trap した場合、システムはキャニスターをアップグレード前のステートに戻します。

初日からアップグレードを計画する。

You can live without upgrades during the initial development cycle, but even then losing state on each deployment quickly becomes annoying. As soon as you deploy your canister to the mainnet, the only way to ship new versions of the code is to carefully plan the upgrades.

最初の開発サイクルの間はアップグレードをしなくても生きていけますが、それでもデプロイのたびにステートを失うのはすぐに煩わしくなってしまいます。キャニスターをメインネットにデプロイした時点で、新しいバージョンのコードを出荷する唯一の方法は、アップグレードを注意深く計画することです。

ステーブルメモリをバージョンアップする。

ステーブルメモリは、キャニスターの古いバージョンと新しいバージョンの間の通信チャネルと見なすことができます。優れた通信プロトコルはすべてバージョン管理されており、あなたのプロトコルもそうであるべきです。たとえば、将来的にステーブルデータのレイアウトまたはシリアライズ形式を根本的に変えたいと思うかもしれません。ステーブルメモリのデコードコードがデータフォーマットを 推測 する必要がある場合、それは非常に厄介で脆弱になる傾向があります。

神経細胞を節約して、事前にバージョン管理を考えましょう。「私の安定したメモリの最初の1バイトはバージョン番号です」と宣言するのと同じくらい簡単です。

アップグレードフックは必ずテストする。

アップグレードのテストは非常に重要です。アップグレードに失敗すると、データを取り返しのつかない形で失うことになります。アップグレードテストは、インフラストラクチャの不可欠な部分であることを確認してください。

アップグレードをテストするためのひとつのアプローチとして、統合テストで使えるものを説明します。このアイデアは、テストのステート検証部分を実行する前に、オプションでアップグレードのステップを追加することです。以下の擬似コードは Rust で書かれていますが、シェルスクリプトでも同様に動作します。

let canister_id = install_canister(WASM);
populate_data(canister_id);
if should_upgrade { upgrade_canister(canister_id, WASM); }
let data = query_canister(canister_id);
assert_eq!(data, expected_value);

そして、異なるモードで2回テストを実行します。

  1. “アップグレードなし “ モードでは、アップグレードを実行することなく、テストが正常に実行されます。
  2. “アップグレード “モードでは、テスト対象のキャニスターを “セルフアップグレード “します。

これにより、キャニスターをアップグレードしても、ステートが保持され、ユーザはアップグレードがあったかどうかわからないという確信が得られるはずです。もちろん、キャニスターを以前のバージョンから安全にアップグレードできるかどうかをテストするのも良いアイデアです。

pre_upgradeフックで trap しないでください。

pre_upgradepost_upgradeのフックは左右対称に見えます。どちらかのフックが trap すると、キャニスターはアップグレード前の状態に戻ります。しかし、この対称性は欺瞞的です。

pre_upgradeフックが成功しても、post_upgradeフックが trap しても、希望は失われません。何がいけなかったのかを突き止め、アップグレードに失敗しないような別のバージョンのキャニスターを作ることができます。複雑な多段階のアップグレード手順を考案する必要があるかもしれませんが、少なくとも逃げ道はあります。

一方、pre_upgradeフックが trap した場合、できることはあまりありません。キャニスターの動作を変えるにはアップグレードが必要ですが、それは壊れたpre_upgradeフックが阻んでいるのです。

pre_upgradeフックが無ければ、あなたを失望させることはないでしょう。次のアドバイスは、そのフックを取り除くのに役立ちます。

ステーブルメモリをメインストレージとして使用することを検討してください。

アップグレード中にキャニスターが燃焼できるサイクル数には上限があります。キャニスターがこの上限を超えると、システムはアップグレードをキャンセルし、キャニスターのステートを元に戻します。つまり、pre_upgrade フックでステート全体をステーブルメモリにシリアライズし、ステートが巨大化した場合、キャニスターを二度とアップグレードできなくなる可能性があります。

この問題に対処する一つの方法は、そもそもシリアライズのステップを回避することです。ステーブルメモリを「ディスクストア」として使用し、アップデートコールのたびに少しずつ更新していけばいいのです。この方法では pre_upgrade フックは全く必要なく、post_upgrade フックは数サイクルを消費するだけでよいでしょう。

しかし、この方法にはかなりのデメリットがあります。

  • ステーブルストレージのフラットなアドレス空間をデータ構造に整理することは、困難なことです。特に、複数のデータ構造が連動して構成される複雑なステートの場合は、そうです。私の知る限り、そのための良いライブラリはまだありませんが、この問題に取り組んでいる人は何人かいます。
  • データのレイアウトを変更することは、実現不可能かもしれません。キャニスターが一度にデータの移行を完了させるのは、単に負担が大きすぎるのです。8GB のディスクを FAT32 から NTFS に、データを失うことなく再フォーマットするプログラムを書くのと同じくらい難しいかもしれません。ちなみに、そのプログラムは5秒以内に完成させなければなりません。
  • データ構造の後方互換性については、慎重に考える必要があります。最新バージョンのキャニスターは、数ヶ月前にインストールされたバージョンによって書かれたデータを読むことができる必要があるかもしれません。

全体として、サービスのスケーラビリティとコードのシンプルさの間の厳しいトレードオフです。ギガバイト単位のステートを保存し、コードをアップグレードする予定があるなら、ステーブルメモリをメインストレージとして使うことは、検討すべき良い選択肢です。

観測可能性

DFINITY では、メトリクスを多用し、プロダクションサービスに関する多くのデータを記録しています。これは、複雑な分散システムの挙動を理解しようとするときに欠かせないものです。この点で、キャニスターは特別ではありません。

キャニスターのメトリクスを見えるようにする。

具体的にどのようなアプローチがあるのか、2つ見ていきましょう。

  1. 最初のアプローチは、メトリクスを含むデータ構造を返すクエリーコールを公開することです。何らかの理由でこのデータを皆に公開したくない場合は、呼び出し元の principal に基づきクエリーを拒否できます。このアプローチの主な利点の一つは、応答が高度に構造化され、解析が容易であることです。私はこの方法を統合テストでよく使います。
pub struct MyMetrics {   
pub stable_memory_size: u32,
pub allocated_bytes: u32,
pub my_user_map_size: u64,
pub last_upgraded_ts: u64,
}#[query]
fn metrics() -> MyMetrics {
check_acl();
MyMetrics {
// ...
}
}

2. 2つ目の方法は、キャニスターの HTTP ゲートウェイを通じて、監視システムが直接吸い上げられるような形式でメトリクスを公開することです。例えば、私たちはモニタリングに Prometheus を使用しているので、キャニスターは Prometheus のテキストベースの説明形式でメトリクスをダンプします。

fn http_request(req: HttpRequest) -> HttpResponse { 
match path(&req) {
“/metrics” => HttpResponse {
status_code: 200,
body: format!(r#”stable_memory_bytes {}
allocated_bytes {}
registered_users_total {}”#,
stable_memory_bytes, allocated_bytes, num_users),
// …
}
}
}

ご覧のように、派手なライブラリをリンクする必要はなく、フォーマットはシンプルそのものです。単純なカウンターやゲージが必要なだけなら、formatマクロで十分です。ヒストグラムとラベルはもう少し手間がかかりますが、基本的なツールでかなりのことができます。

注目したいいくつかの点

  • ステーブルメモリのサイズ。
  • ヒープ上に割り当てられたオブジェクトのサイズ (このサイズは、カスタムアロケーターを定義することで非常に簡単に取得できます)。
  • 内部データ構造のサイズ。
  • キャニスターが最後にアップグレードされた日。

参考

以下は、よく使われている Rust キャニスターの例です。

  • Internet Identity のバックエンドは、メインストレージとしてステーブルメモリーを使用し、システムからセキュアなランダム性を取得し、Prometheus メトリクスを公開するキャニスターの良い例です。
  • Certified アセットキャニスター は、認定された HTTP レスポンスを生成するキャニスターの好例です。

____

internetcomputer.org で開発を開始し、forum.dfinity.org で開発者コミュニティに参加しましょう。

--

--