diff --git a/examples/aws-redis-serverless/README.md b/examples/aws-redis-serverless/README.md new file mode 100644 index 0000000000..5044582208 --- /dev/null +++ b/examples/aws-redis-serverless/README.md @@ -0,0 +1,107 @@ +# AWS Serverless Redis + +An example of deploying a Redis serverless cache using [Amazon ElastiCache Serverless](https://aws.amazon.com/elasticache/serverless/). + +This example demonstrates the new `serverless` option in SST's Redis component, which automatically scales based on usage and offers pay-per-use pricing. + +## Key Features + +- **Serverless Redis**: No instance management required +- **Automatic Scaling**: Scales based on usage +- **Pay-per-use**: Only pay for what you consume +- **Simplified VPC**: No NAT Gateway required for serverless +- **Same Client Interface**: Uses the same Redis client as traditional clusters + +## Get started + +1. **Clone and deploy** + + ```bash + git clone https://github.com/sst/sst + cd sst/examples/aws-redis-serverless + npm install + sst deploy + ``` + +2. **Test the Redis connection** + + Once deployed, you can invoke the function to test the Redis connection: + + ```bash + sst invoke MyApp + ``` + +## Usage + +The serverless Redis is created with default limits: + +```ts title="sst.config.ts" {4-7} +const redis = new sst.aws.Redis("MyRedis", { + vpc, + serverless: { + dataStorage: { maximum: 10, unit: "GB" }, + ecpuPerSeconds: { maximum: 5000 } + } +}); +``` + +You can also enable serverless mode with defaults: + +```ts title="sst.config.ts" {3} +const redis = new sst.aws.Redis("MyRedis", { + vpc, + serverless: true +}); +``` + +The client code remains exactly the same: + +```ts title="index.ts" {4-6,11-12} +import { Cluster } from "ioredis"; +import { Resource } from "sst"; + +const client = new Cluster([{ + host: Resource.MyRedis.host, + port: Resource.MyRedis.port, +}], { + redisOptions: { + tls: { checkServerIdentity: () => undefined }, + username: Resource.MyRedis.username, + password: Resource.MyRedis.password + } +}); +``` + +## Architecture + +``` +┌──────────────┐ ┌─────────────────────┐ +│ Lambda │───▶│ ElastiCache │ +│ Function │ │ Serverless Redis │ +└──────────────┘ └─────────────────────┘ + │ │ + └──────────────────────┘ + VPC Network +``` + +## Cost + +Serverless Redis pricing is based on: +- **Data Storage**: Per GB stored +- **ElastiCache Processing Units (ECPUs)**: Per second of processing + +Example cost for light usage: +- Storage: 1 GB = ~$0.125/month +- ECPUs: 1000 ECPU/second = ~$0.0034/hour + +This is significantly cheaper than traditional instances for variable workloads. + +## Differences from Traditional Redis + +| Traditional Redis | Serverless Redis | +|-------------------|------------------| +| Fixed instance costs | Pay-per-use pricing | +| Manual scaling | Automatic scaling | +| Requires NAT Gateway | Simplified networking | +| Cluster/non-cluster modes | Simplified configuration | +| Instance-based limits | Usage-based limits | diff --git a/examples/aws-redis-serverless/index.ts b/examples/aws-redis-serverless/index.ts new file mode 100644 index 0000000000..2109f870c8 --- /dev/null +++ b/examples/aws-redis-serverless/index.ts @@ -0,0 +1,30 @@ +import { Cluster } from "ioredis"; +import { Resource } from "sst"; + +const client = new Cluster( + [ + { + host: Resource.MyRedis.host, + port: Resource.MyRedis.port, + }, + ], + { + redisOptions: { + tls: { + checkServerIdentity: () => undefined, + }, + username: Resource.MyRedis.username, + password: Resource.MyRedis.password, + }, + } +); + +export async function handler() { + await client.set("foo", `bar-serverless-${Date.now()}`); + return { + statusCode: 200, + body: JSON.stringify({ + foo: await client.get("foo"), + }), + }; +} diff --git a/examples/aws-redis-serverless/package.json b/examples/aws-redis-serverless/package.json new file mode 100644 index 0000000000..aa0cd6e8ad --- /dev/null +++ b/examples/aws-redis-serverless/package.json @@ -0,0 +1,17 @@ +{ + "name": "aws-redis-serverless", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "ioredis": "^5.4.1", + "sst": "latest" + } +} diff --git a/examples/aws-redis-serverless/sst-env.d.ts b/examples/aws-redis-serverless/sst-env.d.ts new file mode 100644 index 0000000000..294fde015e --- /dev/null +++ b/examples/aws-redis-serverless/sst-env.d.ts @@ -0,0 +1,16 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ + +declare module "sst" { + export interface Resource { + "MyVpc": { + "type": "sst.aws.Vpc" + } + } +} +/// + +import "sst" +export {} \ No newline at end of file diff --git a/examples/aws-redis-serverless/sst.config.ts b/examples/aws-redis-serverless/sst.config.ts new file mode 100644 index 0000000000..66ca9dce65 --- /dev/null +++ b/examples/aws-redis-serverless/sst.config.ts @@ -0,0 +1,28 @@ +/// + +export default $config({ + app(input) { + return { + name: "aws-redis-serverless", + removal: input?.stage === "production" ? "retain" : "remove", + home: "aws", + }; + }, + async run() { + // Serverless Redis doesn't require NAT Gateways + const vpc = new sst.aws.Vpc("MyVpc"); + const redis = new sst.aws.Redis("MyRedis", { + vpc, + serverless: { + dataStorage: { maximum: 10, unit: "GB" }, + ecpuPerSeconds: { maximum: 5000 } + } + }); + new sst.aws.Function("MyApp", { + handler: "index.handler", + url: true, + vpc, + link: [redis], + }); + }, +}); diff --git a/platform/src/components/aws/redis.ts b/platform/src/components/aws/redis.ts index c690f39270..ef804452fe 100644 --- a/platform/src/components/aws/redis.ts +++ b/platform/src/components/aws/redis.ts @@ -102,6 +102,65 @@ export interface RedisArgs { * ``` */ parameters?: Input>>; + /** + * Enable serverless Redis using Amazon ElastiCache Serverless. + * + * Serverless Redis automatically scales based on usage and offers pay-per-use pricing. + * When enabled, traditional cluster configuration options like `instance`, `cluster`, and `parameters` are ignored. + * + * @default `false` + * @example + * Enable serverless Redis with default limits. + * ```js + * { + * serverless: true + * } + * ``` + * + * Configure serverless Redis with custom limits. + * ```js + * { + * serverless: { + * dataStorage: { maximum: 100, unit: "GB" }, + * ecpuPerSeconds: { maximum: 50000 } + * } + * } + * ``` + */ + serverless?: Input< + | boolean + | { + /** + * The maximum data storage limit in GB. + * + * @default `{ maximum: 10, unit: "GB" }` + * @example + * ```js + * { + * dataStorage: { maximum: 100, unit: "GB" } + * } + * ``` + */ + dataStorage?: Input<{ + maximum: Input; + unit: Input<"GB">; + }>; + /** + * The maximum ElastiCache Processing Units (ECPU) per second. + * + * @default `{ maximum: 5000 }` + * @example + * ```js + * { + * ecpuPerSeconds: { maximum: 50000 } + * } + * ``` + */ + ecpuPerSeconds?: Input<{ + maximum: Input; + }>; + } + >; /** * The VPC to use for the Redis instance. * @@ -209,6 +268,10 @@ export interface RedisArgs { * Transform the Redis cluster. */ cluster?: Transform; + /** + * Transform the Redis serverless cache. + */ + serverlessCache?: Transform; }; } @@ -230,6 +293,30 @@ interface RedisRef { * const redis = new sst.aws.Redis("MyRedis", { vpc }); * ``` * + * #### Create a serverless Redis + * + * You can also create a serverless Redis instance that automatically scales based on usage. + * + * ```js title="sst.config.ts" + * const vpc = new sst.aws.Vpc("MyVpc"); + * const redis = new sst.aws.Redis("MyRedis", { + * vpc, + * serverless: true + * }); + * ``` + * + * Configure serverless limits. + * + * ```js title="sst.config.ts" + * const redis = new sst.aws.Redis("MyRedis", { + * vpc, + * serverless: { + * dataStorage: { maximum: 100, unit: "GB" }, + * ecpuPerSeconds: { maximum: 50000 } + * } + * }); + * ``` + * * #### Link to a resource * * You can link your cluster to other resources, like a function or your Next.js app. @@ -307,6 +394,7 @@ interface RedisRef { */ export class Redis extends Component implements Link.Linkable { private cluster?: Output; + private serverlessCache?: Output; private _authToken?: Output; private dev?: { enabled: boolean; @@ -334,9 +422,6 @@ export class Redis extends Component implements Link.Linkable { const version = all([engine, args.version]).apply( ([engine, v]) => v ?? (engine === "redis" ? "7.1" : "7.2"), ); - const instance = output(args.instance).apply((v) => v ?? "t4g.micro"); - const argsCluster = normalizeCluster(); - const vpc = normalizeVpc(); const dev = registerDev(); if (dev?.enabled) { @@ -344,13 +429,24 @@ export class Redis extends Component implements Link.Linkable { return; } + const vpc = normalizeVpc(); + const serverless = normalizeServerless(); + if (serverless.enabled) { + const serverlessCache = createServerlessCache(); + this.serverlessCache = serverlessCache; + return; + } + const instance = output(args.instance).apply((v) => v ?? "t4g.micro"); + const argsCluster = normalizeCluster(); + const { authToken, secret } = createAuthToken(); + this._authToken = authToken; + const subnetGroup = createSubnetGroup(); const parameterGroup = createParameterGroup(); const cluster = createCluster(); this.cluster = cluster; - this._authToken = authToken; function reference() { const ref = args as unknown as RedisRef; @@ -460,6 +556,26 @@ Listening on "${dev.host}:${dev.port}"...`, }); } + function normalizeServerless() { + return output(args.serverless).apply((v) => { + if (v === true) { + return { + enabled: true, + dataStorage: { maximum: 10, unit: "GB" }, + ecpuPerSeconds: { maximum: 5000 }, + }; + } + if (v === false || v === undefined) { + return { enabled: false }; + } + return { + enabled: true, + dataStorage: v.dataStorage ?? { maximum: 10, unit: "GB" }, + ecpuPerSeconds: v.ecpuPerSeconds ?? { maximum: 5000 }, + }; + }); + } + function createAuthToken() { const authToken = new RandomPassword( `${name}AuthToken`, @@ -585,13 +701,54 @@ Listening on "${dev.host}:${dev.port}"...`, ), ); } + + function createServerlessCache() { + return serverless.apply( + (serverless) => + new elasticache.ServerlessCache( + ...transform( + args.transform?.serverlessCache, + `${name}ServerlessCache`, + { + description: "Managed by SST", + engine, + name: `${name.toLowerCase()}`, + majorEngineVersion: version.apply((v) => { + // Extract major version (e.g., "7.1" -> "7") + const majorVersion = v.split(".")[0]; + return majorVersion; + }), + cacheUsageLimits: { + dataStorage: { + maximum: serverless.dataStorage?.maximum ?? 10, + unit: serverless.dataStorage?.unit ?? "GB", + }, + ecpuPerSeconds: [ + { + maximum: serverless.ecpuPerSeconds?.maximum ?? 5000, + }, + ], + }, + securityGroupIds: vpc.securityGroups, + subnetIds: vpc.subnets, + tags: { + "sst:component-version": _version.toString(), + }, + }, + { parent: self }, + ), + ), + ); + } } /** * The ID of the Redis cluster. */ public get clusterId() { - return this.dev ? output("placeholder") : this.cluster!.id; + if (this.dev) return output("placeholder"); + if (this.serverlessCache) return this.serverlessCache.id; + return this.cluster!.id; } /** @@ -612,20 +769,30 @@ Listening on "${dev.host}:${dev.port}"...`, * The host to connect to the Redis cluster. */ public get host() { - return this.dev - ? this.dev.host - : this.cluster!.clusterEnabled.apply((enabled) => - enabled - ? this.cluster!.configurationEndpointAddress - : this.cluster!.primaryEndpointAddress, - ); + if (this.dev) return this.dev.host; + if (this.serverlessCache) { + return this.serverlessCache.endpoints.apply( + (endpoints) => endpoints?.[0]?.address ?? "", + ); + } + return this.cluster!.clusterEnabled.apply((enabled) => + enabled + ? this.cluster!.configurationEndpointAddress + : this.cluster!.primaryEndpointAddress, + ); } /** * The port to connect to the Redis cluster. */ public get port() { - return this.dev ? this.dev.port : this.cluster!.port.apply((v) => v!); + if (this.dev) return this.dev.port; + if (this.serverlessCache) { + return this.serverlessCache.endpoints.apply( + (endpoints) => endpoints?.[0]?.port ?? 6379, + ); + } + return this.cluster!.port.apply((v) => v!); } /** @@ -642,6 +809,16 @@ Listening on "${dev.host}:${dev.port}"...`, throw new VisibleError("Cannot access `nodes.cluster` in dev mode."); return _this.cluster!; }, + /** + * The ElastiCache Redis serverless cache. + */ + get serverlessCache() { + if (_this.dev) + throw new VisibleError( + "Cannot access `nodes.serverlessCache` in dev mode.", + ); + return _this.serverlessCache!; + }, }; } diff --git a/platform/src/components/component.ts b/platform/src/components/component.ts index 24bedb419b..68507ca853 100644 --- a/platform/src/components/component.ts +++ b/platform/src/components/component.ts @@ -233,6 +233,11 @@ export class Component extends ComponentResource { 40, { lower: true, replace: (name) => name.replaceAll(/-+/g, "-") }, ], + "aws:elasticache/serverlessCache:ServerlessCache": [ + "name", + 40, + { lower: true, replace: (name) => name.replaceAll(/-+/g, "-") }, + ], "aws:elasticache/subnetGroup:SubnetGroup": [ "name", 255,