Redis serializer の交換は一行の設定変更ではない:object trust から rollback 可能な migration へ
技術ノート
公開 / DavidHLPL
Redis serializer の変更は、一つの Bean を編集するだけに見える。しかし Redis に保存されるのは Java object ではなく bytes である。
旧 cache が JDK serializer や GenericJackson2JsonRedisSerializer を使い、新 serializer が別の envelope を使う場合、直接切り替えると二つの問いが生じる。
- 新しい code は Redis 内の type information を安全に解釈できるか。
- 既存の bytes を migration・rollback し、concurrent write を上書きしない方法は何か。
deserialization の危険は object 作成前に発生する
Jackson の polymorphic data は通常 type identifier を持つ。
{
"version": 2,
"payload": {
"@class": "com.example.User"
}
}
type identifier が Redis 由来で、その内容を別 application、古い program、攻撃者が書ける可能性があるなら、「先に Jackson に type で object を作らせ、business layer で確認する」では遅すぎる。
必要な境界は次である。
bytes を読む
-> type identifier が許可されるか確認
-> 通過してから Jackson に object を instantiate させる
これは通常の DTO conversion ではなく trust boundary である。
ResiCache の serializer は三層の制約を持つ
現在の SecureJacksonRedisSerializer には三つの関連 mechanism がある。
versioned envelope
null ではない cache value は次のように包まれる。
{
"version": 2,
"payload": "..."
}
ここでの version は serialization format version であり、business object version や cache value の CAS token ではない。
allowlist policy
default で許可される business package prefix は次である。
io.github.davidhlp
java.lang、java.time、java.math の一部 type と、明示列挙された collection type も通過できる。
allowlist は単なる「polymorphism を on/off」ではない。次に答える必要がある。
この完全修飾 class name は cache bytes に現れてよいか?
現在の設定には注意すべき境界がある。
com.example.* -> package boundary で match
com.example -> startsWith で match
そのため com.example は com.exampleX.SomeType にも match し得る。厳密な package boundary が必要なら .* 付き prefix、または十分に限定した prefix を使う。
streaming preflight
serializer は envelope を本当に読む前に streaming parser で次を確認する。
- 設定された type property。
- @class。
- wrapper-array 形式の type id。
- polymorphic payload に現れる type identifier。
確認を通過してから EnvelopeCodec.read に入る。
中心の flow は次の通り。
try (JsonParser parser = objectMapper.createParser(bytes)) {
validateTypeIdsStreaming(parser);
}
Object envelope = EnvelopeCodec.read(objectMapper, bytes);
if (EnvelopeCodec.version(envelope)
!= EnvelopeCodec.currentVersion()) {
// failOnUnknownType=true のとき reject
}
完全な deserialization security audit ではないが、最も重要な type check を object instantiation 前に置いている。
SecureJacksonRedisSerializer.java と WhitelistPolicy.java を参照。
古い cache の migration をなぜ支えるのか
新 serializer が安全でも、古い cache は現在の envelope でない可能性がある。
migration config は四つの phase を明確に分ける。
| phase | behavior | source key を変更するか |
|---|---|---|
| SHADOW_READ | old value を decode・validate | しない |
| DUAL_WRITE | old value を残し、新 envelope を sidecar に write | しない |
| CUTOVER | old value を backup して source を replace | する |
| ROLLBACK | backup から old value を restore | する |
default phase は SHADOW_READ であり、application startup 時に自動 migration は実行しない。
SHADOW_READ:まず既存データを知る
SHADOW_READ は意図的に保守的である。
candidate key を scan
-> current envelope かを識別
-> 設定した legacy serializer で decode を試す
-> 成功/失敗を記録
-> Redis には何も write しない
migration 前の visibility を解決する。
旧 format と decode 可能な割合を先に知らずに直接 cutover すると、compatibility failure を通常の cache miss に見せてしまう。
DUAL_WRITE:まず sidecar に書く
DUAL_WRITE は old source key を残し、新 envelope を sidecar に書く。
user:42
user:42:__resicache_envelope
sidecar は source key の TTL を引き継ぐ。同じ内容で再度 migration しても、不要な再 write はしない。
利点は二つある。
- old consumer は old source key を読み続けられる。
- main data を replace する前に new format を検証できる。
CUTOVER:backup、compare、replace
cutover の順序は次の通り。
old source を読む
-> legacy backup sidecar に書く
-> source が old bytes のままか atomic compare
-> 一致した場合だけ new envelope に replace
-> KEEPTTL で lifetime を保持
重要な Lua semantics は次の通り。
if redis.call('get', KEYS[1]) == ARGV[1] then
redis.call('set', KEYS[1], ARGV[2], 'KEEPTTL')
return 1
else
return 0
end
migration が old value を読んだ後に business code が new value を write すると、compare は失敗する。
migration が old を読む
business が new を書く
migration が old -> envelope(old) を試みる
compare failure、新しい write を保護
migration を必ず完了させることが目的ではない。migration tool が新しい data overwriter になるのを防ぐ。
ROLLBACK も盲目的に overwrite してはいけない
rollback は backup を source に単純に書き戻さない。
current source が expected envelope のままか先に確認する。
current value が今回の cutover で生成した value のまま
-> old value の restore を許可
current value が business code により再度 write 済み
-> rollback を拒否し、新しい write を保護
rollback も unconditional overwrite ではなく conditional recovery である。
なぜ Redis を消去して切り替えないのか
cache が本当に捨てられ、business が一度の full source reload を受け入れられるなら、old cache を消して serializer を切り替える方が単純かもしれない。
ただし次の前提をすべて満たす必要がある。
- cache が失うと困る state を含まない。
- full source-load pressure を受け入れられる。
- すべての instance が同時に切り替えられる。
- old consumer が old format を書き続けない。
- canary、sidecar validation、rollback が不要。
現在の migration engine は、単純に消去できない、既存 format を観測したい、concurrent write を保護したい場合に向く。そのコストは次である。
- 複数の migration phase。
- sidecar と backup key。
- operator control。
- legacy decoder。
- migration report と failure handling。
- Redis wire format の追加 maintenance。
これは自動的に「より高度」という意味ではない。migration 中の control と運用複雑性を交換している。
type safety と migration safety は同時に成立する必要がある
legacy decoder も allowlist を bypass してはいけない。
test で使う悪意ある old JSON は次である。
{
"@class": "com.attacker.Gadget"
}
old format 由来だからと信頼せず、migration 中も reject される。
migration は security boundary 外の一時 script ではない。同じ untrusted または uncertain data を読むため、type constraint を再利用する必要がある。
test が検証する境界
serializer test は次を含む。
- allowlist 外の @class を拒否。
- wrapper-array type identifier を拒否。
- custom type property を拒否。
- allowlist 内 type の round-trip。
- serializer factory の設定伝達。
- refresh metadata のない古い CachedValue の read。
real Redis integration test の SerializationMigrationIntegrationTest は 8 test がすべて通過し、次をカバーする。
- SHADOW_READ は source key に write しない。
- DUAL_WRITE は old value を保持し idempotent に実行できる。
- sidecar は TTL を保持する。
- CUTOVER は old bytes を backup する。
- ROLLBACK は old value を restore する。
- concurrent business write の後は overwrite を拒否する。
- mixed dataset の allowlist 外 type を拒否する。
SerializationMigrationIntegrationTest.java を参照。
コストと失敗境界
この設計の境界も明示する必要がある。
- envelope version は format version だけで、任意の過去 format を自動 migration しない。
- current legacy decoder が support する format は限定され、別 serializer には別 decoder が必要。
- VersionEnvelope.payload の @JsonTypeInfo は固定の @class を使う。type property を変えるなら annotation と config を同時に変更する。
- default allowlist は自身の package name を優先し、custom business type は明示設定が必要。
- preflight は sample を scan するだけで Redis 全体に old format がないとは証明しない。
- migration scan は maxKeys、pattern、batch size に制限され、全 database の一つの transaction ではない。
- dryRun は risk を下げるが、real cutover test の代わりにはならない。
- allowlist と streaming preflight は完全な supply-chain security audit ではない。
- 本ラウンドでは serialization CPU、GC、Redis network overhead を測定しておらず、性能改善比率は主張できない。
移植できる判断方法
persistence format を変更するとき、少なくとも次を確認する。
- 新 format はどう識別するか。
- old format はいくつあり、各々 decode できるか。
- untrusted type はどこで reject するか。
- old consumer の read 能力を残す必要があるか。
- cutover は concurrent write の overwrite をどう防ぐか。
- TTL、expiry、sidecar をどう一致させるか。
- rollback は current value が今回の migration に属するかをどう判定するか。
- migration failure は全体を止めるか、key ごとに隔離して続行するか。
答えがなければ、serializer config を変更しただけでは migration 完了とは言えない。
出典と関連文書
研究記録
- 元の文書: src/content/note/ja/resicache-serialization-migration.md
- 出典リビジョン: f49953fb0d4bd52a39bf81167b0c4b7dd7d78a64
- 証拠の状態: 暫定