mcpField
M
multi-canister

multi-canister

github.com/dfinity開発39294 回インストール今日更新
リポジトリ

multi-canister について

複数のキャニスターで ICP アプリを設計する開発者向けのスキルです。アーキテクチャ上の判断やキャニスター間呼び出しを解説し、信頼性、ビルド、セキュリティ上の注意点とともに Motoko と Rust の実装方法を示します。

主な機能

  1. 01複数キャニスターへの分割とデプロイ設定
  2. 02非同期のキャニスター間呼び出しパターン
  3. 03呼び出し制限と失敗処理
  4. 04並行性とセキュリティ上の注意点
  5. 05Motoko と Rust の例

活用シーン

  1. 01ICP アプリをサービス単位で分割
  2. 02ストレージや計算負荷の分散
  3. 03キャニスターごとの個別アップグレード
  4. 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 未満のシンプルなアプリ。単一キャニスターのほうがシンプルで高速であり、キャニスター間呼び出しのオーバーヘッドも発生しません。過剰に設計しないでください。

機能上のバグにつながる可能性がある問題

マルチキャニスターアプリケーションを構築する際は、経験豊富なシニアソフトウェアエンジニアの視点で、微妙な機能上のバグにつながる可能性がある以下の問題を注意深く確認してください。これらの問題が原因で発生するバグを細心の注意を払って回避してください。

  1. リクエストとレスポンスのペイロードは 2 MB に制限されています。 キャニスター呼び出しはサブネット境界を越える必要が生じる可能性があり、サブネット間(または XNet)メッセージ(各キャニスター呼び出しに対応するリクエストとレスポンス)は 4 MB のブロックに取り込まれる(パッケージ化される)ため、キャニスターのリクエストおよびレスポンスのペイロードは 2 MB に制限されています。リクエストのペイロードが 2 MB を超える呼び出しは同期的に失敗し、ペイロードが 2 MB を超えるレスポンスはトラップを発生させます。エンコードによるオーバーヘッドを考慮して、大きなペイロードは 1 MB のチャンクに分割し、複数回の呼び出し(チャンク分割アップロードやバイト範囲クエリなど)で送信してください。

  2. 呼び出しを行う更新メソッドは、アトミックに実行されません。 更新メソッドが呼び出しを行う場合、await より前のコードは、1回のアトミックなメッセージ実行(つまり、更新メソッドを呼び出したイングレスメッセージまたはキャニスターリクエスト)となり、await より後のコードは、別のアトミックなメッセージ実行(呼び出しへの応答)となります。特に、更新メソッドが await の後にトラップした場合、await より前の変更はすでに永続化されており、await より後の変更はロールバックされます。最終的整合性を前提に設計するか、サーガパターンを使用してください。詳しい説明が必要な場合は、必要に応じてICP におけるメッセージ実行の特性を参照してください。

  3. べき等な API を使用するか、べき等でない呼び出しの結果を照会するための別のエンドポイントを用意してください。 べき等でない API への呼び出しがタイムアウトする場合、呼び出し元がその結果を知るための別の方法が必要です(たとえば、元の呼び出しに一意の ID を付与し、その ID を使って呼び出しの結果を照会する方法があります)。結果を知る方法がないと、呼び出し元は SYS_UNKNOWN 応答を受け取った際に、処理を続けるか、呼び出しを再試行するか、中止するかを判断できない場合があります。

  4. サブネットをまたぐ呼び出しは、同一サブネット内の呼び出しよりも時間がかかります。 サブネットの負荷が低い場合、同一サブネット内のキャニスターへの呼び出しは1ラウンド以内に完了し、呼び出し元が応答を処理できることがあります。呼び出しのレイテンシーは、呼び出し元と呼び出し先がどの程度の頻度でスケジュールされるかによってのみ決まります(1ラウンド内に複数回スケジュールされる場合もあります)。キャニスター間の呼び出しでは、スケジューラーのレイテンシーに加え、いずれの方向でも2~3ラウンド(リクエストの配信と応答の配信)が必要です。

  5. サブネットをまたぐ呼び出しの帯域幅は比較的低くなります。 サブネット間(XNet)メッセージは、イングレスメッセージやその他の XNet メッセージとともに、1ラウンドにつき1回、4 MB のブロックに取り込まれます(パッケージ化されます)。数 MB のメッセージの配信には、XNet のレイテンシーに加えて複数ラウンドかかると想定してください(サブネット内のメッセージはサブネット内でルーティングされるため、この帯域幅の制限を受けません)。

  6. 防御的な実践方法: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; // 安全
    }
    
  7. 拒否された呼び出しを処理しないこと。 キャニスター間の呼び出しは失敗する場合があります(呼び出し先でのトラップ、サイクル不足、キャニスターの停止など)。Motoko では try/catch を使用してください。Rust では ic_cdk::call から返される Result を処理してください。拒否を処理しないと、キャニスターがトラップします。

  8. サイクルが不足しているキャニスターファクトリー。 キャニスターの作成にはサイクルが必要です。管理キャニスターは、作成と初期サイクル残高に対して料金を請求します。十分なサイクルを添付しないと、作成に失敗します。

  9. Rust で #[init] と #[post_upgrade] を設定しないこと。 post_upgrade ハンドラーがないと、キャニスターのアップグレードが予期しない動作をする場合があります。必ず両方を定義してください。

セキュリティバグの原因となる可能性がある問題

経験豊富なシニアセキュリティエンジニアの視点に立ち、セキュリティ上のリスクを引き起こす可能性がある以下の問題を注意深く確認してください。このようなセキュリティバグを細心の注意を払って回避してください。

  1. リエントランシーの問題を回避してください。 呼び出しがアトミックではないため、二重支払いの脆弱性などのリエントランシーバグが発生する可能性があります。上記の「呼び出しを行うメソッドはアトミックに実行されない」も参照してください。ロックパターンを使用するなどして、このような問題を回避してください。詳細が必要な場合は、セキュリティのベストプラクティスまたは論文を参照してもかまいません。

  2. コールバック内のトラップを安全に処理してください。 コールバック内のトラップ(Rust のパニック)が発生すると、コールバックによる状態変更は適用されません。たとえば、悪意のあるエンティティによってトラップが発生する可能性がある場合、DeFi のコンテキストでアカウントから引き落とすなどの重要なセキュリティ処理がスキップされ、二重支払いのような重大な問題につながるおそれがあります。このようなバグを引き起こすトラップがコールバック内で発生しないようにし、call_on_cleanup の使用を検討し、「ジャーナリング」を使用してください。詳細が必要な場合は、セキュリティのベストプラクティスを参照してもかまいません。

  3. 上限のない待機呼び出しにより、カニスターのアップグレードが無期限に妨げられる可能性があります。 上限のない待機呼び出しは、完了までに任意の時間がかかることがあります。悪意のある、または誤った呼び出し先が応答を返さずに無限に処理を続ける可能性があるためです。未完了の呼び出しへの応答を待っている間、カニスターを停止することはできません。上限付き待機呼び出しを使用すれば、呼び出し先が応答したかどうかにかかわらず、呼び出しが一定時間内に完了するため、この問題を回避できます。詳細が必要な場合は、セキュリティのベストプラクティスを参照してもかまいません。

  4. canister_inspect_message はカニスター間の呼び出しでは呼び出されません。 外部ユーザーからの ingress メッセージに対してのみ実行されます。カニスター間のアクセス制御にこれを使用しないでください。代わりに、プリンシパルを明示的に確認してください。詳細が必要な場合は、セキュリティのベストプラクティスを参照してもかまいません。

ビルドを失敗させる間違い

  1. 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」セクションを参照してください。

  2. 各バックエンドカニスターの型宣言を生成し忘れる。 言語固有のツール(Candid バインディング用の didc など)を使用して、各バックエンドカニスターの宣言を個別に生成してください。

  3. カニスター間で共有型が食い違う。 カニスター 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 を記録してください。

マルチキャニスターシステムのアップグレード戦略

順序
  1. 共有依存関係を最初にデプロイします(例:content_service より先に user_service)。
  2. Candid インターフェースに互換性を損なう変更を加えないでください。新しいフィールドは opt 型として追加します。
  3. メインネットにデプロイする前に、ローカルでアップグレードをテストします。
安全なアップグレードのチェックリスト
  • キャニスター間で共有されている既存の型のフィールドを削除したり、名前を変更したりしないでください。
  • 新しいフィールドはオプションとして追加します(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")

コメント