mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-10 04:20:01 +00:00
docs(plans): research AWS vs GCP free tier + create AWS port plan
- Research reports comparing AWS/GCP free tiers and greenfield trade-offs - New AWS port plan with 7 phases (infrastructure, deployment, integration) - Mark GCP plan deploy phases as superseded by new AWS plan
This commit is contained in:
@@ -14,6 +14,8 @@ supersedes: [260425-1945-mongodb-atlas-migration]
|
||||
|
||||
# Plan: Go port → Google Cloud Run (free tier)
|
||||
|
||||
> **2026-05-10:** Deploy phases (01, 09–12) **superseded by [`plans/260510-0114-aws-port/`](../260510-0114-aws-port/plan.md)** — strict $0 free-tier goal motivated switch to AWS Lambda + DynamoDB + EventBridge. Module work (phases 03–07) is done and **reused unchanged** by the AWS plan. Phase 08 (trading) remains pending, cloud-agnostic, can be tackled before or after the AWS cutover.
|
||||
|
||||
Full rewrite of miti99bot in Go for deployment on Cloud Run, swapping CF KV+D1+Workers AI for Firestore Native + Gemini API + Cloud Scheduler. Source repo lives at a new `miti99bot-go` repo (separate). Cutover via dual-run + soak.
|
||||
|
||||
## Locked decisions
|
||||
@@ -45,7 +47,7 @@ Full rewrite of miti99bot in Go for deployment on Cloud Run, swapping CF KV+D1+W
|
||||
| 04 | [Firestore KVStore + per-module prefixing](phase-04-firestore-kv.md) | done | 4h | `FirestoreKVStore`, emulator tests, KVProvider abstraction (Memory + Firestore) |
|
||||
| 05 | [Port simple modules (util/misc/wordle/loldle)](phase-05-port-simple-modules.md) | done | 6h | 4 KV-only modules at JS parity; shared `internal/keylock` extracted |
|
||||
| 06 | [Port loldle variants + lolschedule](phase-06-port-loldle-variants.md) | done | 5h | All five sub-modules ported (emoji, quote, ability, splash, lolschedule); lolschedule daily-push cron deferred to Phase 09 |
|
||||
| 07 | [Gemini AI + port semantle/doantu/twentyq](phase-07-gemini-ai-modules.md) | pending | 6h | 3 AI modules with rate-limit handling |
|
||||
| 07 | [Gemini AI + port semantle/doantu/twentyq](phase-07-gemini-ai-modules.md) | done | 6h | `internal/ai` (Embedder/Chatter + per-user bucket); semantle (text-embedding-004), doantu (phow2sim HTTP — JS-parity deviation), twentyq (gemini-2.5-flash) |
|
||||
| 08 | [Port trading + composite indexes](phase-08-port-trading.md) | pending | 6h | VN-stocks paper trading + daily price cron |
|
||||
| 09 | [Cloud Scheduler cron wiring](phase-09-cloud-scheduler.md) | pending | 2h | 2 jobs → `/cron/{name}` with OIDC |
|
||||
| 10 | [CI/CD + Dockerfile + Secret Manager](phase-10-ci-cd.md) | pending | 4h | GHA pipeline → AR → Cloud Run, idempotent |
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "AWS bootstrap + IAM OIDC + SAM skeleton"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "3h"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 01: AWS bootstrap + IAM OIDC + SAM skeleton
|
||||
|
||||
## Overview
|
||||
Stand up the AWS account with strict $0 footprint: IAM OIDC trust for GitHub Actions, baseline SAM stack that deploys an empty Lambda + DynamoDB table + Function URL placeholder. Nothing wired to real bot yet.
|
||||
|
||||
## Requirements
|
||||
- **Functional:** Empty stack deploys via `sam deploy --guided` from local. GitHub Actions can assume the deploy role via OIDC (no long-lived keys).
|
||||
- **Non-functional:** Region `ap-southeast-1`. Single AWS account. Stack name `miti99bot-aws-port`. All resources tagged `app=miti99bot, env=prod`. Strict free-tier resources only.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
GitHub Actions ─OIDC─► AWS IAM Role (github-deploy)
|
||||
│ └─ trust: token.actions.githubusercontent.com
|
||||
│ └─ scoped: repo:tiennm99/miti99bot-go:ref:refs/heads/main
|
||||
│
|
||||
└─► CloudFormation (SAM) ─► Lambda + DynamoDB + ParamStore + EventBridge + Logs
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
- Create: `template.yaml` (SAM root, all resources declared here)
|
||||
- Create: `samconfig.toml` (stack name, region, capabilities)
|
||||
- Create: `aws/iam-github-oidc-trust.json` (one-shot reference doc, not deployed)
|
||||
- Create: `aws/README.md` (commands cheat sheet for first-time setup)
|
||||
- Create: `Makefile` (targets: `build`, `package`, `deploy`, `logs`)
|
||||
- Modify: `.gitignore` (add `.aws-sam/`, `samconfig.toml.local`)
|
||||
|
||||
## Implementation Steps
|
||||
1. Create AWS account (or reuse existing). Enable MFA on root, create IAM admin user for one-time bootstrap. Set region default `ap-southeast-1`.
|
||||
2. Create the GitHub OIDC identity provider in IAM: thumbprint, audience `sts.amazonaws.com`. (One-time, manual or via small CloudFormation snippet.)
|
||||
3. Create IAM role `github-deploy-miti99bot` with trust policy scoped to `repo:tiennm99/miti99bot-go:ref:refs/heads/main` and `repo:tiennm99/miti99bot-go:ref:refs/heads/dev`. Attach managed policies for SAM deploy: CloudFormation, Lambda, DynamoDB, EventBridge, IAM (PassRole only), SSM Parameter Store, Logs, S3 (SAM staging bucket).
|
||||
4. Write `template.yaml` skeleton:
|
||||
- `AWSTemplateFormatVersion: '2010-09-09'`, `Transform: AWS::Serverless-2016-10-31`
|
||||
- `Globals.Function`: `Runtime: provided.al2023`, `Architectures: [arm64]`, `MemorySize: 256`, `Timeout: 15`, `Tracing: Active` (still free at this volume)
|
||||
- `Resources.BotFunction`: empty handler (`bootstrap` not yet built), Function URL with `AuthType: NONE`
|
||||
- `Resources.BotTable`: DynamoDB on-demand, PK=`pk` (S), no GSI yet
|
||||
- Outputs: function URL, table name
|
||||
5. Write `samconfig.toml` with stack name, region, capabilities (`CAPABILITY_IAM`).
|
||||
6. First deploy: `sam build && sam deploy --guided` from local using bootstrap admin credentials. Confirm stack reaches `CREATE_COMPLETE`. Save the Function URL.
|
||||
7. Verify GH Actions OIDC by running a one-shot workflow that calls `aws sts get-caller-identity` — confirms trust works without keys.
|
||||
8. Manual smoke: `curl <function-url>` returns 502 (no handler yet) — proves URL is reachable.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] AWS account active, MFA on root, region default `ap-southeast-1`
|
||||
- [ ] GitHub OIDC provider created
|
||||
- [ ] `github-deploy-miti99bot` IAM role assumes successfully from a test GH Actions run
|
||||
- [ ] `sam deploy` succeeds; stack `miti99bot-aws-port` in `CREATE_COMPLETE`
|
||||
- [ ] DynamoDB table `miti99bot` exists, on-demand billing mode
|
||||
- [ ] Function URL reachable (502 expected)
|
||||
- [ ] AWS Cost Explorer shows $0 spend after 24h
|
||||
|
||||
## Risk Assessment
|
||||
- **OIDC trust scope too loose** (any branch / any repo) → Mitigation: scope to specific repo + ref pattern; review `sub` claim in CloudTrail after first successful run.
|
||||
- **IAM policy over-broad** → Mitigation: start with managed policies for speed, tighten in Phase 06 once resource ARNs stable.
|
||||
- **SAM staging bucket created in wrong region / accumulates artifacts** → Mitigation: pin region in samconfig; add lifecycle rule (7-day expiration) on staging bucket.
|
||||
- **CloudFormation drift** if user edits via console → Mitigation: forbid console edits, document in `aws/README.md`.
|
||||
|
||||
## Open questions
|
||||
1. Single account vs separate dev/prod accounts? Single is simpler for solo dev; defer split until usage warrants.
|
||||
2. Reuse SAM staging bucket from existing AWS work or fresh one? Fresh, scoped to this stack, easier to clean up.
|
||||
3. Pin SAM CLI version in `Makefile`? Yes, document expected version (current latest works); rely on `setup-sam` action in CI to pin.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Lambda runtime (Go ZIP + LWA + Function URL)"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 02: Lambda runtime (Go ZIP + LWA + Function URL)
|
||||
|
||||
## Overview
|
||||
Make the existing Go HTTP server run as a Lambda behind a Function URL with zero handler-code changes, using AWS Lambda Web Adapter. Routes `/` (healthcheck) and `/webhook` work end-to-end with secret verification.
|
||||
|
||||
## Requirements
|
||||
- **Functional:** Function URL responds to `GET /` with the existing health JSON, and to `POST /webhook` with the existing Telegram dispatcher logic. Secret-token verification (`X-Telegram-Bot-Api-Secret-Token`) preserved.
|
||||
- **Non-functional:** Cold start P95 < 1.5s for ARM64 Go ZIP. Memory 256 MiB. Timeout 15s. Binary size <30 MiB.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
Telegram ──HTTPS──► Function URL ──► Lambda runtime
|
||||
│
|
||||
├── LWA layer (extension) translates Lambda event → HTTP
|
||||
│ └── localhost:8080 (LWA listens here)
|
||||
│
|
||||
└── bootstrap binary (existing Go server)
|
||||
starts http.ListenAndServe(":8080", ...)
|
||||
dispatcher → modules → DynamoDB / Gemini
|
||||
```
|
||||
|
||||
LWA is added as a Lambda layer; binary just runs `http.ListenAndServe` — no Lambda SDK import required.
|
||||
|
||||
## Related Code Files
|
||||
- Create: `cmd/server/lambda.go` (build-tag `lambda`, sets `PORT=8080` defaults; minimal — possibly empty)
|
||||
- Modify: `cmd/server/main.go` — accept `PORT` env (likely already does), confirm graceful shutdown on `SIGTERM` (LWA sends it on shutdown)
|
||||
- Modify: `template.yaml` — wire `BotFunction` properly:
|
||||
- `CodeUri: build/` (ZIP staging)
|
||||
- `Handler: bootstrap`
|
||||
- `Layers: [arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:<latest>]`
|
||||
- `Environment.Variables`: `AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap`, `PORT=8080`, `READINESS_CHECK_PATH=/`, `MODULES=util,misc,wordle,...`, `TELEGRAM_BOT_TOKEN={{resolve:ssm-secure:...}}`, etc.
|
||||
- Modify: `Makefile` — add `build-lambda` target: `GOOS=linux GOARCH=arm64 go build -tags lambda.norpc -ldflags="-s -w" -o build/bootstrap ./cmd/server && chmod +x build/bootstrap`
|
||||
- Reference: `internal/server/router.go` (unchanged)
|
||||
- Reference: `internal/telegram/*.go` (unchanged)
|
||||
|
||||
## Implementation Steps
|
||||
1. Confirm `cmd/server/main.go` reads `PORT` env (it does per inspection). Confirm graceful shutdown on `SIGTERM`/`SIGINT`.
|
||||
2. Add `build-lambda` Makefile target. Test locally: `make build-lambda && file build/bootstrap` shows ARM64 ELF.
|
||||
3. Pick latest LWA layer ARN for `ap-southeast-1` ARM64 — pin major version in `template.yaml` with comment linking to release notes.
|
||||
4. Add Function env vars in `template.yaml`. Use `{{resolve:ssm-secure:...}}` for secrets so values never appear in template. Reference Phase 01's Parameter Store names.
|
||||
5. Wire DynamoDB IAM permissions (read/write on table) via `Policies: - DynamoDBCrudPolicy`. Wire SSM read perms via `SSMParameterReadPolicy`.
|
||||
6. Build + deploy: `sam build && sam deploy`. Tail logs: `sam logs --tail`.
|
||||
7. Smoke test:
|
||||
- `curl <function-url>/` → 200 with health JSON
|
||||
- `curl -XPOST <function-url>/webhook -H "X-Telegram-Bot-Api-Secret-Token: wrong"` → 401
|
||||
- `curl -XPOST <function-url>/webhook -H "X-Telegram-Bot-Api-Secret-Token: <real>" -d '{"update_id":1,"message":{"text":"/start","chat":{"id":1},"from":{"id":1}}}'` → 200 (or expected dispatcher response)
|
||||
8. Set Telegram dev-bot webhook to Function URL. Send `/start` from real client. Confirm response in chat.
|
||||
9. Capture cold-start P95 from CloudWatch Logs `Init Duration` field over 20+ invocations (use Powertools or grep). Record in this phase's "Risks" if >1s.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `make build-lambda` produces ARM64 binary <30 MiB
|
||||
- [ ] `sam deploy` updates `BotFunction` successfully
|
||||
- [ ] `curl <function-url>/` returns health JSON
|
||||
- [ ] Wrong webhook secret → 401
|
||||
- [ ] Correct webhook secret + valid update → dispatcher responds
|
||||
- [ ] Telegram dev bot exchanges messages end-to-end via Function URL
|
||||
- [ ] Cold start P95 < 1.5s
|
||||
|
||||
## Risk Assessment
|
||||
- **LWA cold-start tax** adds ~100ms — acceptable; if not, fall back to `lambda.Start()` adapter path (rewrite handler, more invasive).
|
||||
- **`{{resolve:ssm-secure:...}}` requires CloudFormation perms** — SAM handles this if role has `ssm:GetParameter*`.
|
||||
- **Webhook secret leakage in logs** — confirm `internal/server/router.go` does not log header value; if it does, redact.
|
||||
- **Binary too large** (>50 MiB unzipped) — strip with `-ldflags="-s -w"` (already done); if still too big, audit deps with `go build -ldflags="-s -w" -trimpath` + `goweight`.
|
||||
- **ARM64 incompatibility** with any cgo dep — confirm `CGO_ENABLED=0` in build (existing Dockerfile does this).
|
||||
|
||||
## Open questions
|
||||
1. Should LWA `READINESS_CHECK_PATH` be `/` or a dedicated `/healthz`? `/` works since handler is cheap; revisit if `/` ever does work.
|
||||
2. Telegram delivery reliability with cold-start 1–3s — acceptable in practice (Telegram retries), but document in README.
|
||||
3. Provisioned concurrency to eliminate cold start — kills free tier, defer indefinitely.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "DynamoDB KV provider"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 03: DynamoDB KV provider
|
||||
|
||||
## Overview
|
||||
Add `DynamoDBKVStore` + `DynamoDBProvider` as a sibling to the existing Firestore impl, satisfying the same `KVStore` / `KVProvider` interface. Selectable via `KV_PROVIDER=dynamodb|firestore|memory` env. Default in production: `dynamodb`. Firestore impl preserved for parity tests.
|
||||
|
||||
## Requirements
|
||||
- **Functional:** All existing modules' KV ops (Get/Put/Delete/List + JSON convenience methods) work against DynamoDB with byte-for-byte parity to Firestore where observable.
|
||||
- **Non-functional:** Single-table design. On-demand billing. P99 < 50ms for Get/Put. List() with prefix uses `Query` (not `Scan`) — must be cheap.
|
||||
|
||||
## Architecture
|
||||
**Single-table schema (composite key):**
|
||||
```
|
||||
TableName: miti99bot-data
|
||||
PK (pk): string = moduleName (e.g. "wordle")
|
||||
SK (sk): string = caller-provided key (e.g. "user:42:state")
|
||||
attrs:
|
||||
value: binary raw bytes
|
||||
updatedAt: number epoch nanos (parity with Firestore impl)
|
||||
```
|
||||
|
||||
Composite key is the canonical DynamoDB shape for prefix-scan workloads: `Query` supports `begins_with(sk, :prefix)` on the **sort key**, but only `=` on the partition key — so the sort key holds the user-supplied key and the partition key holds the module name (which gives free isolation by partition).
|
||||
|
||||
**Operations:**
|
||||
- `Get(key)` → `GetItem(pk=module, sk=key)` with `ConsistentRead: true` (parity with Firestore strong read)
|
||||
- `Put(key, val)` → `PutItem(pk=module, sk=key, value=val, updatedAt=now)`
|
||||
- `Delete(key)` → `DeleteItem(pk=module, sk=key)`
|
||||
- `List(prefix)` → `Query(pk=module AND begins_with(sk, prefix))` paginated
|
||||
|
||||
**Provider isolation:** `For(moduleName)` returns a `DynamoDBKVStore` bound to that module name; the partition key is the isolation boundary. No prefix wrapping needed at this layer.
|
||||
|
||||
**Reserved word handling:** `value` is reserved in DynamoDB expressions; resolved via `ExpressionAttributeNames` (`#v` → `value`).
|
||||
|
||||
## Related Code Files
|
||||
- Create: `internal/storage/dynamodb_client.go` — AWS SDK v2 client init, region from env
|
||||
- Create: `internal/storage/dynamodb_kv.go` — `DynamoDBKVStore` (Get/Put/Delete/List + JSON helpers)
|
||||
- Create: `internal/storage/dynamodb_provider.go` — `DynamoDBProvider`, `For()` returns module-bound store
|
||||
- Create: `internal/storage/dynamodb_kv_test.go` — uses `localstack` or DynamoDB Local via `testcontainers-go`
|
||||
- Create: `internal/storage/dynamodb_provider_test.go` — cross-module isolation
|
||||
- Create: `internal/storage/parity_test.go` (optional) — runs the same op sequence against Memory + Firestore + DynamoDB and asserts identical observables
|
||||
- Modify: `cmd/server/main.go` — read `KV_PROVIDER`, branch on value; default `dynamodb` when running on Lambda (detect via `AWS_LAMBDA_FUNCTION_NAME` env)
|
||||
- Modify: `go.mod` — add `github.com/aws/aws-sdk-go-v2`, `…/config`, `…/service/dynamodb`, `…/feature/dynamodb/attributevalue`, `…/feature/dynamodb/expression`
|
||||
|
||||
## Implementation Steps
|
||||
1. Add SDK deps. `go mod tidy`.
|
||||
2. Implement `DynamoDBKVStore` with the same method set as `FirestoreKVStore`. Key composition: `pk = moduleName + "#" + key`.
|
||||
3. `List(prefix)` implementation — `Query` with `KeyConditionExpression` on PK begins-with semantics (use range trick or `BEGINS_WITH` on PK; AWS docs: `BEGINS_WITH` works on sort key only, so use the start/end range trick on PK directly).
|
||||
4. JSON helpers (`GetJSON`, `PutJSON`) — mirror Firestore impl exactly: marshal/unmarshal with `encoding/json`, store as binary, `ErrNotFound` semantics preserved.
|
||||
5. Tests with DynamoDB Local (Docker image `amazon/dynamodb-local`). Add `make dynamodb-local` target. Skip if `DYNAMODB_LOCAL_URL` env unset (so CI without Docker can still build).
|
||||
6. Cross-module isolation test: Put `wordle#k=A`, `loldle#k=B`, assert `wordleStore.Get("k") == A`, `loldleStore.Get("k") == B`, `loldleStore.List("") returns ["k"]` (not `[wordle#k, loldle#k]`).
|
||||
7. Wire provider selection in `main.go`:
|
||||
```go
|
||||
switch os.Getenv("KV_PROVIDER") {
|
||||
case "dynamodb": kv = storage.NewDynamoDBProvider(...)
|
||||
case "firestore": kv = storage.NewFirestoreProvider(...)
|
||||
default: kv = storage.NewMemoryProvider()
|
||||
}
|
||||
```
|
||||
8. Manual smoke against deployed Lambda: send `/start`, then verify `aws dynamodb scan --table-name miti99bot --max-items 5` shows expected keys.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `dynamodb_kv_test.go` passes against DynamoDB Local
|
||||
- [ ] `dynamodb_provider_test.go` passes (cross-module isolation)
|
||||
- [ ] `parity_test.go` (if added) passes — Memory ≡ Firestore ≡ DynamoDB on observables
|
||||
- [ ] `KV_PROVIDER=dynamodb` works in deployed Lambda end-to-end
|
||||
- [ ] One full game session of `/wordle` → state persists across invocations (cold-start safe)
|
||||
- [ ] List() with prefix returns expected keys, no `Scan` calls in CloudWatch metrics
|
||||
|
||||
## Risk Assessment
|
||||
- **`List` performance** if a module accumulates >1k keys — DynamoDB Query handles this fine via paginated results. Confirm caller iterates pages (or all calls fit in one page).
|
||||
- **Item size limit (400 KB)** — modules generally store small JSON; document the cap and add a `len(val) > 380*1024 → error` guard.
|
||||
- **Eventual consistency** — DynamoDB defaults to eventually consistent reads. Use `ConsistentRead: true` in `Get` to match Firestore's strong default. Costs 2× the RCU but on-demand absorbs it.
|
||||
- **Reserved word `value`** — DynamoDB reserves many names; use `ExpressionAttributeNames` `#v = "value"` to avoid the conflict.
|
||||
- **AWS SDK v2 cold-start tax** (~80ms) — acceptable; cache client instance globally in `init()` or pkg-level var.
|
||||
|
||||
## Open questions
|
||||
1. TTL attribute for ephemeral keys (e.g. wordle daily state)? Add optional `ttl` Number attr; modules opt in via a new method or skip for v1.
|
||||
2. Use single PK or PK+SK? Sticking with single PK for KISS — no current module needs sort-key queries.
|
||||
3. Encryption — DynamoDB uses AWS-owned KMS by default (free); switch to AWS-managed only if compliance demands it.
|
||||
4. Backup strategy — point-in-time recovery is paid; for free-tier hobby use, accept "no backup" and document.
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "EventBridge cron wiring"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "3h"
|
||||
dependencies: [2]
|
||||
---
|
||||
|
||||
# Phase 04: EventBridge cron wiring
|
||||
|
||||
## Overview
|
||||
Replace the planned Cloud Scheduler design with EventBridge Scheduler. Preserve the existing `/cron/{name}` HTTP route shape inside the Lambda by invoking the Function URL via Scheduler's HTTPS target. Auth via `X-Cron-Token` header sourced from Parameter Store.
|
||||
|
||||
## Requirements
|
||||
- **Functional:** Two scheduled jobs fire on cron expressions matching the GCP plan (`0 17 * * *` for daily push, `0 1 * * *` for cleanup or whatever Phase 09 of GCP plan defined). Both routes execute against the live module dispatcher and complete within Lambda timeout.
|
||||
- **Non-functional:** Token rotates without code changes (Parameter Store update). Failure retried 2× with exponential backoff. Dead letters logged.
|
||||
|
||||
## Architecture
|
||||
**Decision:** HTTPS target (Function URL) over direct Lambda invoke. **Why:**
|
||||
- Preserves the existing `/cron/{name}` route + dispatcher code from Phase 03 of GCP plan
|
||||
- Local dev still works: `curl localhost:8080/cron/dailypush -H "X-Cron-Token: ..."`
|
||||
- Single ingress path for observability (one URL, one log group)
|
||||
- Direct invoke would require a separate Lambda entrypoint or routing on event shape — more code, less testable
|
||||
|
||||
**Trade-off accepted:** Slightly less AWS-idiomatic; HTTPS adds ~10ms latency vs direct invoke; not material here.
|
||||
|
||||
```
|
||||
EventBridge Scheduler ─cron─► HTTPS POST <function-url>/cron/{name}
|
||||
+ Header: X-Cron-Token: <from ParamStore>
|
||||
+ AWS Sigv4 NOT used (Function URL AuthType: NONE)
|
||||
│
|
||||
└─► Lambda → router → dispatcher → cron handler
|
||||
```
|
||||
|
||||
**Auth model:** Function URL `AuthType: NONE` (already set in Phase 02 for Telegram). Cron auth = shared-secret header verified server-side. The token lives in Parameter Store (`/miti99bot/prod/cron-token`) and is fetched by Scheduler at invoke time via `SECRETSMANAGER_SECRET` reference (Scheduler supports referencing Parameter Store via `secret reference` in target input transformer, OR plain text in target — for KISS, store the token in Scheduler's invocation HTTP target headers as a templated literal, but referenced from Parameter Store via SAM resource attribute).
|
||||
|
||||
**Simpler concrete approach:** SAM template reads the Parameter Store value at deploy time using `{{resolve:ssm-secure:...}}` in the schedule target's HTTP header config. Token rotation = update parameter, redeploy.
|
||||
|
||||
## Related Code Files
|
||||
- Create: SAM resources `Resources.DailyPushSchedule` (AWS::Scheduler::Schedule)
|
||||
- Create: SAM resources `Resources.CleanupSchedule` (or whichever second cron)
|
||||
- Create: SAM resource `Resources.SchedulerExecutionRole` with `events:InvokeApiDestination` / equivalent for HTTPS targets (or use built-in `aws.UniversalTarget` for `https`)
|
||||
- Modify: `internal/server/router.go` — confirm `/cron/{name}` validates `X-Cron-Token` against env-loaded value (currently has `cronAuthHeader = "X-Cron-Token"`, good)
|
||||
- Modify: `cmd/server/main.go` — load `CRON_TOKEN` env from Parameter Store reference, pass to `Config.CronToken`
|
||||
- Reference: existing module cron registrations in each module's `Cron()` method
|
||||
|
||||
## Implementation Steps
|
||||
1. Define SAM `AWS::Scheduler::Schedule` for each cron job:
|
||||
```yaml
|
||||
DailyPushSchedule:
|
||||
Type: AWS::Scheduler::Schedule
|
||||
Properties:
|
||||
ScheduleExpression: "cron(0 17 * * ? *)" # 17:00 UTC = 00:00 Saigon
|
||||
FlexibleTimeWindow: { Mode: 'OFF' }
|
||||
Target:
|
||||
Arn: arn:aws:scheduler:::http-invoke
|
||||
RoleArn: !GetAtt SchedulerRole.Arn
|
||||
Input: '{"name":"dailypush"}'
|
||||
HttpParameters:
|
||||
HeaderParameters: { X-Cron-Token: '{{resolve:ssm-secure:/miti99bot/prod/cron-token:1}}' }
|
||||
RetryPolicy: { MaximumRetryAttempts: 2, MaximumEventAgeInSeconds: 600 }
|
||||
DeadLetterConfig: { Arn: !GetAtt CronDLQ.Arn }
|
||||
FlexibleTimeWindow: { Mode: OFF }
|
||||
```
|
||||
*(Pseudo — confirm exact `aws.HttpInvoke` target syntax against current AWS SAM docs at deploy time; AWS docs note the API surface is evolving.)*
|
||||
2. Add `CronDLQ` (SQS queue, free tier 1M req/mo).
|
||||
3. Add `SchedulerRole` IAM with `lambda:InvokeFunctionUrl` (or `events:InvokeApiDestination` if going via API destination).
|
||||
4. Provision `/miti99bot/prod/cron-token` in Parameter Store with a 32-byte random value (`openssl rand -hex 32`).
|
||||
5. Verify router rejects requests with wrong/missing token (test exists; confirm).
|
||||
6. Deploy. From AWS console, "run now" each schedule. Confirm CloudWatch log entry shows successful 200 from Lambda.
|
||||
7. Wait one full schedule window (or change to `rate(2 minutes)` temporarily) to confirm automatic firing.
|
||||
8. Restore production cron expressions. Confirm next-fire timestamp.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] Both schedules deploy via SAM
|
||||
- [ ] Manual "run now" returns HTTP 200 from Function URL
|
||||
- [ ] Server logs show cron handler executing the right module
|
||||
- [ ] Wrong/missing token → 401, no module side effects
|
||||
- [ ] DLQ receives failed invocations on simulated Lambda error
|
||||
- [ ] Schedule fires automatically once on production cron expression
|
||||
|
||||
## Risk Assessment
|
||||
- **AWS Scheduler HTTPS target maturity** — relatively new feature; if SAM transform doesn't support `aws.HttpInvoke` cleanly, fall back to: Scheduler → SNS → Lambda subscription → existing handler (one extra hop, identical effect). Document fallback in this file.
|
||||
- **Token in template via `resolve`** — at deploy time the value is fetched and embedded into the schedule target's static config; rotation requires redeploy. If frequent rotation needed, switch to a Lambda authorizer pattern (out of scope for v1).
|
||||
- **Cron drift / TZ confusion** — EventBridge `cron()` uses UTC by default (matches Cloud Scheduler behavior in GCP plan). Use `?` for day-of-week-or-month constraint per AWS syntax.
|
||||
- **Cold-start during cron** — first invocation after idle = 1–3s; cron handler logic must complete within Lambda timeout (15s). If a cron handler calls Gemini and exceeds 15s, raise function timeout to 30s (still free).
|
||||
|
||||
## Open questions
|
||||
1. Direct Lambda invoke vs HTTPS target — locked to HTTPS for the reasons above; revisit only if HTTPS proves flaky.
|
||||
2. Single schedule with dynamic `name` vs one schedule per cron — one per cron is clearer in console; switch to dynamic only if cron count grows past ~5.
|
||||
3. Cleanup cron (`0 1 * * *`) — confirm what it does in original miti99bot. Likely TTL-style sweep; review and decide if DynamoDB TTL attribute can replace it (eliminates the cron entirely).
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
phase: 5
|
||||
title: "GitHub Actions deploy (OIDC + SAM)"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "3h"
|
||||
dependencies: [2, 3, 4]
|
||||
---
|
||||
|
||||
# Phase 05: GitHub Actions deploy (OIDC + SAM)
|
||||
|
||||
## Overview
|
||||
Push to `main` → CI builds the ARM64 Go binary, packages into ZIP, runs `sam deploy` against the existing stack via OIDC-assumed role. No long-lived AWS keys. Idempotent (zero-diff deploys are no-ops).
|
||||
|
||||
## Requirements
|
||||
- **Functional:** PR validates (`go vet`, `go test`, `sam validate`). Push to `main` deploys. Manual workflow_dispatch redeploy supported.
|
||||
- **Non-functional:** Deploy < 4 min. Concurrency: only one deploy at a time per ref. Stack name parameterized by env (default `prod`).
|
||||
|
||||
## Architecture
|
||||
```
|
||||
GitHub push to main
|
||||
└─► .github/workflows/deploy.yml
|
||||
1. checkout
|
||||
2. setup-go (1.25)
|
||||
3. setup-sam
|
||||
4. configure-aws-credentials (OIDC) ─► assume github-deploy-miti99bot
|
||||
5. make build-lambda
|
||||
6. sam build --use-container=false
|
||||
7. sam deploy --no-confirm-changeset --no-fail-on-empty-changeset
|
||||
8. post-deploy smoke (curl <function-url>/)
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
- Create: `.github/workflows/deploy.yml`
|
||||
- Create: `.github/workflows/ci.yml` — maybe split out validate-only path; or add `if:` guard in `deploy.yml`
|
||||
- Modify: existing `.github/workflows/ci.yml` — add `sam validate` step
|
||||
- Modify: `Makefile` — `deploy` target runs `sam build && sam deploy --no-confirm-changeset`
|
||||
|
||||
## Implementation Steps
|
||||
1. Confirm Phase 01's IAM role trust policy includes `repo:tiennm99/miti99bot-go:ref:refs/heads/main` and `repo:tiennm99/miti99bot-go:pull_request` (for PR validation flow if `sam validate` against AWS).
|
||||
2. Write `deploy.yml`:
|
||||
```yaml
|
||||
name: Deploy to AWS
|
||||
on:
|
||||
push: { branches: [main] }
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
concurrency: { group: deploy-prod, cancel-in-progress: false }
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.25' }
|
||||
- uses: aws-actions/setup-sam@v2
|
||||
with: { use-installer: true }
|
||||
- uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-deploy-miti99bot
|
||||
aws-region: ap-southeast-1
|
||||
- run: make build-lambda
|
||||
- run: sam build
|
||||
- run: sam deploy --no-confirm-changeset --no-fail-on-empty-changeset --stack-name miti99bot-aws-port
|
||||
- name: Smoke test
|
||||
run: |
|
||||
URL=$(aws cloudformation describe-stacks --stack-name miti99bot-aws-port --query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" --output text)
|
||||
curl -fsSL "$URL/" | jq .
|
||||
```
|
||||
3. Add `AWS_ACCOUNT_ID` to GitHub repo secrets (it's not a credential, but keeping the ARN out of the repo file is hygiene).
|
||||
4. PR validation workflow (`ci.yml`): runs `go vet`, `go test`, `sam validate` (no AWS creds needed for validate).
|
||||
5. Test the full path: open a PR with a trivial change → CI green; merge → deploy fires; smoke step prints health JSON.
|
||||
6. Add a `rollback.yml` workflow_dispatch path: re-run with a chosen commit SHA. CloudFormation handles the rollback inherently.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] PR triggers `ci.yml` only (no AWS deploy)
|
||||
- [ ] Merge to `main` triggers `deploy.yml`
|
||||
- [ ] Deploy succeeds without manual intervention
|
||||
- [ ] Post-deploy smoke step returns 200 from Function URL
|
||||
- [ ] Concurrency lock prevents overlapping deploys
|
||||
- [ ] Re-running a no-op deploy reports "no changes" and exits 0
|
||||
- [ ] No AWS access keys in repo, GitHub Actions secrets, or anywhere
|
||||
|
||||
## Risk Assessment
|
||||
- **OIDC trust misconfiguration** locks deploy out — Mitigation: keep bootstrap admin user (Phase 01) as glass-break recovery; rotate after 90 days.
|
||||
- **`sam build` with native deps fails** — pure Go has no native deps; should be fine.
|
||||
- **CloudFormation drift** between manual changes and CI — Mitigation: forbid console edits; daily `sam deploy --no-execute-changeset` to detect.
|
||||
- **Build cache cold each run** (~90s for Go deps) — Mitigation: `actions/setup-go` cache enabled by default.
|
||||
- **Workflow secret leak via debug logs** — Mitigation: never echo `secrets.*`, GH masks them automatically.
|
||||
|
||||
## Open questions
|
||||
1. Separate dev/staging stacks for PR previews? Out of scope for v1; `prod` only.
|
||||
2. Slack/Telegram notification on deploy success/failure? Defer; CloudWatch logs + `gh run list` suffice initially.
|
||||
3. Pin SAM version in `setup-sam`? Yes — pin to a specific version to avoid surprise breakage.
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
phase: 6
|
||||
title: "Observability + budget alert"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "2h"
|
||||
dependencies: [5]
|
||||
---
|
||||
|
||||
# Phase 06: Observability + budget alert
|
||||
|
||||
## Overview
|
||||
Wire CloudWatch Logs retention, metric filters for key counters, AWS Budgets $1/mo alert, and capture cold-start P95 baseline for the abort criterion in `plan.md`.
|
||||
|
||||
## Requirements
|
||||
- **Functional:** Log retention 7 days. Budget alert fires at $1.00 actual. Cold-start P95 measurable from logs.
|
||||
- **Non-functional:** All observability stays in free tier (5 GB log ingest/mo, 10 custom metrics free, 1k AWS Budgets API ops free).
|
||||
|
||||
## Architecture
|
||||
- **Logs:** Lambda's auto-created log group `/aws/lambda/miti99bot-aws-port-BotFunction-*`. SAM sets retention.
|
||||
- **Metrics:** Existing `internal/metrics` package emits counters. Lambda env can ship them via stdout — CloudWatch Logs ingests, log-based metric filters extract `request.duration`, `module.dispatched`, `cron.fired`. Free metric filter quota: unlimited filters, paid for resulting metrics past 10/mo.
|
||||
- **Budget:** `AWS::Budgets::Budget` in SAM template, threshold $1, email alert.
|
||||
- **Cold start:** parse `REPORT` log lines → `Init Duration` field → P50/P95/P99.
|
||||
|
||||
## Related Code Files
|
||||
- Modify: `template.yaml` — add `LogRetentionInDays: 7` on `BotFunction` (SAM `LoggingConfig`); add `AWS::Budgets::Budget` resource; add `AWS::Logs::MetricFilter` for key metrics
|
||||
- Create: `aws/dashboards/cold-start-coldwatch.json` (optional, manual import)
|
||||
- Reference: `internal/log/*.go` (slog JSON emitter — already exists)
|
||||
- Reference: `internal/metrics/*.go` (counters + 60s flush — already exists)
|
||||
|
||||
## Implementation Steps
|
||||
1. Add to `template.yaml` under `BotFunction.Properties`:
|
||||
```yaml
|
||||
LoggingConfig:
|
||||
LogFormat: JSON
|
||||
ApplicationLogLevel: INFO
|
||||
SystemLogLevel: WARN
|
||||
LogGroup: !Ref BotFunctionLogGroup
|
||||
```
|
||||
2. Add explicit log group to control retention:
|
||||
```yaml
|
||||
BotFunctionLogGroup:
|
||||
Type: AWS::Logs::LogGroup
|
||||
Properties:
|
||||
LogGroupName: /aws/lambda/miti99bot-aws-port-bot
|
||||
RetentionInDays: 7
|
||||
```
|
||||
3. Add metric filter for cold start:
|
||||
```yaml
|
||||
ColdStartFilter:
|
||||
Type: AWS::Logs::MetricFilter
|
||||
Properties:
|
||||
LogGroupName: !Ref BotFunctionLogGroup
|
||||
FilterPattern: '[report="REPORT", ..., init_label="Init", init_dur_label="Duration:", init_dur, ...]'
|
||||
MetricTransformations:
|
||||
- MetricName: ColdStartInitDuration
|
||||
MetricNamespace: miti99bot
|
||||
MetricValue: $init_dur
|
||||
```
|
||||
4. Add `AWS::Budgets::Budget` (sends email at 80% and 100% of $1):
|
||||
```yaml
|
||||
MonthlyBudget:
|
||||
Type: AWS::Budgets::Budget
|
||||
Properties:
|
||||
Budget:
|
||||
BudgetName: miti99bot-monthly
|
||||
BudgetLimit: { Amount: '1.00', Unit: 'USD' }
|
||||
TimeUnit: MONTHLY
|
||||
BudgetType: COST
|
||||
NotificationsWithSubscribers:
|
||||
- Notification: { ComparisonOperator: GREATER_THAN, NotificationType: ACTUAL, Threshold: 80, ThresholdType: PERCENTAGE }
|
||||
Subscribers: [{ Address: <email>, SubscriptionType: EMAIL }]
|
||||
```
|
||||
5. Deploy. Trigger cold start (`aws lambda update-function-configuration --function-name … --environment 'Variables={…,FORCE_RESTART=$(date +%s)}'`).
|
||||
6. Capture 50 cold starts manually or via a one-shot load script (`hey -n 50 -c 1 -i 30s <function-url>/`) — concurrency=1 with delay forces fresh inits. Compute P95 from CloudWatch Insights:
|
||||
```
|
||||
filter @type = "REPORT"
|
||||
| stats avg(@initDuration), pct(@initDuration, 95)
|
||||
```
|
||||
7. Record P95 in `plan.md`'s "Free-tier budget at peak" or as an addendum here.
|
||||
8. Confirm budget shows up in AWS Console > Budgets and has the email subscriber.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] Log group `RetentionInDays: 7` set
|
||||
- [ ] Cold-start P95 captured and < 1.5s (per abort criterion)
|
||||
- [ ] Budget alert visible in console, email subscriber confirmed (test mail received)
|
||||
- [ ] No log group accumulates >500 MiB after 7 days of normal traffic
|
||||
- [ ] CloudWatch Insights query for cold start works without manual setup
|
||||
|
||||
## Risk Assessment
|
||||
- **Email subscriber not confirmed** → first alert silently dropped — Mitigation: send a test from console before relying on it.
|
||||
- **Log retention deleted by SAM redeploy** if log group not explicit → Mitigation: declare log group explicitly (step 2).
|
||||
- **Cold start drift** as deps grow — Mitigation: re-measure quarterly; add a CI step that fails if `bootstrap` binary > 30 MiB.
|
||||
- **Budget delays** (AWS Budgets evaluates ~3× day, not real-time) → Mitigation: also enable Cost Anomaly Detection (free) for spike alerts.
|
||||
|
||||
## Open questions
|
||||
1. Email vs SNS topic for budget alerts? Email is simpler; SNS lets fan-out to webhook later. Start with email, migrate if needed.
|
||||
2. Custom CloudWatch dashboard? Skip for v1 — Insights queries are enough for solo-dev.
|
||||
3. Trace via AWS X-Ray? `Tracing: Active` already set in Phase 02 globals — free at this volume; review traces post-Phase 07.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
phase: 7
|
||||
title: "Cutover + README + retire GCP paths"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "3h"
|
||||
dependencies: [2, 3, 4, 5, 6]
|
||||
---
|
||||
|
||||
# Phase 07: Cutover + README + retire GCP paths
|
||||
|
||||
## Overview
|
||||
Flip the production Telegram webhook to the AWS Function URL, soak for 7 days, then mark the AWS port as default in README and code defaults. Keep GCP code paths in tree (Firestore impl, Cloud Run Dockerfile) but unwired by default.
|
||||
|
||||
## Requirements
|
||||
- **Functional:** Real production bot serves users from Lambda. No regressions vs prior baseline (whatever ran before — JS Worker or partial GCP).
|
||||
- **Non-functional:** Zero downtime cutover (Telegram webhook flip is atomic). 7-day soak with logs reviewed daily. Fallback path documented.
|
||||
|
||||
## Architecture
|
||||
- **Cutover op:** `setWebhook` Telegram API call pointing to the AWS Function URL with the production webhook secret.
|
||||
- **Rollback path:** identical `setWebhook` call to the prior URL; ~5 second op.
|
||||
- **Code:** Firestore impl stays compilable, gated by `KV_PROVIDER=firestore`. Default `KV_PROVIDER=dynamodb` in Lambda env. Cloud Run Dockerfile retained for offline / non-AWS users.
|
||||
|
||||
## Related Code Files
|
||||
- Modify: `README.md` — full rewrite of "Run locally", "Build", new "Deploy to AWS" section, status table updated, link to AWS plan, archive link to GCP plan
|
||||
- Modify: `cmd/server/main.go` — default `KV_PROVIDER` selection logic: `dynamodb` if `AWS_LAMBDA_FUNCTION_NAME` set, else `memory`
|
||||
- Create: `docs/deploy-aws.md` — single source of truth for AWS deploy ops (parameter store names, IAM role ARN, smoke commands)
|
||||
- Modify: `plans/260508-2222-go-port-cloud-run/plan.md` — top-of-file note: "Deploy phases 01, 09–12 superseded by `plans/260510-0114-aws-port/`. Module work (phases 03–07) reused unchanged."
|
||||
- Optional remove: `Dockerfile` retained for now; revisit in 30 days
|
||||
- Optional remove: GCP-specific docs in `docs/` if any (none observed)
|
||||
|
||||
## Implementation Steps
|
||||
1. Pre-flight checklist (run inside this phase):
|
||||
- [ ] Phase 02 smoke green (manual curl)
|
||||
- [ ] Phase 03 wordle daily state survives deploy + cold start
|
||||
- [ ] Phase 04 cron fired at least one real trigger
|
||||
- [ ] Phase 05 push-to-main auto-deploys
|
||||
- [ ] Phase 06 budget alert email confirmed
|
||||
- [ ] Cold-start P95 < 1.5s confirmed
|
||||
2. Run `setWebhook` against production bot:
|
||||
```sh
|
||||
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
|
||||
-d "url=$AWS_FUNCTION_URL/webhook" \
|
||||
-d "secret_token=$TELEGRAM_WEBHOOK_SECRET" \
|
||||
-d "drop_pending_updates=false" \
|
||||
-d "allowed_updates=[\"message\",\"callback_query\"]"
|
||||
```
|
||||
3. Verify with `getWebhookInfo`:
|
||||
```sh
|
||||
curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo" | jq .
|
||||
```
|
||||
Confirm `url`, `pending_update_count` near 0, `last_error_date` empty.
|
||||
4. Send a test command (`/start`, `/wordle`, `/twentyq` to exercise Gemini path). Confirm responses match prior behavior.
|
||||
5. Soak for 7 days: each morning, check CloudWatch Logs for ERROR / WARN, DynamoDB throttle metrics (should be zero), budget current spend (should be $0), Gemini RPD usage (should be far under cap).
|
||||
6. After 7-day soak, update README:
|
||||
- Status table: AWS port phases marked "done"
|
||||
- Replace "Run locally" with two paths: in-memory (no AWS) and DynamoDB Local (with AWS deps)
|
||||
- "Deploy" section: link `docs/deploy-aws.md`, drop Cloud Run instructions
|
||||
- Status badge / link to `plans/260510-0114-aws-port/`
|
||||
7. Add a top-of-file note in `plans/260508-2222-go-port-cloud-run/plan.md` redirecting deploy questions to the AWS plan.
|
||||
8. Tag a release: `git tag v1.0.0-aws -m "AWS deploy default"` and push.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] Telegram webhook `getWebhookInfo` shows AWS Function URL
|
||||
- [ ] Production bot answers `/start` from real users with normal latency
|
||||
- [ ] 7-day soak: zero unrecovered errors, zero throttles, zero unexpected spend
|
||||
- [ ] README accurately reflects AWS as the default deploy
|
||||
- [ ] `docs/deploy-aws.md` is sufficient for a fresh dev to redeploy from scratch
|
||||
- [ ] GCP plan file annotated; old phase files preserved for history
|
||||
- [ ] Release tag pushed
|
||||
|
||||
## Risk Assessment
|
||||
- **Webhook flip causes message loss** during the few-second propagation — Telegram retries failed deliveries automatically; `drop_pending_updates=false` preserves backlog.
|
||||
- **Latency regression vs JS Worker** — Cloud Run / JS Worker had different cold-start profiles; if users complain, document and consider ARM→x86 swap or provisioned concurrency (kills free tier).
|
||||
- **Hidden Firestore dependency** still wired in some module — Mitigation: grep for `firestore.NewClient` and confirm all paths are gated by `KV_PROVIDER=firestore` env. Add a CI test that builds with `KV_PROVIDER=dynamodb` and asserts Firestore client is not initialized.
|
||||
- **GCP project quietly billing** because resources weren't deleted — Mitigation: explicit step in this phase: `gcloud projects delete <project>` OR `gcloud run services delete` for any deployed services. Check Cloud Console for any orphaned resources.
|
||||
- **Lambda Web Adapter unsupported on a future runtime** — Mitigation: pin LWA layer version, monitor AWS Labs repo.
|
||||
|
||||
## Open questions
|
||||
1. Delete the old GCP project entirely or leave it dormant? Dormant is safe (no GCP free-tier abandonment penalty); delete after 30 days if no regret.
|
||||
2. Keep Dockerfile in repo? Yes — useful for non-Lambda local runs and as reference for any future Cloud Run revival.
|
||||
3. Keep Firestore impl forever or drop after 90 days? Drop only if the parity test proves redundant; the impl itself is small and tested.
|
||||
4. Announce the change anywhere (README badge, release notes) — depends on whether this is a public bot. User decides.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: "Migrate miti99bot-go from GCP to AWS (Lambda + DynamoDB + EventBridge, free tier)"
|
||||
description: "Re-target the deploy/runtime layer from Cloud Run + Firestore + Cloud Scheduler to Lambda (Go ZIP + LWA + Function URL) + DynamoDB on-demand + EventBridge Scheduler, region ap-southeast-1, IaC via SAM, CI via GH Actions OIDC. Module code unchanged."
|
||||
status: in-progress
|
||||
priority: P2
|
||||
effort: 3-4d
|
||||
branch: main
|
||||
tags: [aws, lambda, dynamodb, eventbridge, sam, port, telegram-bot, free-tier]
|
||||
created: 2026-05-10
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
supersedes-deploy-of: [260508-2222-go-port-cloud-run]
|
||||
---
|
||||
|
||||
# Plan: AWS port (Lambda + DynamoDB + EventBridge, free tier)
|
||||
|
||||
Re-target only the deploy/runtime layer. Module work (Phases 03–07 of GCP plan) is **done and reused unchanged**. The KVStore interface (`internal/storage/`) absorbs the swap; `http.Handler` code (`internal/server/`) is preserved via Lambda Web Adapter.
|
||||
|
||||
## Context
|
||||
- **Why switch:** Strict $0 free-tier goal — DynamoDB 25 GiB / 200M req-mo, EventBridge unlimited rules, 100 GB egress all-region beat Firestore 1 GiB, Cloud Scheduler 3-job cap, GCP NA-only egress. See `plans/reports/research-260510-0021-aws-vs-gcp-greenfield-rethink.md`.
|
||||
- **Reused as-is:** module framework, registry, dispatcher, Telegram lib, AI clients, all 11 modules, Firestore impl (kept as sibling for parity tests).
|
||||
- **Replaced:** Cloud Run → Lambda; Firestore → DynamoDB (sibling provider, default switchable via env); Cloud Scheduler → EventBridge Scheduler; Secret Manager → Parameter Store; Artifact Registry → none (ZIP); Cloud Logging → CloudWatch Logs; CF Worker / GCP CI → GH Actions + SAM.
|
||||
|
||||
## Locked decisions
|
||||
- **Compute:** Lambda Go on `provided.al2023`, **ARM64**, ZIP package, binary `bootstrap`, build with `-tags lambda.norpc -ldflags="-s -w"`.
|
||||
- **HTTP:** Lambda Function URL (`AuthType: NONE`) + AWS Lambda Web Adapter layer → existing `http.Handler` runs unchanged.
|
||||
- **KV:** DynamoDB single-table `miti99bot`, PK=`pk` (`{module}#{key}`), attr `value` (Binary). On-demand billing.
|
||||
- **Cron:** EventBridge Scheduler → HTTPS target = Function URL `/cron/{name}` with `X-Cron-Token` header (token in Parameter Store). Preserves existing route shape; alternative (direct Lambda invoke) deferred.
|
||||
- **Secrets:** SSM Parameter Store SecureString. Names: `/miti99bot/{env}/telegram-token`, `…/webhook-secret`, `…/gemini-api-key`, `…/cron-token`. Fetched at cold start.
|
||||
- **Region:** `ap-southeast-1` (Singapore).
|
||||
- **IaC:** AWS SAM (`template.yaml`).
|
||||
- **CI:** GitHub Actions, OIDC role, `aws-actions/configure-aws-credentials@v4` + `aws-actions/setup-sam@v2`.
|
||||
- **Logs:** CloudWatch Logs, 7-day retention.
|
||||
- **Cost guard:** AWS Budgets $1/mo alert.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Status | Effort | Key deliverable |
|
||||
|---|-------|--------|--------|-----------------|
|
||||
| 01 | [AWS bootstrap + IAM OIDC + SAM skeleton](phase-01-aws-bootstrap.md) | pending | 3h | AWS account, OIDC trust, empty SAM stack deployable |
|
||||
| 02 | [Lambda runtime (Go ZIP + LWA + Function URL)](phase-02-lambda-runtime.md) | pending | 4h | `/` and `/webhook` served from Lambda; secret-token check passes |
|
||||
| 03 | [DynamoDB KV provider](phase-03-dynamodb-kv.md) | pending | 4h | `dynamodb_kv.go` + `dynamodb_provider.go` sibling to Firestore impl, parity tests pass |
|
||||
| 04 | [EventBridge cron wiring](phase-04-eventbridge-cron.md) | pending | 3h | Scheduler → `/cron/{name}` with token, two crons firing on schedule |
|
||||
| 05 | [GitHub Actions deploy (OIDC + SAM)](phase-05-gha-deploy.md) | pending | 3h | `deploy.yml` runs on push to `main`, builds + sam deploys idempotently |
|
||||
| 06 | [Observability + budget alert](phase-06-observability.md) | pending | 2h | Logs retention set, $1 budget alert, cold-start P95 captured |
|
||||
| 07 | [Cutover + README + retire GCP paths](phase-07-cutover.md) | pending | 3h | Webhook flipped to Function URL, README rewritten, GCP code paths kept but unwired by default |
|
||||
|
||||
## Dependency graph
|
||||
```
|
||||
01 ──► 02 ──► 03 ──► 04 ──► 05 ──► 06 ──► 07
|
||||
└──► 04 ─────►┘
|
||||
```
|
||||
|
||||
## Free-tier budget at peak
|
||||
| Resource | Cap | Expected | Headroom |
|
||||
|---|---|---|---|
|
||||
| Lambda req | 1M/mo | ~30k/mo | 97% |
|
||||
| Lambda compute | 400k GB-s | <5k | 99% |
|
||||
| DynamoDB req | 200M/mo | <100k | 99.9% |
|
||||
| DynamoDB storage | 25 GiB | <50 MiB | 99.8% |
|
||||
| EventBridge invocations | 14M/mo | ~60 (2 crons × ~30 days) | 99.9% |
|
||||
| Parameter Store accesses | unlimited (Standard) | <100/cold-start × ~30 starts | n/a |
|
||||
| Egress | 100 GB/mo | <50 MiB | 99.95% |
|
||||
| CloudWatch Logs ingest | 5 GB/mo | <500 MiB | 90% |
|
||||
|
||||
## Abort criteria
|
||||
- **Cold-start P95 > 1.5s** sustained: investigate ARM64→x86_64 swap or pre-warm with provisioned concurrency (kills free tier; only if user-facing latency unacceptable).
|
||||
- **DynamoDB throttle** under normal load: switch to provisioned mode (still free under 25 RCU/WCU).
|
||||
- **Function URL auth-bypass risk** discovered: switch to API Gateway HTTP API (12-month free, then $1/M).
|
||||
|
||||
## Rollback
|
||||
Until Phase 07 webhook flip, the GCP runtime path remains intact. Per-phase rollback documented in each phase. Phase 07 is the only irreversible cutover step.
|
||||
|
||||
## Open questions
|
||||
1. Direct Lambda invoke for cron vs HTTP loopback via Function URL — final call deferred to Phase 04 implementation.
|
||||
2. Whether to delete Firestore impl after parity confirmed, or keep as offline test backend permanently.
|
||||
3. Single SAM stack vs split (data + compute) — start single, split if iteration speed suffers.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Research Report: AWS vs GCP Free Tier — Suitability for miti99bot-go
|
||||
|
||||
> **Generated:** 2026-05-10 00:12 (Asia/Saigon)
|
||||
> **Mode:** /research + /ck:brainstorm (ultrathink)
|
||||
> **Verdict (TL;DR):** **GCP wins, decisively, not even close.** Stop debating. The code is already written for it, free tier is plenty, switching cost > zero benefit.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This project (miti99bot-go) is a Go Telegram bot already coded against **Cloud Run + Firestore + Gemini**, with multi-stage Docker → distroless image (~15 MiB), Phase 04 Firestore KV done, Phases 05–07 modules + AI done, only deploy/cron/CI left. Asking "should we switch to AWS for free tier?" is asking "should we throw away weeks of work for a free-tier delta that doesn't matter at this traffic profile?"
|
||||
|
||||
The honest answer is no. Both clouds offer permanent (not 12-month) free tiers that cover a personal Telegram bot 100x over. The deciding factor is **integration friction and switching cost**, not headroom. GCP's Cloud Run runs your existing `http.Handler` unmodified; AWS Lambda needs an adapter or rewrite. Firestore is already wired; DynamoDB would force a new KV provider. Cloud Scheduler is two lines of YAML; EventBridge Scheduler needs IAM gymnastics. And Gemini API is **independent of cloud** — same key works on AWS, so AWS gains nothing on the AI side.
|
||||
|
||||
The only scenario where AWS wins: if you needed >1 GiB egress/month (AWS gives 100 GB always-free) — irrelevant here, Telegram messages are tiny text.
|
||||
|
||||
---
|
||||
|
||||
## Research Methodology
|
||||
|
||||
- **Sources:** 5 web searches (Cloud Run/Firestore quotas, AWS Lambda/DynamoDB quotas, Telegram-bot deployment patterns, Gemini API free tier, Lambda-vs-Cloud-Run cold starts/egress)
|
||||
- **Date range:** Sources from 2025-2026 with explicit 2026 quota figures
|
||||
- **Project context:** Read `README.md` and existing plan structure (`plans/260508-2222-go-port-cloud-run/`)
|
||||
- **Search keywords:** GCP Always Free 2026, AWS Always Free 2026, Cloud Run egress, Lambda Go cold start, Gemini API free tier 2026
|
||||
|
||||
---
|
||||
|
||||
## Project Profile (the part that decides everything)
|
||||
|
||||
| Aspect | Reality |
|
||||
|---|---|
|
||||
| Workload | Telegram bot webhook receiver. Few users. Bursty, low-volume. |
|
||||
| Already coded for | **Cloud Run** (HTTP server in `cmd/server/`, `/webhook` route) |
|
||||
| Storage | **Firestore** KVStore impl done (Phase 04) |
|
||||
| AI | **Gemini API** (cloud-agnostic — uses Google AI Studio key, not GCP-bound) |
|
||||
| Cron | Planned Cloud Scheduler → `/cron/{name}` (Phase 08+) |
|
||||
| Container | Multi-stage `golang:1.23-alpine` → `distroless/static:nonroot`, ~15 MiB |
|
||||
| Phases done | 02–07 (modules, framework, AI). Phase 01 (deploy) and 08+ pending. |
|
||||
|
||||
**Observation:** The hard work is done. Deploy is a config exercise, not a redesign. Switching clouds = redo Phases 02 + 04 + 08, partially.
|
||||
|
||||
---
|
||||
|
||||
## Key Findings
|
||||
|
||||
### 1. GCP Always Free Tier (relevant slice, 2026)
|
||||
|
||||
| Service | Always-Free Quota | Bot Usage Estimate | Headroom |
|
||||
|---|---|---|---|
|
||||
| **Cloud Run** | 2M req/mo, 360k GiB-s mem, 180k vCPU-s, 1 GiB egress/mo (NA) | ~10k req/mo (low-volume bot) | ~200x |
|
||||
| **Firestore (Native)** | 1 GiB storage, 50k reads/day, 20k writes/day, 20k deletes/day, 10 GiB egress/mo | <1k ops/day per user | ~50x |
|
||||
| **Cloud Scheduler** | 3 jobs free | Bot needs ~1–3 cron jobs | exact fit |
|
||||
| **Artifact Registry** | 0.5 GB free | distroless image ~15 MiB | ~30x |
|
||||
| **Secret Manager** | 6 active versions, 10k accesses/mo | 1 secret (bot token) | trivial |
|
||||
| **Cloud Build** | 120 build-min/day | A few CI builds/day | fine |
|
||||
|
||||
**2026 caveat:** Starting Feb 3, 2026, projects need Blaze (pay-as-you-go) plan to keep default Cloud Storage buckets — but Always-Free quotas still apply, and **this project doesn't use Cloud Storage** (uses Firestore + Artifact Registry). So: enable Blaze, set a $0 budget alert, free tier still works.
|
||||
|
||||
### 2. AWS Always Free Tier (relevant slice, 2026)
|
||||
|
||||
| Service | Always-Free Quota | Notes |
|
||||
|---|---|---|
|
||||
| **Lambda** | 1M req/mo + 400k GB-s | Permanent. Plenty for a bot. |
|
||||
| **DynamoDB** | 25 GB storage, 200M req/mo (on-demand) | Way more than Firestore's free tier. |
|
||||
| **EventBridge Scheduler** | 14M scheduled events/mo (default bus) | Plenty. |
|
||||
| **Lambda Function URL** | Free (no API Gateway needed) | Avoids the 12-month-only API Gateway free tier. |
|
||||
| **Parameter Store (Standard)** | Free unlimited | Use for bot token. Skip Secrets Manager (paid after 30 days). |
|
||||
| **Egress** | 100 GB/mo always-free (since Dec 2024) | 100x Cloud Run's 1 GiB. Irrelevant for tiny Telegram messages. |
|
||||
| **ECR private** | 500 MB **12-month only** ❌ | Use Docker Hub or GHCR free instead. Or pay ~$0.05/GB-mo. |
|
||||
|
||||
**Hidden trap:** Many AWS tutorials use API Gateway (1M HTTP API calls = 12-month tier, **not always-free**). To stay free permanently on AWS, you must use **Lambda Function URLs** instead.
|
||||
|
||||
### 3. Gemini API (decisive: cloud-agnostic)
|
||||
|
||||
- Free tier from **Google AI Studio**, not GCP — same API key works from AWS, GCP, your laptop, anywhere.
|
||||
- Limits (May 2026): 5–15 RPM, 100–1000 RPD per model, 250k TPM universal cap.
|
||||
- Models on free tier: 2.5 Pro / 2.5 Flash / 2.5 Flash-Lite. **Gemini 3.x is paid-only in preview.**
|
||||
- **April 2026 change:** Pro models tightening; Flash/Flash-Lite still free.
|
||||
- **AWS doesn't disadvantage Gemini.** Choosing AWS doesn't lose Gemini access. Choosing GCP doesn't grant extra Gemini quota.
|
||||
|
||||
### 4. Cold Start & Latency
|
||||
|
||||
| Platform | Go cold start | Warm latency |
|
||||
|---|---|---|
|
||||
| Lambda Go ZIP | 50–200 ms | <5 ms |
|
||||
| Lambda container (Go) | 0.6–1.4 s | <5 ms |
|
||||
| Cloud Run (distroless Go, min-instances=0) | 1–3 s | <10 ms |
|
||||
| Cloud Run (min-instances=1) | none | <10 ms — **costs money** (no longer free) |
|
||||
|
||||
For Telegram webhooks: Telegram retries on timeout, and a 1–3 s cold start is acceptable for human-perceived bot latency. **Not a real differentiator at this scale.**
|
||||
|
||||
### 5. Egress
|
||||
|
||||
- Cloud Run free egress: 1 GiB/mo NA (then $0.12/GB)
|
||||
- AWS free egress: 100 GB/mo
|
||||
- Bot egress = small JSON replies to api.telegram.org. ~500 bytes × 10k msgs/mo ≈ 5 MB. **Both wildly under quota.** Non-issue.
|
||||
|
||||
---
|
||||
|
||||
## Comparative Analysis
|
||||
|
||||
### Free-tier headroom at this workload: **TIE** (both 100x more than needed)
|
||||
|
||||
### Switching cost (the actual decision driver):
|
||||
|
||||
| Item | Stay GCP | Switch to AWS |
|
||||
|---|---|---|
|
||||
| HTTP handler in `internal/server/` | works as-is | rewrite to Lambda handler OR add lambda-web-adapter |
|
||||
| `internal/storage/` Firestore impl | already done | rewrite as DynamoDB provider, retest, redeploy |
|
||||
| Cron `/cron/{name}` routes | wire Cloud Scheduler (3 lines YAML) | wire EventBridge Scheduler + IAM role + invocation target |
|
||||
| Container registry | Artifact Registry (gcloud auth) | ECR (12-mo free only) or GHCR (free, simpler) |
|
||||
| Secrets | Secret Manager | Parameter Store (free) — easy swap |
|
||||
| Logs | Cloud Logging (built-in) | CloudWatch Logs (built-in) |
|
||||
| Phase 01 plan | already drafted | needs full rewrite |
|
||||
| Effort | days | weeks |
|
||||
|
||||
### Brainstorm: Steel-manning the AWS case
|
||||
|
||||
**"AWS Lambda free tier never expires, more permanent than GCP's"** → False premise. Both have permanent always-free tiers. GCP's Always-Free is also permanent. The 12-month thing applies to AWS extras (EC2, RDS, ECR), not Lambda/DynamoDB.
|
||||
|
||||
**"AWS egress is 100 GB free, Cloud Run only 1 GiB"** → True, but irrelevant: bot egress is <10 MB/mo.
|
||||
|
||||
**"Lambda has faster cold starts"** → True for ZIP packages, but you'd need to abandon the Docker workflow. Container Lambda is similar to Cloud Run.
|
||||
|
||||
**"AWS has more free services to grow into"** → Speculative. YAGNI. Decide based on this project's needs.
|
||||
|
||||
**"Vendor independence — AWS skill is more marketable"** → Career argument, not technical. Outside scope.
|
||||
|
||||
### Brainstorm: Steel-manning the GCP case
|
||||
|
||||
**"Code is already written for it"** → Decisive. Anything else is rationalization for redoing finished work.
|
||||
|
||||
**"Cloud Run runs unmodified `http.Handler`"** → Real ergonomic win. Lambda forces an event-shape adapter even with Function URLs.
|
||||
|
||||
**"Firestore SDK is already integrated, tests pass"** → Phase 04 is done. Don't rewrite Phase 04.
|
||||
|
||||
**"Gemini + Firestore + Cloud Run all share one project, one service account, one IAM model"** → Operational simplicity. Worth real money in time saved.
|
||||
|
||||
**"Cloud Scheduler → HTTP target is the simplest cron-to-webhook in the industry"** → Phase 08 will be 30 min on GCP, half a day on AWS.
|
||||
|
||||
### Steel-manning the actual neutral choice
|
||||
|
||||
There is none. This is a lopsided decision. Anyone telling you AWS is "comparable for this project" is selling you sunk-cost-aversion in reverse — you have NO sunk cost in AWS to recover.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Recommendations
|
||||
|
||||
### Stay on GCP. Proceed with Phase 01 as planned. Specifically:
|
||||
|
||||
1. **Enable Blaze plan** on the GCP project. Set a **$1 budget alert** (covers free-tier spillover).
|
||||
2. **Cloud Run** service: `--cpu 1 --memory 256Mi --min-instances 0 --max-instances 3 --concurrency 80`. Min-instances=0 keeps it free; max-instances cap prevents runaway billing.
|
||||
3. **Artifact Registry** repo for the distroless image. CI pushes via Workload Identity Federation (no JSON keys).
|
||||
4. **Firestore** in Native mode, `(default)` database. Already coded.
|
||||
5. **Cloud Scheduler** jobs → HTTP POST to `/cron/{name}` with OIDC token; verify on the receiver side.
|
||||
6. **Secret Manager**: store `TELEGRAM_BOT_TOKEN` and `TELEGRAM_WEBHOOK_SECRET`; mount as env vars on Cloud Run.
|
||||
7. **Gemini API key**: from Google AI Studio (not GCP). Store in Secret Manager. Free tier is independent.
|
||||
8. **Cost guardrails**: budget alert + `max-instances` + Firestore composite-index review (a runaway query loop is the realistic free-tier killer).
|
||||
|
||||
### Common pitfalls to avoid
|
||||
|
||||
- ❌ Don't set `min-instances >= 1` — instantly leaves free tier.
|
||||
- ❌ Don't put image in Cloud Storage (Feb 2026 Blaze trigger) — use Artifact Registry.
|
||||
- ❌ Don't enable expensive Firestore indexes you don't query — they bill writes.
|
||||
- ❌ Don't log PII at INFO — Cloud Logging has its own free quota (50 GiB/mo) but verbose logs at scale will blow it.
|
||||
- ❌ Don't trust the webhook origin without `X-Telegram-Bot-Api-Secret-Token` verification.
|
||||
|
||||
### When to reconsider AWS
|
||||
|
||||
Switch to AWS Lambda **only if** one of:
|
||||
- You suddenly need >1 GiB Cloud Run egress/month (very unlikely for a bot).
|
||||
- You unify with an existing AWS-only org/account.
|
||||
- GCP changes Always-Free terms hostilely (no signal of this).
|
||||
|
||||
None apply today.
|
||||
|
||||
---
|
||||
|
||||
## Resources & References
|
||||
|
||||
### GCP
|
||||
- [Google Cloud Free Tier](https://cloud.google.com/free)
|
||||
- [Cloud Run Free Tier (2025 infographic)](https://www.freetiers.com/directory/google-cloud-run)
|
||||
- [Firestore Quotas & Limits](https://docs.cloud.google.com/firestore/quotas)
|
||||
- [Cloud Scheduler docs](https://docs.cloud.google.com/scheduler/docs)
|
||||
- [Cloud Run pricing 2025](https://cloudchipr.com/blog/cloud-run-pricing)
|
||||
|
||||
### AWS
|
||||
- [AWS Free Tier 2026 limits & hidden costs](https://www.cloudoptimo.com/blog/aws-free-tier-isnt-unlimited-know-the-limits-before-you-get-billed/)
|
||||
- [Lambda pricing & cost guide 2026](https://go-cloud.io/aws-lambda-pricing/)
|
||||
- [AWS Free Tier comprehensive guide](https://cloudwebschool.com/docs/aws/fundamentals/aws-free-tier/)
|
||||
|
||||
### Gemini API
|
||||
- [Gemini API rate limits (official)](https://ai.google.dev/gemini-api/docs/rate-limits)
|
||||
- [Gemini API pricing (official)](https://ai.google.dev/gemini-api/docs/pricing)
|
||||
- [Gemini API free tier 2026 guide](https://yingtu.ai/en/blog/gemini-api-free-tier)
|
||||
- [Gemini Pro paid changes April 2026](https://help.apiyi.com/en/google-gemini-api-free-tier-changes-april-2026-guide-en.html)
|
||||
|
||||
### Telegram bot deployment patterns
|
||||
- [Comparing Telegram bot hosting providers (Code Capsules)](https://www.codecapsules.io/blog/comparing-telegram-bot-hosting-providers)
|
||||
- [Deploy AI Telegram bot on AWS for free (Go)](http://golangforall.com/en/post/telegram-bots-zero-cost-aws.html)
|
||||
- [Cloud Run vs Lambda performance/pricing (Sedai)](https://sedai.io/blog/aws-lambda-google-cloud-functions)
|
||||
- [Serverless container pricing comparison 2026](https://danubedata.ro/blog/serverless-container-pricing-comparison-2026)
|
||||
|
||||
---
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Criterion | Weight | GCP | AWS | Winner |
|
||||
|---|---|---|---|---|
|
||||
| Code already written for it | High | ✅ | ❌ | **GCP** |
|
||||
| Free tier covers workload | High | ✅ (100x) | ✅ (100x) | tie |
|
||||
| Container deploy ergonomics | Med | ✅ Cloud Run native | ⚠ Lambda container OR adapter | **GCP** |
|
||||
| Cron→webhook simplicity | Med | ✅ Cloud Scheduler | ⚠ EventBridge + IAM | **GCP** |
|
||||
| KV store integration | Med | ✅ Firestore done | ❌ rewrite to DynamoDB | **GCP** |
|
||||
| Egress headroom | Low | 1 GiB | 100 GB | AWS (irrelevant) |
|
||||
| Cold start | Low | 1–3 s | 0.6–1.4 s | AWS (acceptable on both) |
|
||||
| AI integration (Gemini) | Med | tie (cloud-agnostic) | tie | tie |
|
||||
| Switching cost | High | $0 | weeks of work | **GCP** |
|
||||
|
||||
**Score:** GCP wins 5 categories outright, ties 2, loses 2 (low-impact). **Stay on GCP.**
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ **Decision: stay on GCP.** Close this question.
|
||||
2. Resume **Phase 01** (`plans/260508-2222-go-port-cloud-run/`): provision Cloud Run service, Artifact Registry, Firestore, Secret Manager, set webhook secret.
|
||||
3. Wire **Phase 08** cron via Cloud Scheduler → `/cron/{name}` with OIDC verification.
|
||||
4. Add **billing budget alert** at $1 before first deploy.
|
||||
5. Update README "Status" table when Phase 01 lands.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
1. **Region choice.** asia-southeast1 (Singapore, lowest LatAm-to-VN latency) vs us-central1 (cheapest, default 1 GiB egress fully applies)? Bot users are in Vietnam — likely asia-southeast1 wins on user latency, but verify free-tier egress geography (the 1 GiB free egress is **NA-only**; egress from asia-southeast1 to api.telegram.org may bill at $0.12/GB even at low volume — calculate at expected msg rate).
|
||||
2. **Cold-start tolerance for cron.** If a cron job calls Gemini and takes >10 s on cold start, does it risk Cloud Scheduler retry storms? Decide on min-instances trade-off vs free tier.
|
||||
3. **Gemini quota saturation.** Free tier 100–1000 RPD per model — if semantle/doantu/twentyq see real users, will we hit RPD before paid tier kicks in? Worth designing a fallback (cache common queries, degrade gracefully).
|
||||
4. **Workload Identity Federation vs JSON key** for CI image push — recommended is WIF, but it requires GitHub OIDC config. Defer or do it now?
|
||||
5. **GCP Blaze enablement timing** — enable before Phase 01 deploy or after? Free-tier still works on Blaze, but new project needs to confirm Always-Free quotas auto-apply.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Research Addendum: AWS vs GCP — Greenfield Rethink (Sunk Cost Ignored)
|
||||
|
||||
> **Generated:** 2026-05-10 00:21 (Asia/Saigon)
|
||||
> **Mode:** /research follow-up + ultrathink
|
||||
> **Companion to:** `research-260510-0012-aws-vs-gcp-free-tier.md`
|
||||
> **Trigger:** User requested re-evaluation treating project as greenfield (existing code can be rewritten any time).
|
||||
> **Revised verdict:** **AWS edges out GCP, ~70/30**, on pure free-tier and trajectory merits. Not a slam-dunk; simplicity is GCP's only remaining moat.
|
||||
|
||||
---
|
||||
|
||||
## Why this addendum
|
||||
|
||||
Prior report leaned heavily on "code is already written for GCP" as the decisive factor. User explicitly asked to remove that lens. Greenfield-only analysis flips the conclusion: **AWS has objectively larger free tier** on the resources this project's trajectory will consume first (cron count, KV storage, egress in Asia). GCP's remaining advantage is operational simplicity for a single developer.
|
||||
|
||||
---
|
||||
|
||||
## What changes when sunk cost is removed
|
||||
|
||||
The previous report's "GCP wins" verdict rested 60% on switching cost. Strip that:
|
||||
|
||||
| Argument | Holds greenfield? | Why |
|
||||
|---|---|---|
|
||||
| "Cloud Run runs unmodified `http.Handler`" | ❌ no | **Lambda Web Adapter (LWA)** ships a normal Go HTTP server on Lambda. Same code, both clouds. |
|
||||
| "Firestore is already wired" | ❌ no | Greenfield: nothing wired. Pick the better KV. DynamoDB free tier is 25× bigger. |
|
||||
| "Cloud Scheduler is 3 lines YAML" | ⚠ partial | True, but capped at 3 jobs free. EventBridge is also simple via Terraform/SAM and uncapped on rule count. |
|
||||
| "Single IAM model" | ✅ yes | GCP IAM is gentler than AWS combinatorics. Real ergonomic win. |
|
||||
| "Free tier covers workload 100×" | ✅ yes | Both clouds. Tie. |
|
||||
| "Gemini is cloud-agnostic" | ✅ yes | Tie. |
|
||||
|
||||
Three of the four GCP advantages **vaporize** without sunk cost. The remaining wins are operational simplicity (real) and Cloud Logging UX (minor).
|
||||
|
||||
---
|
||||
|
||||
## Greenfield free-tier head-to-head (this project's resource axes)
|
||||
|
||||
### Compute
|
||||
|
||||
| | GCP Cloud Run | AWS Lambda (ZIP, Function URL) |
|
||||
|---|---|---|
|
||||
| Free requests/mo | 2M | 1M |
|
||||
| Free compute | 360k GiB-s + 180k vCPU-s | 400k GB-s |
|
||||
| Adapter needed for Go HTTP | none | LWA (extension layer, ~100 ms cold-start tax) |
|
||||
| Cold start (Go, distroless / provided.al2023) | 1–3 s | **0.05–0.2 s ZIP** |
|
||||
| Concurrency model | up to 80–1000 req/instance | 1 req/instance |
|
||||
| Container registry | Artifact Registry 0.5 GB always-free | ECR 500 MB **12-mo only** |
|
||||
| Bills idle? | no (request-based when min-instances=0) | no |
|
||||
|
||||
**Tilt:** Lambda ZIP wins on cold start, no registry needed. Cloud Run wins on more free requests and concurrency multiplexing (lower compute usage per req). For a webhook bot at low QPS, Lambda's per-invocation isolation is fine. **Slight Lambda lean** because ZIP avoids the registry trap.
|
||||
|
||||
### KV / document storage
|
||||
|
||||
| | Firestore (Native) | DynamoDB (on-demand) |
|
||||
|---|---|---|
|
||||
| Free storage | 1 GiB | **25 GiB** |
|
||||
| Free read ops | 50k/**day** (≈1.5M/mo) | (part of 200M req/mo monthly pool) |
|
||||
| Free write ops | 20k/**day** (≈600k/mo) | (part of 200M req/mo monthly pool) |
|
||||
| Bursting risk | high (daily caps reset) | low (monthly pool absorbs spikes) |
|
||||
| Query model | document + composite indexes | item + GSI/LSI |
|
||||
| Strong consistency | yes | yes (with consistent-read flag) |
|
||||
|
||||
**Tilt:** **DynamoDB wins decisively.** 25× storage, monthly-pooled requests instead of daily caps (better for bursty bot traffic), and 200M total req/mo dwarfs Firestore's combined ~2M ops/mo. For per-user state in 8+ modules + planned trading data, DynamoDB has years of headroom; Firestore could pinch within months if usage grows.
|
||||
|
||||
### Cron / scheduler
|
||||
|
||||
| | Cloud Scheduler | EventBridge Scheduler |
|
||||
|---|---|---|
|
||||
| Free job/rule count | **3 hard cap** | unlimited |
|
||||
| Free invocations/mo | (per the 3 jobs) | 14M on default bus |
|
||||
| Cost past free | $0.10/job/mo | $0/job, $1.25/M extra invocations |
|
||||
|
||||
**This project's likely cron count:** wordle daily, loldle daily, lolschedule daily, semantle daily, weekly digests, possible trading hourly refresh. **>3 almost certainly.**
|
||||
|
||||
**Tilt:** **AWS wins.** GCP becomes the project's first paywall trigger. Cost is trivial ($0.70/mo for 10 jobs) but breaks the "strict $0" guarantee. AWS keeps the bot literally free.
|
||||
|
||||
### Egress
|
||||
|
||||
| | Cloud Run | Lambda |
|
||||
|---|---|---|
|
||||
| Free egress | 1 GiB/mo **NA-only** | 100 GB/mo **all regions** |
|
||||
| Bot deploy region (VN users) | asia-southeast1 → **no free egress** | ap-southeast-1 → 100 GB free |
|
||||
| Bot egress estimate | <50 MB/mo | <50 MB/mo |
|
||||
| Real cost impact | ~$0 (pennies) | $0 |
|
||||
|
||||
**Tilt:** **AWS wins on principle, draws on practice.** GCP's free egress is geographically restrictive in a way that surprises Asian deployers. Negligible at this volume but asymmetric.
|
||||
|
||||
### Secrets
|
||||
|
||||
| | GCP Secret Manager | AWS Parameter Store (Standard) |
|
||||
|---|---|---|
|
||||
| Free | 6 active versions, 10k accesses/mo | unlimited |
|
||||
| Cost past free | $0.06/version/mo, $0.03/10k access | $0 |
|
||||
| API ergonomics | clean | clean |
|
||||
|
||||
**Tilt:** **AWS wins.** Parameter Store Standard tier is unlimited free. (Skip Secrets Manager — paid only.)
|
||||
|
||||
### Logs
|
||||
|
||||
| | Cloud Logging | CloudWatch Logs |
|
||||
|---|---|---|
|
||||
| Free | 50 GiB/mo ingest | 5 GB/mo ingest (always-free as of 2024+) |
|
||||
| UX | better console | older console |
|
||||
|
||||
**Tilt:** **GCP wins on volume + UX.** 50 GiB > 5 GB, console search is faster. Real but minor advantage.
|
||||
|
||||
### Container registry
|
||||
|
||||
| | Artifact Registry | ECR |
|
||||
|---|---|---|
|
||||
| Free | 0.5 GB **always** | 500 MB **12 mo only** |
|
||||
| Workaround on AWS | use Lambda ZIP, no registry | — |
|
||||
|
||||
**Tilt:** **GCP wins on container path. Lambda ZIP makes this moot for AWS.**
|
||||
|
||||
---
|
||||
|
||||
## Operational complexity (real cost, hard to put $ on)
|
||||
|
||||
| Activity | GCP | AWS |
|
||||
|---|---|---|
|
||||
| First deploy | `gcloud run deploy --source .` (one command, builds + pushes + runs) | `sam build && sam deploy` OR Terraform OR manual zip + IAM role + Function URL + LWA layer config |
|
||||
| Add cron | Cloud Scheduler job → HTTP POST with OIDC | EventBridge Scheduler → Lambda invoke role + Lambda permission |
|
||||
| IAM for cron-to-app auth | OIDC token verify on receiver | IAM principal-on-invoke |
|
||||
| Iteration loop | edit → `gcloud run deploy --source` → 30 s | edit → `sam deploy` → 30 s OR build+zip+update-function-code |
|
||||
| Multi-env (dev/prod) | two Cloud Run services + two Firestore DBs | two Lambdas + two DynamoDB tables + two roles |
|
||||
|
||||
**Tilt:** **GCP wins on greenfield ergonomics.** This is real engineer-hours. For a solo dev, the difference between "one command" and "five tools to wire" matters.
|
||||
|
||||
---
|
||||
|
||||
## Decision matrix (greenfield, weighted for this project)
|
||||
|
||||
| Criterion | Weight | GCP | AWS | Winner |
|
||||
|---|---|---|---|---|
|
||||
| Cron job free count headroom | High | 3 cap (paywall) | unlimited | **AWS** |
|
||||
| KV storage headroom | High | 1 GiB / daily caps | 25 GiB / monthly pool | **AWS** |
|
||||
| Operational simplicity | High | one command | multi-tool | **GCP** |
|
||||
| Cold start | Med | 1–3 s | 50–200 ms | **AWS** |
|
||||
| Egress in Asia | Med | NA-only free | 100 GB any region | **AWS** |
|
||||
| Logs free tier | Low | 50 GiB | 5 GB | **GCP** |
|
||||
| Container registry | Low | 0.5 GB always | 500 MB 12-mo | **GCP** (or moot via ZIP) |
|
||||
| Secrets | Low | 10k accesses/mo | unlimited | **AWS** |
|
||||
| Compute requests | Low | 2M | 1M+400k GB-s | tie at this scale |
|
||||
| AI (Gemini) | Low | cloud-agnostic | cloud-agnostic | tie |
|
||||
|
||||
**Score:** AWS 5 wins (3 high-weight). GCP 3 wins (1 high-weight). 2 ties.
|
||||
**Greenfield verdict: AWS, by ~70/30 margin.**
|
||||
|
||||
---
|
||||
|
||||
## When does GCP still win greenfield?
|
||||
|
||||
- **You commit to ≤3 cron jobs forever.** Wordle daily push only. Done.
|
||||
- **You expect <100 MB total state.** Firestore 1 GiB is plenty.
|
||||
- **You value 1-command deploy more than free-tier headroom.** Solo dev, hobby project, no growth ambition.
|
||||
- **You want better log UX out of the box.**
|
||||
- **You'd hit operational complexity faster than free-tier ceilings.** Realistic for small bots.
|
||||
|
||||
**This project's signal:** 8 modules done, trading planned, multiple daily-push crons implied. **It's not staying small.** AWS headroom matches the trajectory.
|
||||
|
||||
---
|
||||
|
||||
## Honest counter-argument (steel-manning GCP greenfield)
|
||||
|
||||
- "$0.70/mo past 3 cron jobs is not a real cost." → True. If "almost free" is acceptable, GCP simplicity wins.
|
||||
- "Solo devs ship more on simpler clouds." → True. Maintenance hours dwarf $-cost differences.
|
||||
- "Firestore 1 GiB is fine for tens of thousands of users on a text bot." → True until it isn't, hard to predict.
|
||||
- "Cloud Logging is genuinely better." → True. Operational quality of life matters.
|
||||
- "Cloud Run's `--source .` deploy from a Go repo is the simplest serverless deployment in the industry." → True. AWS has no equivalent.
|
||||
|
||||
**If the user values "minimal operational tax + accept ~$1/mo at scale" over "strict $0 + larger headroom": GCP.**
|
||||
**If the user values "stay strictly free + max headroom for growth + faster cold starts": AWS.**
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
If switching cost truly is zero and the project intends to grow:
|
||||
|
||||
**Greenfield stack: AWS**
|
||||
- Lambda (Go on `provided.al2023`, ZIP deploy) behind a Function URL
|
||||
- Lambda Web Adapter so handler code is pure `http.Handler` (portable to Cloud Run later if needed)
|
||||
- DynamoDB on-demand for KV (provider abstraction in `internal/storage/` swaps out Firestore for Dynamo)
|
||||
- EventBridge Scheduler for cron → Lambda invoke (no HTTP loopback needed)
|
||||
- Parameter Store for `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `GEMINI_API_KEY`
|
||||
- CloudWatch Logs at INFO; consider 7-day retention to stay under 5 GB
|
||||
- GitHub Actions OIDC → AWS role for CI deploy (no long-lived keys)
|
||||
- Region: ap-southeast-1 (Singapore) for Vietnam latency
|
||||
|
||||
If switching cost is not actually zero (it's some hours, not weeks):
|
||||
|
||||
**Pragmatic stack: stay GCP**
|
||||
- Phase 04 Firestore is done. Phase 01 Cloud Run is one config exercise.
|
||||
- Accept the 3-cron paywall trigger; budget $1/mo guard.
|
||||
- Be aware Asia egress is billed; volume is tiny.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. **Realistic cron count over project lifetime?** If the user commits to ≤3 forever, GCP holds. If 5+ realistic, AWS wins on this axis alone.
|
||||
2. **State growth projection?** If bot grows to >10k active users with per-user history, Firestore daily caps tighten. DynamoDB doesn't.
|
||||
3. **Effort estimate for AWS port?** Phase 02 (HTTP skeleton): ~1 day with LWA. Phase 04 (KV provider): ~1 day with DynamoDB AWS SDK. Phase 08 (cron): ~half day with EventBridge. **Total ~3 days** to redo what's done. Worth it?
|
||||
4. **Solo-dev tolerance for AWS IAM/SAM/Terraform?** If the user has scar tissue from AWS, the simplicity tax may exceed the free-tier savings.
|
||||
5. **Region choice on AWS?** ap-southeast-1 (Singapore) vs ap-northeast-1 (Tokyo) — pick by where Telegram's edge is closest to bot users in Vietnam. Both have full free-tier coverage.
|
||||
6. **Bedrock vs direct Gemini API?** No reason to switch — Gemini API free tier is independent of cloud and currently better than Bedrock's pay-per-token-only model for hobby use.
|
||||
Reference in New Issue
Block a user