Skip to content

Commit 85436b9

Browse files
authored
feat: move worker to Fargate, drop the 15-min Lambda cap (#74)
1 parent 21cb538 commit 85436b9

21 files changed

Lines changed: 623 additions & 175 deletions

.github/workflows/deploy.yml

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ permissions:
1919

2020
env:
2121
AWS_REGION: eu-west-1
22-
ECR_REPO: dumpus-prod-lambda
22+
ECR_LAMBDA_REPO: dumpus-prod-lambda
23+
ECR_WORKER_REPO: dumpus-prod-worker
2324
API_FUNCTION: dumpus-prod-api
24-
WORKER_FUNCTION: dumpus-prod-worker
25+
FORWARDER_FUNCTION: dumpus-prod-forwarder
26+
WORKER_TASK_FAMILY: dumpus-prod-worker
2527

2628
jobs:
2729
build-and-deploy:
@@ -53,29 +55,41 @@ jobs:
5355
- name: Set up Buildx
5456
uses: docker/setup-buildx-action@v4
5557

56-
- name: Build & push Lambda image
57-
id: build
58+
- name: Build & push Lambda image (api + forwarder)
5859
uses: docker/build-push-action@v7
5960
with:
6061
context: .
6162
file: Dockerfile.lambda
6263
push: true
6364
# Lambda only runs amd64 unless the function is configured for arm64.
64-
# Keep amd64 unless we explicitly switch the function architecture.
6565
platforms: linux/amd64
6666
provenance: false
6767
tags: |
68-
${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPO }}:${{ steps.meta.outputs.image_tag }}
69-
${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPO }}:latest
68+
${{ steps.ecr.outputs.registry }}/${{ env.ECR_LAMBDA_REPO }}:${{ steps.meta.outputs.image_tag }}
69+
${{ steps.ecr.outputs.registry }}/${{ env.ECR_LAMBDA_REPO }}:latest
7070
cache-from: type=gha,scope=lambda
7171
cache-to: type=gha,scope=lambda,mode=max
7272

73-
- name: Update Lambda functions
73+
- name: Build & push worker image (Fargate)
74+
uses: docker/build-push-action@v7
75+
with:
76+
context: .
77+
file: Dockerfile.worker
78+
push: true
79+
platforms: linux/amd64
80+
provenance: false
81+
tags: |
82+
${{ steps.ecr.outputs.registry }}/${{ env.ECR_WORKER_REPO }}:${{ steps.meta.outputs.image_tag }}
83+
${{ steps.ecr.outputs.registry }}/${{ env.ECR_WORKER_REPO }}:latest
84+
cache-from: type=gha,scope=worker
85+
cache-to: type=gha,scope=worker,mode=max
86+
87+
- name: Update Lambdas
7488
env:
75-
IMAGE_URI: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPO }}:${{ steps.meta.outputs.image_tag }}
89+
IMAGE_URI: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_LAMBDA_REPO }}:${{ steps.meta.outputs.image_tag }}
7690
run: |
7791
set -euo pipefail
78-
for fn in "${API_FUNCTION}" "${WORKER_FUNCTION}"; do
92+
for fn in "${API_FUNCTION}" "${FORWARDER_FUNCTION}"; do
7993
echo "::group::Updating $fn"
8094
aws lambda update-function-code \
8195
--function-name "$fn" \
@@ -86,6 +100,38 @@ jobs:
86100
echo "::endgroup::"
87101
done
88102
103+
- name: Register new worker task definition revision
104+
env:
105+
IMAGE_URI: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_WORKER_REPO }}:${{ steps.meta.outputs.image_tag }}
106+
run: |
107+
set -euo pipefail
108+
# Pull the active revision, swap in the new image, register a new
109+
# revision. ECS resolves task family → latest revision on each
110+
# runTask call, so no further wiring is needed.
111+
aws ecs describe-task-definition \
112+
--task-definition "$WORKER_TASK_FAMILY" \
113+
--query 'taskDefinition' \
114+
> /tmp/td.json
115+
jq --arg img "$IMAGE_URI" '
116+
.containerDefinitions[0].image = $img
117+
| {
118+
family,
119+
taskRoleArn,
120+
executionRoleArn,
121+
networkMode,
122+
containerDefinitions,
123+
requiresCompatibilities,
124+
cpu,
125+
memory,
126+
ephemeralStorage,
127+
runtimePlatform
128+
}
129+
| with_entries(select(.value != null))
130+
' /tmp/td.json > /tmp/td-new.json
131+
aws ecs register-task-definition \
132+
--cli-input-json file:///tmp/td-new.json \
133+
> /dev/null
134+
89135
- name: Smoke test API
90136
run: |
91137
set -euo pipefail

Dockerfile.worker

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Fargate task image. Plain Python — no Lambda runtime — because Fargate
2+
# runs ordinary container processes. Keeps the image small and the entrypoint
3+
# obvious.
4+
FROM python:3.10-slim
5+
6+
WORKDIR /app
7+
8+
# System deps are minimal: pandas wheels are manylinux, orjson too. If a
9+
# future package adds something native, install it here.
10+
RUN apt-get update \
11+
&& apt-get install -y --no-install-recommends ca-certificates \
12+
&& rm -rf /var/lib/apt/lists/*
13+
14+
COPY requirements.txt ./
15+
RUN pip install --no-cache-dir -r requirements.txt
16+
17+
# Flat layout: matches Dockerfile.lambda so existing top-level imports
18+
# (`from tasks import ...`) keep working.
19+
COPY src/ /app/
20+
21+
ENTRYPOINT ["python", "-u", "worker_main.py"]

README.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,18 @@ By default, Dumpus API will only treat zip files sent from `https://discord.clic
4747

4848
## Deploy to AWS
4949

50-
A Terraform stack under `infra/terraform/` provisions a serverless deployment of the API on AWS:
50+
A Terraform stack under `infra/terraform/` provisions a deployment of the API on AWS:
5151

5252
| Component | AWS service |
5353
| ---------------- | ------------------------ |
5454
| API | Lambda (container image) behind API Gateway HTTP API |
55-
| Worker | Lambda triggered by SQS |
55+
| Forwarder | Lambda triggered by SQS, fires one Fargate task per message |
56+
| Worker | Fargate task (no time / memory caps, pay-per-run) |
5657
| Database | RDS Postgres in private subnets |
5758
| Outbound NAT | fck-nat instance (NAT Gateway replacement) |
58-
| Secrets | Secrets Manager + Lambda env |
59+
| Secrets | Secrets Manager + Lambda/task env |
5960
| TLS / DNS | ACM cert + Route53 alias to API Gateway |
60-
| CI | GitHub OIDC role; build → ECR → `update-function-code` |
61+
| CI | GitHub OIDC role; build → ECR → `update-function-code` + `register-task-definition` |
6162

6263
### Bootstrap
6364

@@ -69,21 +70,23 @@ A Terraform stack under `infra/terraform/` provisions a serverless deployment of
6970

7071
### Day-to-day deploys
7172

72-
Push to `main``.github/workflows/deploy.yml` builds the container, pushes to ECR tagged with the git SHA, and rolls both Lambdas via OIDC. No long-lived AWS keys in GitHub.
73+
Push to `main``.github/workflows/deploy.yml` builds both container images (Lambda for the API + forwarder, plain Python for the Fargate worker), pushes them to ECR tagged with the git SHA, rolls both Lambdas, and registers a new ECS task definition revision. The next runTask call picks up the new image. No long-lived AWS keys in GitHub.
7374

7475
### Operations
7576

7677
```bash
7778
aws logs tail /aws/lambda/<name-prefix>-<env>-api --follow
78-
aws logs tail /aws/lambda/<name-prefix>-<env>-worker --follow
79+
aws logs tail /aws/lambda/<name-prefix>-<env>-forwarder --follow
80+
aws logs tail /aws/ecs/<name-prefix>-<env>-worker --follow
7981
aws sqs receive-message --queue-url "$(terraform output -raw sqs_dlq_url)"
8082
```
8183

8284
Things to keep in mind:
8385

8486
- **API cold start** is a few seconds while pandas imports. Invisible on the async submit/poll flow; use provisioned concurrency if a sync endpoint must be sub-second.
85-
- **Worker `/tmp` cap** defaults to 5GB (max 10GB). Bump `worker_ephemeral_storage_mb` if users upload very large Discord exports.
86-
- **Worker timeout** is 15 min (Lambda hard cap). Failures land in the DLQ after one retry.
87+
- **Worker `/tmp` cap** defaults to 30 GiB. Bump `worker_task_ephemeral_storage_gib` if users upload very large Discord exports (Fargate ceiling is 200 GiB).
88+
- **Worker has no time cap.** Heavy packages just take as long as they need; failures show up as ERRORED package rows + a Discord webhook from `process_package`, not in the DLQ.
89+
- **Forwarder DLQ.** If the forwarder Lambda itself fails to launch a Fargate task twice (capacity / IAM / network), the SQS message lands in the DLQ — alarmed via Discord through `monitoring.tf`.
8790
- **fck-nat is a single instance.** Switch to a managed NAT Gateway if you need the extra availability — at the cost of a much higher fixed monthly bill.
8891

8992
## API Documentation

infra/terraform/.terraform.lock.hcl

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

infra/terraform/bootstrap.tf

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
# Solves the first-apply chicken-and-egg between ECR and Lambda:
2-
# CreateFunction (image_uri = ecr_repo:bootstrap) requires the image to exist,
3-
# but the image obviously doesn't exist until something pushes it.
1+
# Solves the first-apply chicken-and-egg between ECR and Lambda/ECS:
2+
# CreateFunction (image_uri = ecr_repo:bootstrap) and ECS task definitions
3+
# require the image to exist, but the image obviously doesn't exist until
4+
# something pushes it.
45
#
5-
# This null_resource runs on first apply, pulls the public AWS Lambda Python
6-
# base image, and re-pushes it to our ECR with the :bootstrap tag. The Lambda
7-
# functions depend on it, so apply ordering becomes:
8-
# ECR repo → null_resource (push placeholder) → Lambdas
6+
# These null_resources run on first apply, pull the relevant public base
7+
# image, and re-push it to our ECR with the :bootstrap tag. Lambda + ECS
8+
# resources depend on it, so apply ordering becomes:
9+
# ECR repo → null_resource (push placeholder) → consumer
910
#
10-
# After first apply, CI takes over: every push to main builds the real image
11-
# and calls update-function-code, which Terraform ignores via the
12-
# lifecycle.ignore_changes = [image_uri] block on the Lambda resources.
11+
# After first apply, CI takes over: every push to main builds the real
12+
# image and updates the consumer (Lambda update-function-code, ECS
13+
# register-task-definition). The lifecycle.ignore_changes blocks on
14+
# image_uri / container_definitions keep TF out of CI's way.
1315
#
1416
# Requires `docker` and `aws` CLI on the apply host.
1517

@@ -27,9 +29,6 @@ resource "null_resource" "lambda_bootstrap_image" {
2729
region="${var.region}"
2830
repo_name="${aws_ecr_repository.lambda.name}"
2931
30-
# Idempotent: skip if a :bootstrap image already exists (e.g. after CI
31-
# has run and the placeholder was overwritten with a real image, or after
32-
# a re-apply that re-creates this null_resource for any reason).
3332
if aws ecr describe-images \
3433
--repository-name "$${repo_name}" \
3534
--image-ids imageTag=bootstrap \
@@ -41,8 +40,6 @@ resource "null_resource" "lambda_bootstrap_image" {
4140
aws ecr get-login-password --region "$${region}" \
4241
| docker login --username AWS --password-stdin "$${registry}"
4342
44-
# The placeholder just needs to satisfy CreateFunction's image-exists
45-
# check; it is replaced on the first CI deploy. Pin a non-deprecated tag.
4643
placeholder="public.ecr.aws/lambda/python:3.13"
4744
docker pull --platform linux/amd64 "$${placeholder}"
4845
docker tag "$${placeholder}" "$${repo_url}:bootstrap"
@@ -52,3 +49,39 @@ resource "null_resource" "lambda_bootstrap_image" {
5249

5350
depends_on = [aws_ecr_repository.lambda]
5451
}
52+
53+
resource "null_resource" "worker_bootstrap_image" {
54+
triggers = {
55+
repo_url = aws_ecr_repository.worker.repository_url
56+
}
57+
58+
provisioner "local-exec" {
59+
interpreter = ["bash", "-c"]
60+
command = <<-EOT
61+
set -euo pipefail
62+
repo_url="${aws_ecr_repository.worker.repository_url}"
63+
registry="$${repo_url%%/*}"
64+
region="${var.region}"
65+
repo_name="${aws_ecr_repository.worker.name}"
66+
67+
if aws ecr describe-images \
68+
--repository-name "$${repo_name}" \
69+
--image-ids imageTag=bootstrap \
70+
--region "$${region}" >/dev/null 2>&1; then
71+
echo "bootstrap tag already present in $${repo_name}, skipping"
72+
exit 0
73+
fi
74+
75+
aws ecr get-login-password --region "$${region}" \
76+
| docker login --username AWS --password-stdin "$${registry}"
77+
78+
# Plain Python base — what the worker image actually uses.
79+
placeholder="public.ecr.aws/docker/library/python:3.10-slim"
80+
docker pull --platform linux/amd64 "$${placeholder}"
81+
docker tag "$${placeholder}" "$${repo_url}:bootstrap"
82+
docker push "$${repo_url}:bootstrap"
83+
EOT
84+
}
85+
86+
depends_on = [aws_ecr_repository.worker]
87+
}

infra/terraform/ecr.tf

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
# One repo holds the image used by both Lambdas. The two functions select
2-
# their handler via ImageConfig.Command.
1+
# Two repos: one for the Lambda image (API + forwarder), one for the
2+
# Fargate worker image. They have different base images (Lambda runtime vs
3+
# plain Python) so they can't share.
4+
35
resource "aws_ecr_repository" "lambda" {
46
name = "${local.name}-lambda"
57
image_tag_mutability = "MUTABLE"
@@ -9,9 +11,17 @@ resource "aws_ecr_repository" "lambda" {
911
}
1012
}
1113

12-
resource "aws_ecr_lifecycle_policy" "lambda" {
13-
repository = aws_ecr_repository.lambda.name
14-
policy = jsonencode({
14+
resource "aws_ecr_repository" "worker" {
15+
name = "${local.name}-worker"
16+
image_tag_mutability = "MUTABLE"
17+
18+
image_scanning_configuration {
19+
scan_on_push = true
20+
}
21+
}
22+
23+
locals {
24+
ecr_lifecycle_policy = jsonencode({
1525
rules = [
1626
{
1727
rulePriority = 1
@@ -37,3 +47,13 @@ resource "aws_ecr_lifecycle_policy" "lambda" {
3747
]
3848
})
3949
}
50+
51+
resource "aws_ecr_lifecycle_policy" "lambda" {
52+
repository = aws_ecr_repository.lambda.name
53+
policy = local.ecr_lifecycle_policy
54+
}
55+
56+
resource "aws_ecr_lifecycle_policy" "worker" {
57+
repository = aws_ecr_repository.worker.name
58+
policy = local.ecr_lifecycle_policy
59+
}

0 commit comments

Comments
 (0)