我们在运行时中内置了一个 Redis 服务器。
We put a Redis server inside our runtime

原始链接: https://encore.dev/blog/redis-runtime

为了确保本地开发环境与生产环境的一致性,Encore 将基础设施直接集成到了其运行时中,而不是依赖外部的 Docker 容器。此前,基于 Go 的 Encore 应用使用的是内存版 Redis;而在开发新的 Rust 运行时以避免管理辅助进程的复杂性时,团队需要一种原生的解决方案。 Encore 的开发者通过将 `miniredis` 库移植到 Rust 来解决了这个问题。该实现作为运行时内部的一个库运行,提供了一个功能完备的 Redis 服务器,支持通过标准 Redis 通信协议进行事务、Lua 脚本和发布/订阅等复杂操作。通过嵌入服务器,Encore 允许开发者在代码中声明缓存基础设施,无需手动设置;运行时会自动检测环境,并根据情况在托管的 Redis 连接与本地内存实例之间进行切换。 为了保证高保真度,团队通过运行原始 Go 实现的集成测试套件来验证了该 Rust 移植版本,并逐字节对比了响应结果。这确保了应用逻辑在开发和生产环境中表现一致。最终,这种方法消除了管理本地依赖的繁琐,同时保持了生产部署所需的严格运维标准。

Encore.dev 团队在其运行时中内置了一个用 Rust 编写的、兼容 Redis 的服务器,专门用于本地开发。 这一公告在 Hacker News 社区引发了质疑。批评者认为,为了确保与官方更新保持字节级的兼容性而维护一个 Redis 的重实现,相比直接运行标准的 Redis 容器,是一种不必要的技术负担。 评论者指出,Docker Compose 和 TestContainers 等工具已经让管理 Redis 实例变得非常简单,这使得该内部实现所谓的“易用性”论点显得苍白无力。一些贡献者将这种做法称为“疯狂”,认为为了避免运行一个额外的容器而重写复杂的底层架构,是在为不存在的问题提供一种过度设计的解决方案。
相关文章

原文

Encore runs the same backend code in local development, in tests, and in production. The infrastructure an application depends on is declared in its code, and Encore provisions that infrastructure for each environment. For local development to be useful, the infrastructure has to actually be present on your machine, and it has to behave the way it does in production.

Most of it is straightforward to stand up locally, with databases running in Docker and pub/sub running against a local NSQ daemon. A cache is harder, because the realistic options are to run a real Redis in Docker, which is another container to install and keep alive, or to replace it with a mock, which only behaves like Redis until your code relies on something the mock implements differently.

We took a different approach, where the runtime has an in-memory Redis server built into it that starts automatically in local development and tests, in the same process as the runtime. This post covers how that works, why we ported a Go implementation to Rust to get there, and how we make sure the in-memory server behaves the same as the Redis an application talks to in production.

Encore's Go runtime has worked this way for a long time. On the Go side, local development uses alicebob/miniredis, an in-memory Redis server written in Go, so that running an application locally needs no external Redis.

When we built the Rust runtime that powers TypeScript applications, we needed the same capability there. One option was to keep the Go implementation and run it as a separate process that the runtime starts and stops, but that means shipping a second binary and supervising another process alongside the runtime, with its own startup, shutdown, and failure modes. We wanted the in-memory server to live inside the runtime, the way the rest of the infrastructure layer does.

So we ported miniredis to Rust (#2300), where it runs as a library inside the runtime. The port is about 25,000 lines of Rust and implements the data types applications actually use: strings, hashes, lists, sets, sorted sets, streams, pub/sub, transactions, and Lua scripting. It is a real Redis server that listens on a TCP socket and speaks the Redis wire protocol (RESP), rather than a stub that emulates a subset of commands.

Porting it also meant carrying over the operational behavior the Go version had. miniredis keeps its own mock clock, so the embedded server runs a small background task that advances that clock once a second to keep time-based expiry working during a long session, and prunes back to a bounded number of keys so a local cache does not grow without limit:

async fn cleanup_task(server: Miniredis) { let mut interval = tokio::time::interval(Duration::from_secs(1)); loop { interval.tick().await; server.fast_forward(Duration::from_secs(1)); } }

A cache in an Encore application is declared in code, the same way every other resource is:

import { CacheCluster, IntKeyspace, expireIn } from "encore.dev/storage/cache"; const cluster = new CacheCluster("rate-limit", { evictionPolicy: "allkeys-lru", }); const requestsPerUser = new IntKeyspace<{ userId: string }>(cluster, { keyPattern: "requests/:userId", defaultExpiry: expireIn(10 * 1000), });

That declaration is all the runtime needs. In a deployed environment, Encore provisions a real Redis and the runtime connects to it, and in local development and tests the runtime starts the built-in server on a local address and connects to that instead. The decision comes from the runtime configuration, where each Redis cluster carries an in_memory flag, and when that flag is set the runtime starts the embedded server rather than dialing the configured servers (#2322).

In the runtime that decision is small. When the embedded server is needed, the runtime starts it and hands the same Redis client it would use for a managed cluster a redis:// address pointing at the local server:

let needs_miniredis = self.testing || self.clusters.iter().any(|c| c.in_memory); if needs_miniredis { let server = self.runtime.block_on(MiniredisServer::start())?; let url = format!("redis://{}", server.addr()); let client = redis::Client::open(url)?; }

The application code is identical in both cases. It holds a keyspace and calls get, set, increment, and the rest against a Redis client. The only thing that changes between local and production is the address the client connects to. Because the embedded server speaks the same protocol over the same kind of socket, the client connects to it exactly as it connects to a managed Redis.

An embedded server is only useful if it behaves like the Redis it stands in for. A small difference in how one command handles an edge case is the kind of thing that passes locally and fails in production, which is the situation local-production parity exists to avoid.

To guard against that, we test the Rust server against the implementation we ported from. miniredis ships with a Go integration suite that runs commands against a live server and checks the responses. We run that same suite against our Rust server and compare the raw RESP responses byte for byte. When the bytes match, our server is answering the way the reference implementation does.

Running the reference suite this way surfaced differences that would be easy to miss otherwise. One of them was in how expiry is tested, where the suite advances a mock clock to check that keys expire on schedule, so the Rust server needed a command the tests could call to fast-forward its own clock to the same effect. Another was TLS, where the certificate chain the suite used was accepted by Go's TLS implementation but rejected by Rust's, so connecting at all required building a proper certificate hierarchy for the tests. Neither difference is one a hand-written mock would reproduce, and both would have gone unnoticed without comparing against the reference.

In local development and tests, a cache is something you declare and use without installing or running anything alongside your application. Tests exercise a real Redis server rather than a mock, so the command behavior a test depends on is the behavior it will meet in production.

The embedded server is only for local development and tests. In production Encore provisions a real, managed Redis, because the embedded server is a development fixture and is not built to scale. Building it into the runtime is what lets local development and tests match production without anyone standing up a cache to get there.

If you want to go deeper, the docs cover how Encore provisions infrastructure from your code and the cache primitive itself.

联系我们 contact @ memedata.com