stable-memory について
Internet Computer の canister を開発する Motoko・Rust 開発者向けの、安定メモリーとアップグレードのガイドです。言語ごとの永続化方法、Rust の安定構造、デプロイ前の互換性確認を説明します。
主な機能
- 01Motoko actor の永続化ルール
- 02Rust の安定構造による保存パターン
- 03MemoryManager による仮想メモリー分割
- 04アップグレードフックと移行の注意点
- 05アップグレード前の安定性互換チェック
活用シーン
- 01canister のアップグレード後もユーザーデータを保持
- 02Rust canister の安定ストレージ構造を選択
- 03複数のデータ構造向けに安定メモリーを分割
- 04デプロイ前に互換性のない状態変更を検出
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dfinity/ icskills --skill stable-memoryインストール後、Claude Code で/stable-memoryを入力して読み込まれたか確認します。
詳細
安定メモリとカニスターのアップグレード
これは何か
安定メモリは、カニスターのアップグレード後も保持される Internet Computer 上の永続ストレージです。通常の変数が保持されるかどうかは、言語によって異なります。
- Rust -- ヒープデータ(
thread_local! { RefCell<T> }、通常のstatic)は、アップグレードのたびに消去されます。必要なデータはすべて、ic-stable-structuresを使うか、pre_upgrade/post_upgradeで自分でシリアライズして、安定メモリに保存する必要があります。 - Motoko -- 拡張された直交永続化がデフォルトで有効なため、
persistent actor内のletとvarは、stableキーワードやアップグレードフックがなくてもアップグレード後に保持されます。消去されるのはtransient宣言だけです。
前提条件
- Motoko の場合:mops.toml で
core = "2.0.0"を指定した mops と、moc >= 1.7.0(Map/Listのドット記法に必要です) - Rust の場合:Cargo.toml で
ic-stable-structures = "0.7"を指定
カニスター ID
外部カニスターへの依存はありません。安定メモリはローカルカニスターの機能です。
ビルドを壊すよくある間違い
-
ユーザーデータに
thread_local! { RefCell<T> }を使う(Rust) -- これはヒープメモリです。カニスターのアップグレードのたびに消去されます。この方法で保存したユーザーデータ、残高、設定は、icp deployの後にすべて失われます。代わりにStableBTreeMapを使ってください。 -
#[post_upgrade]ハンドラーを忘れる(Rust) --post_upgrade関数がない場合、アップグレード後にカニスターの状態が気づかないうちにリセットされたり、予期しない動作をしたりすることがあります。必ず#[init]と#[post_upgrade]の両方を定義してください。 -
persistent actor で
stableキーワードを使う(Motoko) -- mo:core のpersistent actorでは、すべてのletとvarの宣言が自動的に安定化されます。stable letを書くと警告 M0218 が発生し、stable varは冗長です。letとvarだけを使ってください。 -
ヒープメモリの上限と安定メモリの上限を混同する(Rust) -- ヒープ(Wasm の線形メモリ)の上限は、wasm32 では 4GB、wasm64 では 6GB です。安定メモリは数百 GB(サブネットのストレージ上限)まで拡張できます。本当の危険は、
pre_upgrade/post_upgradeフックを使ってヒープデータを安定メモリにシリアライズし、そこからデシリアライズして戻す場合です。その場合、ヒープメモリのサイズとアップグレードフックの命令数上限の両方に制約されます。大規模なデータセットはアップグレード中にトラップし、カニスターが使用不能になる可能性があります。解決策は、安定メモリに直接読み書きし、ヒープを完全に経由しない安定構造体(StableBTreeMap、StableCellなど)を使うことです。MemoryManagerを使って安定メモリを仮想メモリに分割すれば、複数の構造体が互いの領域を上書きすることなく共存できます。 -
拒否されたアップグレードをデータ損失とみなす(Motoko) -- 拡張された直交永続化は、新しいプログラムを既存の状態と照合し、アップグレードの適用前に受け入れるか拒否します。アップグレードが拒否されても、データは失われません。カニスターは状態をそのまま保持して以前のバージョンで実行を続けるため、コードを修正して再デプロイしてください。
どのスキーマ変更に移行が必要か、また移行の書き方については、
migrating-motoko-actors(mops で管理される移行チェーン)が担当します。ルールと構文については、そちらを参照してください。ここで知っておくべきよくある意外な点は、既存の永続変数が保持するレコード型を変更することは互換性のない変更であり、移行が必要になるということです。フィールドをオプションで追加する場合も、レコードがどこにあるか(トップレベルのvar、Mapの値など)にかかわらず同様です。新しい安定フィールドの追加自体に移行が必要かどうかは、プロジェクトの設定によって異なります。拡張された移行チェーンでは、安定フィールドは型だけを宣言するため、新しいフィールドには移行が必要です。一方、通常のpersistent actorでは、新しいフィールドに初期化子があれば移行は不要です。まさにこのため、ルールはここで表にするのではなく、そのスキルにまとめられています。互換性のないアップグレードは、実行時にRTS error: Memory-incompatible program upgradeで拒否されます(以前の状態はそのまま保持され、データは失われません)。 デプロイ時に初めて問題に気づくことがないようにしてください。mops.tomlで[canisters.<name>.check-stable]を設定すると、mops checkが前バージョンの.mostに対して安定性互換性チェックを実行します(mops-cliを読み込みます)。これにより、アップグレード呼び出しでRTS errorが発生してからではなく、チェック時に変更に互換性があるか、移行が必要かがわかります。 -
pre_upgrade での大きなデータのシリアライズ(Rust) --
pre_upgradeには固定の命令数上限があります。大きな HashMap をpre_upgradeで安定メモリにシリアライズすると、上限に達してトラップし、キャニスターが使用不能になります。安定メモリに直接書き込み、シリアライズ処理が不要なStableBTreeMapを使ってください。 -
アクターの形式と
--default-persistent-actorsフラグの不一致(Motoko) -- 通常のactorとpersistent actorはどちらも正しい形式です。どちらがコンパイルできるかは、そのコンパイラフラグによって決まります。コードが簡潔になるため、このフラグを有効にすることを推奨します。# mops.toml — recommended; see `mops-cli` [moc] args = ["--default-persistent-actors"]このフラグを使うと、すべてのアクターが永続化されるため、通常の
actor { }と記述します。persistentキーワードを付けてもコンパイルできますが冗長で、警告 M0217 が発生します。このフラグを使わない場合はpersistent actor { }が必要で、通常のactorは[M0220] this actor or actor class should be declared persistentで失敗します。アノテーションを付けても解決しません。stable、transient、pre_upgrade/post_upgradeフックはすべて失敗します。エラーはアクター自体に対するものだからです。// without --default-persistent-actors actor { stable var users = Map.empty<Nat, Text>(); // M0220 — `stable` does not help }; persistent actor { let users = Map.empty<Nat, Text>(); // compiles; persisted automatically };以下の例では、どちらの設定でもコンパイルできる
persistent actorを使います。フラグをオフにしている場合の注意点が1つあります。アクター本体にアノテーションのない
let/varがあると、moc はその行で[M0219] this declaration is currently implicitly transient, please declare it explicitly transientを報告し、M0220 は表示されません。この提案には従わないでください。アップグレード時にtransientはデータを破棄しますし、アクターは引き続き M0220 で失敗します。解決するには、アクターにpersistentを付けるか、フラグを有効にします。
実装
Motoko
mo:core 2.0 では、persistent actor により安定ストレージを簡単に利用できます。アクター本体内のすべての let と var 宣言は、アップグレードをまたいで自動的に保持されます。
import Map "mo:core/Map";
import List "mo:core/List";
import Nat "mo:core/Nat"; // required: the compiler derives Map's implicit `compare` from this module
import Text "mo:core/Text";
import Time "mo:core/Time";
persistent actor {
// Types: in the actor body (as here) or in an imported module -- but never
// between the imports and the actor, which is M0141. See the `writing-motoko` skill.
type User = {
id : Nat;
name : Text;
created : Int;
};
// These survive upgrades automatically -- no "stable" keyword needed
let users = Map.empty<Nat, User>();
var userCounter : Nat = 0;
let tags = List.empty<Text>();
// Transient data -- reset to initial value on every upgrade
transient var requestCount : Nat = 0;
public func addUser(name : Text) : async Nat {
let id = userCounter;
users.add(id, {
id;
name;
created = Time.now();
});
userCounter += 1;
requestCount += 1;
id
};
public query func getUser(id : Nat) : async ?User {
users.get(id)
};
public query func getUserCount() : async Nat {
users.size()
};
// requestCount resets to 0 after every upgrade
public query func getRequestCount() : async Nat {
requestCount
};
}
Motoko の永続アクターに関する主なルール:
- Map、List、Set、Queue には
letを使います。自動的に保持され、シリアライズは不要です - 単純な値(Nat、Text、Bool)には
varを使います。自動的に保持されます - アップグレード時にリセットするキャッシュやカウンターには
transient varを使います pre_upgrade/post_upgradeは不要です。ランタイムが処理しますstableキーワードは不要です。冗長で、警告が発生します
mops.toml
[package]
name = "my-project"
version = "0.1.0"
[dependencies]
core = "2.0.0"
# Required. Without a [toolchain] moc pin, mops falls back to the dfx cache and
# the build dies with `dfx: not found` (see `mops-cli`).
[toolchain]
moc = "1.9.0"
Rust
Rust のキャニスターでは、永続ストレージに ic-stable-structures を使います。MemoryManager は、サブネットのストレージ容量を上限として、安定メモリ(最大で数百 GB)を仮想メモリに分割します。各仮想メモリは異なるデータ構造の格納領域になります。
Cargo.toml
[package]
name = "stable_memory_backend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
ic-cdk = "0.19"
ic-stable-structures = "0.7"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
ciborium = "0.2"
単一の安定構造(シンプルなケース)
use ic_stable_structures::{
memory_manager::{MemoryId, MemoryManager, VirtualMemory},
storable::{Bound, Storable},
DefaultMemoryImpl, StableBTreeMap,
};
use ic_cdk::{init, post_upgrade, query, update};
use candid::CandidType;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
// -- Implement Storable for custom types --
// StableBTreeMap keys need Storable + Ord, values need Storable.
// Storable defines how a type is serialized to/from bytes in stable memory.
// Use CBOR (via ciborium) for serialization -- compact binary format, faster than candid.
#[derive(CandidType, Serialize, Deserialize, Clone)]
struct User {
id: u64,
name: String,
created: u64,
}
impl Storable for User {
// Recommended: prefer Unbounded to avoid backwards compatibility issues when adding new fields.
// Bounded requires a fixed max_size -- adding a field that increases the size will break existing data.
const BOUND: Bound = Bound::Unbounded;
fn to_bytes(&self) -> Cow<'_, [u8]> {
let mut buf = vec![];
ciborium::into_writer(self, &mut buf).expect("Failed to encode User");
Cow::Owned(buf)
}
fn into_bytes(self) -> Vec<u8> {
let mut buf = vec![];
ciborium::into_writer(&self, &mut buf).expect("Failed to encode User");
buf
}
fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
ciborium::from_reader(bytes.as_ref()).expect("Failed to decode User")
}
}
// Bound::Bounded { max_size, is_fixed_size: true } exists for fixed-size types but is NOT
// recommended -- adding a new field later will exceed max_size and break deserialization.
// Stable storage -- survives upgrades
thread_local! {
static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
static USERS: RefCell<StableBTreeMap<u64, User, Memory>> =
RefCell::new(StableBTreeMap::init(
MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
));
// Counter stored in stable memory via StableCell
static COUNTER: RefCell<ic_stable_structures::StableCell<u64, Memory>> =
RefCell::new(ic_stable_structures::StableCell::init(
MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))),
0u64,
));
}
#[init]
fn init() {
// Any one-time initialization
}
#[post_upgrade]
fn post_upgrade() {
// Stable structures auto-restore -- no deserialization needed
// Re-init timers or other transient state here
}
#[update]
fn add_user(name: String) -> u64 {
let id = COUNTER.with(|c| {
let mut cell = c.borrow_mut();
let current = *cell.get();
cell.set(current + 1);
current
});
let user = User {
id,
name,
created: ic_cdk::api::time(),
};
USERS.with(|users| {
users.borrow_mut().insert(id, user);
});
id
}
#[query]
fn get_user(id: u64) -> Option<User> {
USERS.with(|users| users.borrow().get(&id))
}
#[query]
fn get_user_count() -> u64 {
USERS.with(|users| users.borrow().len())
}
ic_cdk::export_candid!();
MemoryManager を使用した複数の安定構造体
use ic_stable_structures::{
memory_manager::{MemoryId, MemoryManager, VirtualMemory},
DefaultMemoryImpl, StableBTreeMap, StableCell, StableLog,
};
use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
// Each structure gets its own MemoryId -- NEVER reuse IDs
const USERS_MEM_ID: MemoryId = MemoryId::new(0);
const POSTS_MEM_ID: MemoryId = MemoryId::new(1);
const COUNTER_MEM_ID: MemoryId = MemoryId::new(2);
const LOG_INDEX_MEM_ID: MemoryId = MemoryId::new(3);
const LOG_DATA_MEM_ID: MemoryId = MemoryId::new(4);
thread_local! {
static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
static USERS: RefCell<StableBTreeMap<u64, Vec<u8>, Memory>> =
RefCell::new(StableBTreeMap::init(
MEMORY_MANAGER.with(|m| m.borrow().get(USERS_MEM_ID))
));
static POSTS: RefCell<StableBTreeMap<u64, Vec<u8>, Memory>> =
RefCell::new(StableBTreeMap::init(
MEMORY_MANAGER.with(|m| m.borrow().get(POSTS_MEM_ID))
));
static COUNTER: RefCell<StableCell<u64, Memory>> =
RefCell::new(StableCell::init(
MEMORY_MANAGER.with(|m| m.borrow().get(COUNTER_MEM_ID)),
0u64,
));
static AUDIT_LOG: RefCell<StableLog<Vec<u8>, Memory, Memory>> =
RefCell::new(StableLog::init(
MEMORY_MANAGER.with(|m| m.borrow().get(LOG_INDEX_MEM_ID)),
MEMORY_MANAGER.with(|m| m.borrow().get(LOG_DATA_MEM_ID)),
));
}
Rust の安定構造体に関する重要なルール:
MemoryManagerは安定メモリを分割します。各構造体には一意のMemoryIdが割り当てられます- 2 つの異なる構造体で
MemoryIdを絶対に再利用しないでください。互いにデータを破損させます StableBTreeMapのキーはStorableとOrdを実装する必要があり、値はStorableを実装する必要があります- カスタム型には
Storableを実装します。BOUND、to_bytes、into_bytes、from_bytesを定義してください。CBOR のシリアライズにはciborium::into_writer/ciborium::from_readerを使用します(コンパクトで高速です)。フィールド追加時の後方互換性の問題を避けられるため、Bound::Unboundedを推奨します。Bound::Boundedも利用できますが、スキーマ変更後にmax_sizeを超えるとデシリアライズに失敗するため、推奨されません - プリミティブ型(
u64、bool、f64など)、String、Vec<u8>、PrincipalはすでにStorableを実装しているため、手動で実装する必要はありません - 単一の値(カウンター、設定)には
StableCellを使用します - 追記専用のログには
StableLogを使用します(2 つのメモリ領域が必要です。インデックスとデータ用です) thread_local! { RefCell<StableBTreeMap<...>> }が適切なパターンです。RefCellでラップするのは安定構造体であり、ヒープ上のHashMapではありませんpre_upgrade/post_upgradeによるシリアライズは不要です。データはすでに安定メモリに保存されています
デプロイとテスト
Motoko:アップグレード後もデータが保持されることを確認する
# Start local replica
icp network start -d
# Deploy
icp deploy backend
# Add data
icp canister call backend addUser '("Alice")'
# Expected: (0 : nat)
icp canister call backend addUser '("Bob")'
# Expected: (1 : nat)
# Verify data exists
icp canister call backend getUserCount '()'
# Expected: (2 : nat)
icp canister call backend getUser '(0)'
# Expected: (opt record { id = 0 : nat; name = "Alice"; created = ... })
# Now upgrade the canister (simulates code change + redeploy)
icp deploy backend
# Verify data survived the upgrade
icp canister call backend getUserCount '()'
# Expected: (2 : nat) -- STILL 2, not 0
icp canister call backend getUser '(1)'
# Expected: (opt record { id = 1 : nat; name = "Bob"; created = ... })
Rust:アップグレード後もデータが保持されることを確認する
icp network start -d
icp deploy backend
icp canister call backend add_user '("Alice")'
# Expected: (0 : nat64)
icp canister call backend get_user_count '()'
# Expected: (1 : nat64)
# Upgrade
icp deploy backend
# Verify persistence
icp canister call backend get_user_count '()'
# Expected: (1 : nat64) -- data survived
icp canister call backend get_user '(0)'
# Expected: (opt record { id = 0 : nat64; name = "Alice"; created = ... })
動作を確認する
安定メモリの決定的なテストは、アップグレード後もデータが保持されることです。
# 1. Deploy and add data
icp deploy backend
icp canister call backend addUser '("TestUser")'
# 2. Record the count
icp canister call backend getUserCount '()'
# Note the number
# 3. Upgrade (redeploy)
icp deploy backend
# 4. Check count again -- must be identical
icp canister call backend getUserCount '()'
# Must match step 2
# 5. Verify transient data DID reset
icp canister call backend getRequestCount '()'
# Expected: (0 : nat) -- transient var resets on upgrade
手順 3 の後にカウントが 0 になる場合、データは安定メモリに保存されていません。ストレージの宣言を確認してください。