multi-canister について
複数のキャニスターで ICP アプリを設計する開発者向けのスキルです。アーキテクチャ上の判断やキャニスター間呼び出しを解説し、信頼性、ビルド、セキュリティ上の注意点とともに Motoko と Rust の実装方法を示します。
主な機能
- 01複数キャニスターへの分割とデプロイ設定
- 02非同期のキャニスター間呼び出しパターン
- 03呼び出し制限と失敗処理
- 04並行性とセキュリティ上の注意点
- 05Motoko と Rust の例
活用シーン
- 01ICP アプリをサービス単位で分割
- 02ストレージや計算負荷の分散
- 03キャニスターごとの個別アップグレード
- 04キャニスター間呼び出しの問題調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dfinity/ icskills --skill multi-canisterインストール後、Claude Code で/multi-canisterを入力して読み込まれたか確認します。
詳細
マルチキャニスターアーキテクチャ
概要
スケーリング、関心の分離、または独立したアップグレードサイクルのために、IC アプリケーションを複数のキャニスターに分割します。各キャニスターは独自の状態、サイクル残高、アップグレード経路を持ちます。キャニスター間では、非同期のキャニスター間呼び出しを通じて通信します。
前提条件
- Motoko の場合:
mopsパッケージマネージャー、mops.toml のcore = "2.1.0"— 以下で兄弟キャニスターの ID の検出に使用するRuntime.envVarは、motoko-core 2.1.0 で追加されました - Rust の場合:
ic-cdk >= 0.19、candid、serde、ic-stable-structures
仕組み
呼び出し元キャニスターが呼び出し先キャニスターを呼び出します。メソッド名、引数(ペイロード)、添付されたサイクルはキャニスターリクエストメッセージにまとめられ、呼び出し元が await で処理を一時停止した後、呼び出し先に配信されます。呼び出し先はリクエストを実行してレスポンスを生成し、それがキャニスターレスポンスメッセージにまとめられて呼び出し元に配信されます。呼び出し元は await から再開し、実行を続けます(キャニスターレスポンスメッセージを実行します)。たとえば、呼び出し先が見つからない場合や、リソース制限に達した場合など、システムが拒否レスポンスメッセージを生成することがあります。
呼び出しは、無期限待機(呼び出し元は呼び出し先がレスポンスを生成するまで必ず待機します)または有期限待機(呼び出しのタイムアウトが過ぎた場合や、サブネットのリソースが不足した場合、呼び出し元は実際のレスポンスの代わりに SYS_UNKNOWN レスポンスを受け取ることがあります)のいずれかです。リクエストの配信はベストエフォートです。システムは、リクエストを配信せずに拒否する場合があります。無期限待機のレスポンス(拒否レスポンスを含む)の配信は保証されており、呼び出し元は必ず呼び出しの結果を把握できます。有期限待機のレスポンス配信はベストエフォートです。リクエストが呼び出し先に配信されたかどうかにかかわらず、呼び出しがタイムアウトした場合やシステムリソースが枯渇した場合、呼び出し元は実際のレスポンスの代わりに、システムが生成した SYS_UNKNOWN 拒否レスポンス(結果不明)を受け取ることがあります。
マルチキャニスターを使用する場面
| 理由 | しきい値 |
|---|---|
| ストレージ制限 | 各キャニスターは、最大で数百 GB の安定メモリと 4GB のヒープを使用できます。データがヒープ制限を超える可能性がある場合や、パーティショニングが有効な場合は、ストレージを複数のキャニスターに分割します。 |
| スケーラブルな計算 | キャニスターはシングルスレッドのアクターです。複数のキャニスター、場合によっては複数のサブネットに負荷をシャーディングすると、スループットを大幅に向上できます。 |
| 関心の分離 | 認証サービス、コンテンツサービス、決済サービスを独立したユニットにします。 |
| 独立したアップグレード | ユーザーキャニスターに触れずに、決済キャニスターをアップグレードできます。 |
| アクセス制御 | キャニスターごとに異なるコントローラーを設定します(例:DAO が一方を、チームがもう一方を管理します)。 |
使用しない場面: データが 1GB 未満のシンプルなアプリ。単一キャニスターのほうがシンプルで高速であり、キャニスター間呼び出しのオーバーヘッドも発生しません。過剰に設計しないでください。
機能上のバグにつながる可能性がある問題
マルチキャニスターアプリケーションを構築する際は、経験豊富なシニアソフトウェアエンジニアの視点で、微妙な機能上のバグにつながる可能性がある以下の問題を注意深く確認してください。これらの問題が原因で発生するバグを細心の注意を払って回避してください。
-
リクエストとレスポンスのペイロードは 2 MB に制限されています。 キャニスター呼び出しはサブネット境界を越える必要が生じる可能性があり、サブネット間(または XNet)メッセージ(各キャニスター呼び出しに対応するリクエストとレスポンス)は 4 MB のブロックに取り込まれる(パッケージ化される)ため、キャニスターのリクエストおよびレスポンスのペイロードは 2 MB に制限されています。リクエストのペイロードが 2 MB を超える呼び出しは同期的に失敗し、ペイロードが 2 MB を超えるレスポンスはトラップを発生させます。エンコードによるオーバーヘッドを考慮して、大きなペイロードは 1 MB のチャンクに分割し、複数回の呼び出し(チャンク分割アップロードやバイト範囲クエリなど)で送信してください。
-
呼び出しを行う更新メソッドは、アトミックに実行されません。 更新メソッドが呼び出しを行う場合、
awaitより前のコードは、1回のアトミックなメッセージ実行(つまり、更新メソッドを呼び出したイングレスメッセージまたはキャニスターリクエスト)となり、awaitより後のコードは、別のアトミックなメッセージ実行(呼び出しへの応答)となります。特に、更新メソッドがawaitの後にトラップした場合、awaitより前の変更はすでに永続化されており、awaitより後の変更はロールバックされます。最終的整合性を前提に設計するか、サーガパターンを使用してください。詳しい説明が必要な場合は、必要に応じてICP におけるメッセージ実行の特性を参照してください。 -
べき等な API を使用するか、べき等でない呼び出しの結果を照会するための別のエンドポイントを用意してください。 べき等でない API への呼び出しがタイムアウトする場合、呼び出し元がその結果を知るための別の方法が必要です(たとえば、元の呼び出しに一意の ID を付与し、その ID を使って呼び出しの結果を照会する方法があります)。結果を知る方法がないと、呼び出し元は
SYS_UNKNOWN応答を受け取った際に、処理を続けるか、呼び出しを再試行するか、中止するかを判断できない場合があります。 -
サブネットをまたぐ呼び出しは、同一サブネット内の呼び出しよりも時間がかかります。 サブネットの負荷が低い場合、同一サブネット内のキャニスターへの呼び出しは1ラウンド以内に完了し、呼び出し元が応答を処理できることがあります。呼び出しのレイテンシーは、呼び出し元と呼び出し先がどの程度の頻度でスケジュールされるかによってのみ決まります(1ラウンド内に複数回スケジュールされる場合もあります)。キャニスター間の呼び出しでは、スケジューラーのレイテンシーに加え、いずれの方向でも2~3ラウンド(リクエストの配信と応答の配信)が必要です。
-
サブネットをまたぐ呼び出しの帯域幅は比較的低くなります。 サブネット間(XNet)メッセージは、イングレスメッセージやその他の XNet メッセージとともに、1ラウンドにつき1回、4 MB のブロックに取り込まれます(パッケージ化されます)。数 MB のメッセージの配信には、XNet のレイテンシーに加えて複数ラウンドかかると想定してください(サブネット内のメッセージはサブネット内でルーティングされるため、この帯域幅の制限を受けません)。
-
防御的な実践方法:Rust では
msg_caller()の値を.awaitより前に束縛してください。 現在の ic-cdk エグゼキューターは、保護されたタスクを介して.awaitのポイントを越えて呼び出し元を保持しますが、値を早めに取得しておくことで、将来のエグゼキューターの変更にも備えられます。Motoko は安全です:public shared ({ caller }) funcは、関数の開始時にcallerを不変の束縛として取得します。// 推奨(Rust)— await より前に呼び出し元を取得する: #[update] async fn do_thing() { let original_caller = ic_cdk::api::msg_caller(); // 防御策:await より前に取得する let _ = some_canister_call().await; let who = original_caller; // 安全 } -
拒否された呼び出しを処理しないこと。 キャニスター間の呼び出しは失敗する場合があります(呼び出し先でのトラップ、サイクル不足、キャニスターの停止など)。Motoko では
try/catchを使用してください。Rust ではic_cdk::callから返されるResultを処理してください。拒否を処理しないと、キャニスターがトラップします。 -
サイクルが不足しているキャニスターファクトリー。 キャニスターの作成にはサイクルが必要です。管理キャニスターは、作成と初期サイクル残高に対して料金を請求します。十分なサイクルを添付しないと、作成に失敗します。
-
Rust で
#[init]と#[post_upgrade]を設定しないこと。post_upgradeハンドラーがないと、キャニスターのアップグレードが予期しない動作をする場合があります。必ず両方を定義してください。
セキュリティバグの原因となる可能性がある問題
経験豊富なシニアセキュリティエンジニアの視点に立ち、セキュリティ上のリスクを引き起こす可能性がある以下の問題を注意深く確認してください。このようなセキュリティバグを細心の注意を払って回避してください。
-
リエントランシーの問題を回避してください。 呼び出しがアトミックではないため、二重支払いの脆弱性などのリエントランシーバグが発生する可能性があります。上記の「呼び出しを行うメソッドはアトミックに実行されない」も参照してください。ロックパターンを使用するなどして、このような問題を回避してください。詳細が必要な場合は、セキュリティのベストプラクティスまたは論文を参照してもかまいません。
-
コールバック内のトラップを安全に処理してください。 コールバック内のトラップ(Rust のパニック)が発生すると、コールバックによる状態変更は適用されません。たとえば、悪意のあるエンティティによってトラップが発生する可能性がある場合、DeFi のコンテキストでアカウントから引き落とすなどの重要なセキュリティ処理がスキップされ、二重支払いのような重大な問題につながるおそれがあります。このようなバグを引き起こすトラップがコールバック内で発生しないようにし、
call_on_cleanupの使用を検討し、「ジャーナリング」を使用してください。詳細が必要な場合は、セキュリティのベストプラクティスを参照してもかまいません。 -
上限のない待機呼び出しにより、カニスターのアップグレードが無期限に妨げられる可能性があります。 上限のない待機呼び出しは、完了までに任意の時間がかかることがあります。悪意のある、または誤った呼び出し先が応答を返さずに無限に処理を続ける可能性があるためです。未完了の呼び出しへの応答を待っている間、カニスターを停止することはできません。上限付き待機呼び出しを使用すれば、呼び出し先が応答したかどうかにかかわらず、呼び出しが一定時間内に完了するため、この問題を回避できます。詳細が必要な場合は、セキュリティのベストプラクティスを参照してもかまいません。
-
canister_inspect_messageはカニスター間の呼び出しでは呼び出されません。 外部ユーザーからの ingress メッセージに対してのみ実行されます。カニスター間のアクセス制御にこれを使用しないでください。代わりに、プリンシパルを明示的に確認してください。詳細が必要な場合は、セキュリティのベストプラクティスを参照してもかまいません。
ビルドを失敗させる間違い
-
dependenciesフィールドでデプロイの順序を指定しようとする。 icp.yaml にはdependenciesフィールドがありますが、デプロイの順序付けには使用されません。icp-cli はカニスターを並列でビルドし、icp deployはすべてのカニスターの ID を、PUBLIC_CANISTER_ID:<canister-name>環境変数としてすべてのカニスターに挿入します。そのため、デプロイ順にかかわらず、カニスターは実行時に兄弟カニスターの ID を取得できます。Motoko(motoko-core >= 2.1.0)ではRuntime.envVar<system>("PUBLIC_CANISTER_ID:user_service")、Rust ではic_cdk::api::env_var_value("PUBLIC_CANISTER_ID:user_service")を使用して読み取ります。icp-cliスキルの「Canister Environment Variables」セクションを参照してください。 -
各バックエンドカニスターの型宣言を生成し忘れる。 言語固有のツール(Candid バインディング用の
didcなど)を使用して、各バックエンドカニスターの宣言を個別に生成してください。 -
カニスター間で共有型が食い違う。 カニスター A が
{ id: Nat; name: Text }を想定し、カニスター B が{ id: Nat; title: Text }を送信すると、呼び出しは通知なく失敗するか、トラップが発生します。両方のカニスターがインポートする共有型モジュールを使用してください。
実装
プロジェクト構成
my-project/
icp.yaml
mops.toml
src/
shared/
Types.mo # Shared type definitions
user_service/
main.mo # User canister
content_service/
main.mo # Content canister
frontend/
... # Frontend assets
icp.yaml
canisters:
- name: user_service
recipe:
type: "@dfinity/[email protected]"
- name: content_service
recipe:
type: "@dfinity/[email protected]"
mops.toml
@dfinity/motoko@v5+ のレシピは mops build <canister-name> でコンパイルするため、各 canister の main は icp.yaml の recipe.configuration ではなく、mops.toml に記述します。icp.yaml のすべての canister name に対して、ここに完全に一致する [canisters.<name>] キーが必要です。一致しない場合、ビルドは No Motoko canisters found in mops.toml configuration で失敗します。
[toolchain]
moc = "1.9.0"
[dependencies]
core = "2.1.0"
[canisters.user_service]
main = "src/user_service/main.mo"
[canisters.content_service]
main = "src/content_service/main.mo"
Motoko
src/shared/Types.mo — 共通型
module {
public type UserId = Principal;
public type PostId = Nat;
public type UserProfile = {
id : UserId;
username : Text;
created : Int;
};
public type Post = {
id : PostId;
author : UserId;
title : Text;
body : Text;
created : Int;
};
public type ServiceError = {
#NotFound;
#Unauthorized;
#AlreadyExists;
#InternalError : Text;
};
};
src/user_service/main.mo — User Canister
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Array "mo:core/Array";
import Time "mo:core/Time";
import Result "mo:core/Result";
import Runtime "mo:core/Runtime";
import Types "../shared/Types";
persistent actor {
type UserProfile = Types.UserProfile;
let users = Map.empty<Principal, UserProfile>();
// Register a new user
public shared ({ caller }) func register(username : Text) : async Result.Result<UserProfile, Types.ServiceError> {
if (Principal.isAnonymous(caller)) {
return #err(#Unauthorized);
};
switch (Map.get(users, Principal.compare, caller)) {
case (?_existing) { #err(#AlreadyExists) };
case null {
let profile : UserProfile = {
id = caller;
username;
created = Time.now();
};
Map.add(users, Principal.compare, caller, profile);
#ok(profile)
};
}
};
// Check if a user exists (called by other canisters)
public shared query func isValidUser(userId : Principal) : async Bool {
switch (Map.get(users, Principal.compare, userId)) {
case (?_) { true };
case null { false };
}
};
// Get user profile
public shared query func getUser(userId : Principal) : async ?UserProfile {
Map.get(users, Principal.compare, userId)
};
// Get all users
public query func getUsers() : async [UserProfile] {
Array.fromIter<UserProfile>(Map.values(users))
};
};
src/content_service/main.mo — Content Canister(User Service を呼び出します)
import Map "mo:core/Map";
import Nat "mo:core/Nat";
import Array "mo:core/Array";
import Time "mo:core/Time";
import Result "mo:core/Result";
import Runtime "mo:core/Runtime";
import Error "mo:core/Error";
import Principal "mo:core/Principal";
import Types "../shared/Types";
// Import the other canister — name must match icp.yaml canister key
import UserService "canister:user_service";
persistent actor {
type Post = Types.Post;
let posts = Map.empty<Nat, Post>();
var postCounter : Nat = 0;
// Create a post — validates user via inter-canister call
public shared ({ caller }) func createPost(title : Text, body : Text) : async Result.Result<Post, Types.ServiceError> {
let originalCaller = caller;
if (Principal.isAnonymous(originalCaller)) {
return #err(#Unauthorized);
};
// Inter-canister call to user_service
let isValid = try {
await UserService.isValidUser(originalCaller)
} catch (e : Error.Error) {
Runtime.trap("User service unavailable: " # Error.message(e));
};
if (not isValid) {
return #err(#Unauthorized);
};
let id = postCounter;
let post : Post = {
id;
author = originalCaller;
title;
body;
created = Time.now();
};
Map.add(posts, Nat.compare, id, post);
postCounter += 1;
#ok(post)
};
// Get all posts
public query func getPosts() : async [Post] {
Array.fromIter<Post>(Map.values(posts))
};
// Get posts by author — with enriched user data
public func getPostsWithAuthor(authorId : Principal) : async {
user : ?Types.UserProfile;
posts : [Post];
} {
let userProfile = try {
await UserService.getUser(authorId)
} catch (_e : Error.Error) { null };
let authorPosts = Array.filter<Post>(
Array.fromIter<Post>(Map.values(posts)),
func(p : Post) : Bool { p.author == authorId }
);
{ user = userProfile; posts = authorPosts }
};
// Delete a post — only the author can delete
public shared ({ caller }) func deletePost(id : Nat) : async Result.Result<(), Types.ServiceError> {
let originalCaller = caller;
switch (Map.get(posts, Nat.compare, id)) {
case (?post) {
if (post.author != originalCaller) {
return #err(#Unauthorized);
};
ignore Map.delete(posts, Nat.compare, id);
#ok(())
};
case null { #err(#NotFound) };
}
};
};
本番環境への対応:Content Service
上記の Content Service の例は、複数 canister 間の通信パターンを示すため、意図的にシンプルにしています。本番環境で必要となるいくつかの機能がありません。
- 入力検証。
registerのusernameパラメーターは、空文字列から 2MB のメッセージサイズ上限まで、任意の文字列を受け付けます。長さ(例:1~64 文字)を検証し、使用可能な文字セットを制限するとともに、なりすましを防ぐために逆引きマップを使って一意性を確保してください。 getUsersでのユーザー列挙とページネーション。getUsersを使うと、誰でもプラットフォーム上のすべてのユーザーを列挙できるため、望ましくない場合があります。さらに、getUsersエンドポイントはすべてのユーザープロフィールを単一のレスポンスで返します。ユーザー数が増えると、レスポンスサイズの 2MB 上限に達してトラップが発生し、エンドポイントが使用不能になります。ページネーション(offset/limit パラメーター)を追加してください。同じことがgetPostsにも当てはまります。
Rust
プロジェクト構成(Rust)
my-project/
icp.yaml
Cargo.toml # workspace
src/
user_service/
Cargo.toml
src/lib.rs
content_service/
Cargo.toml
src/lib.rs
Cargo.toml(ワークスペースルート)
[workspace]
members = [
"src/user_service",
"src/content_service",
]
icp.yaml(Rust)
canisters:
- name: user_service
recipe:
type: "@dfinity/[email protected]"
configuration:
candid: src/user_service/user_service.did
- name: content_service
recipe:
type: "@dfinity/[email protected]"
configuration:
candid: src/content_service/content_service.did
package は省略されています。recipe >= v3.3.0 では canister の name がデフォルト値になり、各ワークスペースメンバーの [package] name はすでに一致しているためです。package を明示的に設定するのは、クレート名が canister 名と異なる場合に限ります。
src/user_service/Cargo.toml
[package]
name = "user_service"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
ic-stable-structures = "0.7"
src/user_service/src/lib.rs
use candid::{CandidType, Deserialize, Principal};
use ic_cdk::{init, post_upgrade, query, update};
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap};
use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
#[derive(CandidType, Deserialize, Clone, Debug)]
struct UserProfile {
id: Principal,
username: String,
created: i64,
}
// Stable storage
thread_local! {
static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
static USERS: RefCell<StableBTreeMap<Vec<u8>, Vec<u8>, Memory>> = RefCell::new(
StableBTreeMap::init(
MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
)
);
}
fn principal_to_key(p: &Principal) -> Vec<u8> {
p.as_slice().to_vec()
}
fn serialize_profile(profile: &UserProfile) -> Vec<u8> {
candid::encode_one(profile).unwrap()
}
fn deserialize_profile(bytes: &[u8]) -> UserProfile {
candid::decode_one(bytes).unwrap()
}
#[init]
fn init() {}
#[post_upgrade]
fn post_upgrade() {}
#[update]
fn register(username: String) -> Result<UserProfile, String> {
let caller = ic_cdk::api::msg_caller();
if caller == Principal::anonymous() {
return Err("Unauthorized".to_string());
}
let key = principal_to_key(&caller);
USERS.with(|users| {
if users.borrow().contains_key(&key) {
return Err("Already exists".to_string());
}
let profile = UserProfile {
id: caller,
username,
created: ic_cdk::api::time() as i64,
};
let bytes = serialize_profile(&profile);
users.borrow_mut().insert(key, bytes);
Ok(profile)
})
}
#[query]
fn is_valid_user(user_id: Principal) -> bool {
let key = principal_to_key(&user_id);
USERS.with(|users| users.borrow().contains_key(&key))
}
#[query]
fn get_user(user_id: Principal) -> Option<UserProfile> {
let key = principal_to_key(&user_id);
USERS.with(|users| {
users.borrow().get(&key).map(|bytes| deserialize_profile(&bytes))
})
}
ic_cdk::export_candid!();
src/content_service/Cargo.toml
[package]
name = "content_service"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
ic-stable-structures = "0.7"
src/content_service/src/lib.rs
use candid::{CandidType, Deserialize, Principal};
use ic_cdk::call::Call;
use ic_cdk::{init, post_upgrade, query, update};
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap, StableCell};
use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
#[derive(CandidType, Deserialize, Clone, Debug)]
struct Post {
id: u64,
author: Principal,
title: String,
body: String,
created: i64,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
struct UserProfile {
id: Principal,
username: String,
created: i64,
}
// Stable storage -- survives canister upgrades
thread_local! {
static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
// Posts keyed by id (u64 as big-endian bytes) -> candid-encoded Post
static POSTS: RefCell<StableBTreeMap<Vec<u8>, Vec<u8>, Memory>> = RefCell::new(
StableBTreeMap::init(
MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
)
);
// Post counter in stable memory
static POST_COUNTER: RefCell<StableCell<u64, Memory>> = RefCell::new(
StableCell::init(
MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))),
0u64,
)
);
// Store the user_service canister ID (set during init, re-set on upgrade)
static USER_SERVICE_ID: RefCell<Option<Principal>> = RefCell::new(None);
}
fn post_id_to_key(id: u64) -> Vec<u8> {
id.to_be_bytes().to_vec()
}
fn serialize_post(post: &Post) -> Vec<u8> {
candid::encode_one(post).unwrap()
}
fn deserialize_post(bytes: &[u8]) -> Post {
candid::decode_one(bytes).unwrap()
}
#[init]
fn init(user_service_id: Principal) {
USER_SERVICE_ID.with(|id| *id.borrow_mut() = Some(user_service_id));
}
#[post_upgrade]
fn post_upgrade(user_service_id: Principal) {
// Re-set the user_service ID (not stored in stable memory for simplicity,
// since it is always passed as an init/upgrade argument)
init(user_service_id);
}
fn get_user_service_id() -> Principal {
USER_SERVICE_ID.with(|id| {
id.borrow().expect("user_service canister ID not set")
})
}
// Defensive: capture caller before any await
#[update]
async fn create_post(title: String, body: String) -> Result<Post, String> {
// Capture caller before the await as defensive practice
let original_caller = ic_cdk::api::msg_caller();
if original_caller == Principal::anonymous() {
return Err("Unauthorized".to_string());
}
// Inter-canister call to user_service
let user_service = get_user_service_id();
let (is_valid,): (bool,) = Call::unbounded_wait(user_service, "is_valid_user")
.with_arg(original_caller)
.await
.map_err(|e| format!("User service call failed: {:?}", e))?
.candid_tuple()
.map_err(|e| format!("Failed to decode response: {:?}", e))?;
if !is_valid {
return Err("User not registered".to_string());
}
let id = POST_COUNTER.with(|counter| {
let mut counter = counter.borrow_mut();
let id = *counter.get();
counter.set(id + 1);
id
});
let post = Post {
id,
author: original_caller, // Use captured caller
title,
body,
created: ic_cdk::api::time() as i64,
};
POSTS.with(|posts| {
posts.borrow_mut().insert(post_id_to_key(id), serialize_post(&post));
});
Ok(post)
}
#[query]
fn get_posts() -> Vec<Post> {
POSTS.with(|posts| {
posts.borrow().iter()
.map(|entry| deserialize_post(&entry.value()))
.collect()
})
}
// Cross-canister enrichment: get posts with author profile
#[update]
async fn get_posts_with_author(author_id: Principal) -> (Option<UserProfile>, Vec<Post>) {
let user_service = get_user_service_id();
// Call user_service for profile data
let user_profile: Option<UserProfile> =
match Call::unbounded_wait(user_service, "get_user")
.with_arg(author_id)
.await
{
Ok(response) => response.candid_tuple::<(Option<UserProfile>,)>()
.map(|(profile,)| profile)
.unwrap_or(None),
Err(_) => None, // Handle gracefully if user service is down
};
let author_posts = POSTS.with(|posts| {
posts.borrow().iter()
.map(|entry| deserialize_post(&entry.value()))
.filter(|p| p.author == author_id)
.collect()
});
(user_profile, author_posts)
}
#[update]
async fn delete_post(id: u64) -> Result<(), String> {
let original_caller = ic_cdk::api::msg_caller();
POSTS.with(|posts| {
let mut posts = posts.borrow_mut();
let key = post_id_to_key(id);
match posts.get(&key) {
Some(bytes) => {
let post = deserialize_post(&bytes);
if post.author != original_caller {
return Err("Unauthorized".to_string());
}
posts.remove(&key);
Ok(())
}
None => Err("Not found".to_string()),
}
})
}
ic_cdk::export_candid!();
Canister ファクトリーパターン
ほかの canister を動的に作成する canister です。ユーザーごとの canister、シャーディング、動的スケーリングに役立ちます。
Motoko ファクトリー
import Principal "mo:core/Principal";
import Map "mo:core/Map";
import Array "mo:core/Array";
import Runtime "mo:core/Runtime";
persistent actor Self {
type CanisterSettings = {
controllers : ?[Principal];
compute_allocation : ?Nat;
memory_allocation : ?Nat;
freezing_threshold : ?Nat;
};
type CreateCanisterResult = {
canister_id : Principal;
};
// IC Management canister
transient let ic : actor {
create_canister : shared ({ settings : ?CanisterSettings }) -> async CreateCanisterResult;
install_code : shared ({
mode : { #install; #reinstall; #upgrade };
canister_id : Principal;
wasm_module : Blob;
arg : Blob;
}) -> async ();
deposit_cycles : shared ({ canister_id : Principal }) -> async ();
} = actor "aaaaa-aa";
// Track created canisters
let childCanisters = Map.empty<Principal, Principal>(); // owner -> canister
// Create a new canister for a user (one per caller)
public shared ({ caller }) func createChildCanister(wasmModule : Blob) : async Principal {
if (Principal.isAnonymous(caller)) { Runtime.trap("Auth required") };
if (Map.get(childCanisters, Principal.compare, caller) != null) {
Runtime.trap("Child canister already exists for this caller");
};
// Create canister with cycles
let createResult = await (with cycles = 1_000_000_000_000)
ic.create_canister({
settings = ?{
controllers = ?[Principal.fromActor(Self), caller];
compute_allocation = null;
memory_allocation = null;
freezing_threshold = null;
};
});
let canisterId = createResult.canister_id;
// Install code
await ic.install_code({
mode = #install;
canister_id = canisterId;
wasm_module = wasmModule;
arg = to_candid (caller); // Pass owner as init arg
});
Map.add(childCanisters, Principal.compare, caller, canisterId);
canisterId
};
// Get a user's canister
public query func getChildCanister(owner : Principal) : async ?Principal {
Map.get(childCanisters, Principal.compare, owner)
};
};
Rust ファクトリー
use candid::{CandidType, Deserialize, Nat, Principal, encode_one};
use ic_cdk::management_canister::{
create_canister_with_extra_cycles, install_code,
CreateCanisterArgs, InstallCodeArgs, CanisterInstallMode, CanisterSettings,
};
use ic_cdk::update;
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap};
use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
thread_local! {
static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
// Stable storage: owner principal -> child canister principal (survives upgrades)
static CHILD_CANISTERS: RefCell<StableBTreeMap<Vec<u8>, Vec<u8>, Memory>> = RefCell::new(
StableBTreeMap::init(
MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
)
);
}
#[update]
async fn create_child_canister(wasm_module: Vec<u8>) -> Principal {
let caller = ic_cdk::api::msg_caller();
assert_ne!(caller, Principal::anonymous(), "Auth required");
// One child canister per caller
let already_exists = CHILD_CANISTERS.with(|c| c.borrow().contains_key(&caller.as_slice().to_vec()));
if already_exists {
ic_cdk::trap("Child canister already exists for this caller");
}
// Create canister
let create_args = CreateCanisterArgs {
settings: Some(CanisterSettings {
controllers: Some(vec![ic_cdk::api::canister_self(), caller]),
compute_allocation: None,
memory_allocation: None,
freezing_threshold: None,
reserved_cycles_limit: None,
log_visibility: None,
wasm_memory_limit: None,
wasm_memory_threshold: None,
environment_variables: None,
}),
};
// Attach 1T cycles for the new canister
let create_result = create_canister_with_extra_cycles(&create_args, 1_000_000_000_000u128)
.await
.expect("Failed to create canister");
let canister_id = create_result.canister_id;
// Install code
let install_args = InstallCodeArgs {
mode: CanisterInstallMode::Install,
canister_id,
wasm_module,
arg: encode_one(&caller).unwrap(), // Pass owner as init arg
};
install_code(&install_args)
.await
.expect("Failed to install code");
// Track the child canister
CHILD_CANISTERS.with(|canisters| {
canisters.borrow_mut().insert(
caller.as_slice().to_vec(),
canister_id.as_slice().to_vec(),
);
});
canister_id
}
#[ic_cdk::query]
fn get_child_canister(owner: Principal) -> Option<Principal> {
CHILD_CANISTERS.with(|canisters| {
canisters.borrow().get(&owner.as_slice().to_vec())
.map(|bytes| Principal::from_slice(&bytes))
})
}
本番環境への対応:Canister ファクトリー
上記のファクトリーの例は、canister の作成パターンを示すため、意図的にシンプルにしています。本番環境で必要となるいくつかの機能がありません。
- Cycles の枯渇対策。 匿名ではない任意の principal が
createChildCanisterを繰り返し呼び出すことができ、呼び出しごとにファクトリーから 1T cycles が消費されます。許可された呼び出し元の許可リストを追加し、ユーザーごとの作成数を制限するとともに、canister を作成する前にファクトリーの cycles 残高を確認してください(例:Motoko ではmo:core/CyclesのCycles.balance()、Rust ではic_cdk::api::canister_cycle_balance())。 - WASM モジュールの検証。 WASM モジュールは呼び出し元が指定するため、認証済みの任意のユーザーが任意のコードをデプロイできます。本番環境では、任意の呼び出し元から WASM を受け付けないでください。代わりに、既知の WASM モジュール(またはそのハッシュ)をファクトリー canister にハードコードするか、インストール前にモジュールのハッシュが許可リストに含まれていることを確認してください。無許可の利用を防ぐため、ファクトリー経由でのデプロイを許可する principal を許可リストに登録してください。
- 再入可能性への対策。 ファクトリーはロックなしで 2 回連続して await(
create_canister、次にinstall_code)を実行します。同じ呼び出し元からの呼び出しが並行すると、孤立した canister が作成され、ファクトリーが追跡できなくなる可能性があります。同じ呼び出し元による並行作成を防ぐロック(例:処理中の呼び出しを行う principal のSet)を追加してください。 - 部分的な失敗への対応。
create_canisterが成功した後にinstall_codeが失敗すると、cycles を持つ canister が存在する一方、ファクトリーでは追跡されない状態になります。再インストールを試みるか、失敗時にクリーンアップできるよう、作成後すぐ(install_codeを試みる前)に canister ID を記録してください。
マルチキャニスターシステムのアップグレード戦略
順序
- 共有依存関係を最初にデプロイします(例:
content_serviceより先にuser_service)。 - Candid インターフェースに互換性を損なう変更を加えないでください。新しいフィールドは
opt型として追加します。 - メインネットにデプロイする前に、ローカルでアップグレードをテストします。
安全なアップグレードのチェックリスト
- キャニスター間で共有されている既存の型のフィールドを削除したり、名前を変更したりしないでください。
- 新しいフィールドはオプションとして追加します(Motoko では
?Type、Rust ではOption<T>)。 - キャニスターの Candid インターフェースが変更された場合は、プロバイダーのアップグレード後にコンシューマーをアップグレードします。
- Rust キャニスターには、必ず
#[init]と#[post_upgrade]の両方を用意してください。 - Motoko では、
persistent actorが安定ストレージを自動的に処理します。
アップグレードコマンド
# Upgrade canisters in dependency order
icp deploy user_service
# Rust content_service requires the user_service principal on every upgrade (post_upgrade arg)
USER_SERVICE_ID=$(icp canister status user_service --id-only)
icp deploy content_service --argument "(principal \"$USER_SERVICE_ID\")"
npm run build
icp deploy frontend
デプロイとテスト
ローカル開発
# Start the local replica
icp network start -d
# Deploy in dependency order
icp deploy user_service
# content_service (Rust) requires the user_service canister ID as an init argument
USER_SERVICE_ID=$(icp canister status user_service --id-only)
icp deploy content_service --argument "(principal \"$USER_SERVICE_ID\")"
# Build and deploy frontend
npm run build
icp deploy frontend
キャニスター間呼び出しのテスト(Motoko)
# Register a user
PRINCIPAL=$(icp identity principal)
icp canister call user_service register "(\"alice\")"
# Verify user exists
icp canister call user_service isValidUser "(principal \"$PRINCIPAL\")"
# Expected: (true)
# Create a post (triggers inter-canister call to user_service)
icp canister call content_service createPost "(\"Hello World\", \"My first post\")"
# Expected: (variant { ok = record { id = 0; author = principal "..."; ... } })
# Get all posts
icp canister call content_service getPosts
# Expected: (vec { record { id = 0; ... } })
キャニスター間呼び出しのテスト(Rust)
Rust キャニスターでは、関数名にスネークケースを使用します。
PRINCIPAL=$(icp identity principal)
icp canister call user_service register "(\"alice\")"
icp canister call user_service is_valid_user "(principal \"$PRINCIPAL\")"
# Expected: (true)
# content_service must have been deployed with --argument "(principal \"<user_service_id>\")"
icp canister call content_service create_post "(\"Hello World\", \"My first post\")"
# Expected: (variant { ok = record { id = 0 : nat64; author = principal "..."; ... } })
icp canister call content_service get_posts
# Expected: (vec { record { id = 0 : nat64; ... } })
動作の確認
ユーザー登録の確認
icp canister call user_service register '("testuser")'
# Expected: (variant { ok = record { id = principal "..."; username = "testuser"; created = ... } })
キャニスター間呼び出しの確認
# This call should succeed (user is registered)
# Motoko: createPost / Rust: create_post
icp canister call content_service createPost '("Test Title", "Test Body")'
# Expected: (variant { ok = record { ... } })
# Remember the identity you registered with. There is no identity named
# `default` -- `icp identity default` names the *role*, and a fresh icp-cli
# install has only `anonymous`. (Notices go to stderr, so this captures cleanly.)
ORIGINAL_IDENTITY=$(icp identity default)
# Create a new identity that is NOT registered
icp identity new unregistered --storage plaintext
icp identity default unregistered
icp canister call content_service createPost '("Should Fail", "No user")'
# Expected: (variant { err = "User not registered" })
# Switch back
icp identity default "$ORIGINAL_IDENTITY"
キャニスター間クエリの確認
PRINCIPAL=$(icp identity principal)
# Motoko: getPostsWithAuthor / Rust: get_posts_with_author
icp canister call content_service getPostsWithAuthor "(principal \"$PRINCIPAL\")"
# Expected: (opt record { id = ...; username = "testuser"; ... }, vec { record { ... } })
キャニスターファクトリーの確認
# Read the wasm file for the child canister
# (In practice you'd upload or reference a wasm blob)
icp canister call factory createChildCanister '(blob "...")'
# Expected: (principal "NEW-CANISTER-ID")
icp canister call factory getChildCanister "(principal \"$PRINCIPAL\")"
# Expected: (opt principal "NEW-CANISTER-ID")