DEV Community

Cover image for Moving Serverless from AWS to GCP: Lambda, Fargate and Events
Emmanuel R for CobuildX AI

Posted on Edited on Originally published at cobuildx.ai

Moving Serverless from AWS to GCP: Lambda, Fargate and Events

An order platform of 30 Lambdas, SQS queues, EventBridge rules and a Step Functions workflow, moved to Cloud Run, Pub/Sub and Workflows. The cost math at 300 million requests a month, the code that breaks at concurrency 40, the queue semantics that change, the workflow gaps and a traffic-shifted cutover.

You run an order platform on about 30 Lambda functions. Twenty-two of them sit behind an API Gateway HTTP API, six read from SQS queues fed by EventBridge rules, and one Step Functions state machine takes every order from "placed" to "paid" to "shipped". The Postgres database has already moved to Cloud SQL. Leadership signed a Google Cloud commit, and the serverless tier is next.

There are no servers to copy, so the move looks small. It isn't. Every one of those functions was written against Lambda's defaults: one request per execution environment, SQS batches with a visibility timeout, EventBridge rules that match on the event body, and a state machine with a 256 KiB payload. Google Cloud changes every one of those defaults. This post works through the decisions in the order we'd make them, with the numbers that drive each one, the option we'd reject, and what we'd measure before and after cutover.

This post covers serverless. For EC2, EKS and GPUs, see moving compute from AWS to GCP. For S3, EBS and EFS, see moving storage from AWS to GCP. For EMR, Glue and Redshift, see moving big data from AWS to GCP. Going the other way? Read moving serverless from GCP to AWS.

The workload we're moving

The scenario is illustrative, not a customer: a representative shape we use to make the trade-offs concrete. Swap in your own numbers; the method stays the same.

Component On AWS today Volume and shape (assumed)
Public API 22 Lambdas (Node.js, 1,024 MB) behind an HTTP API with a JWT authorizer 300 million requests a month, about 116 per second on average, peaks near 600. Mean billed duration 120 ms, of which roughly 100 ms waits on Postgres or a payment provider
Invoice renderer 1 Lambda, 2,048 MB 2 million PDFs a month, 800 ms each, almost all CPU
Async consumers 6 Lambdas on SQS event source mappings, batch size 10, partial batch responses on 10 million messages a month from EventBridge rules on an orders bus
Order workflow 1 Standard Step Functions state machine, with a .waitForTaskToken step for payment confirmation 1 million executions a month, about 12 state transitions each
Schedules EventBridge Scheduler: nightly settlement, hourly stock sync Small
Database Cloud SQL for PostgreSQL (already moved), reached from AWS over HA VPN max_connections set to 400

The mapping itself fits on one page. It is a useful checklist, but it doesn't make any of the decisions that follow:

Serverless service map from AWS to Google Cloud: Lambda to Cloud Run functions, Fargate and App Runner to Cloud Run services, API Gateway to Cloud Run direct or API Gateway or Apigee, EventBridge to Eventarc, SQS and SNS to Pub/Sub or Cloud Tasks, EventBridge Scheduler to Cloud Scheduler, and Step Functions to Workflows, with what to watch for on each

Why the API moves now: while Postgres lives in Google Cloud and the API lives in AWS, every query from every Lambda crosses the VPN. That round trip is paid on each of 300 million requests a month, and it stops the day the API moves.

Decision 1: functions or services, and which billing mode

Cloud Run functions and Cloud Run services run on the same platform. The real choice is not the product name, it is how many requests one instance serves at once and how you are billed for the time it is alive.

How requests reach your code: on standard AWS Lambda, each request gets its own execution environment, so 80 requests need 80 environments; on Cloud Run and Cloud Run functions, up to 80 or more requests share one instance

Lambda bills each request for its own duration times its memory, even if 100 ms of a 120 ms request is spent waiting on a socket. Cloud Run bills instance time: while an instance is serving, it is billed once whether it is handling 1 request or 40. Concurrency defaults to 80 per instance (80 per vCPU when you deploy with gcloud or Terraform) and goes up to 1,000 [2]. There are two billing modes [3]:

  • Request-based: you pay only while an instance is starting, serving or shutting down, plus a per-request fee. In us-central1 that is $0.000024 per vCPU-second, $0.0000025 per GiB-second and $0.40 per million requests [10].
  • Instance-based: you pay for the whole life of every instance, busy or idle, with no per-request fee and full CPU outside requests. In us-central1 that is $0.000018 per vCPU-second and $0.000002 per GiB-second [10].

The worked cost comparison

List prices as of November 2026, AWS in us-east-1 and Google Cloud in us-central1, free tiers ignored on both sides, 30-day month. For Cloud Run we consolidate the 22 API Lambdas into one service with 1 vCPU, 1 GiB and concurrency 40. Average in-flight requests are 116 per second times 0.12 s, about 14 (Little's law). We assume the autoscaler holds an average of 3 instances, each serving almost continuously: 3 × 2,592,000 s = 7,776,000 instance-seconds. That instance count is the assumption to check first, from the instance_count metric during the shadow phase described below.

Line item Lambda + HTTP API Cloud Run, request-based Cloud Run, instance-based
Requests 300M × $0.20/M = $60 300M × $0.40/M = $120 None
Compute 300M × 0.12 s × 1 GB = 36M GB-s × $0.0000166667 = $600 CPU 7.776M vCPU-s = $186.62; memory 7.776M GiB-s = $19.44 CPU 7.776M vCPU-s = $139.97; memory 7.776M GiB-s = $15.55
API front door HTTP API, 300M × $1.00/M = $300 None (JWT checked in middleware) None (JWT checked in middleware)
Monthly total $960 $326 $156

Sources for the AWS lines: Lambda at $0.20 per million requests and $0.0000166667 per GB-second [13], HTTP APIs at $1.00 per million in the first tier [14]. If you put a global external Application Load Balancer in front for a custom domain and Cloud Armor, add its forwarding rule and data processing charges to both Cloud Run columns.

Two thresholds come out of this table, and they are the part worth keeping:

  • When concurrency stops paying. Request-based Cloud Run matches the $960 AWS bill at about 12 always-busy instances (12.2 × 2,592,000 s × $0.0000265 + $120 ≈ $960). With 14 requests in flight, that is roughly 1.1 requests per instance. If your service can't safely run more than one request per instance, the saving disappears and the per-request fee is twice Lambda's.
  • When instance-based billing wins. Instance-based CPU plus memory costs $0.00002 per second against $0.0000265 for request-based. Instance-based is cheaper once instances are busy more than about 75% of their lifetime, and earlier when the per-request fee is a big share of the bill, as it is here. Spiky APIs that sit idle at night belong on request-based billing; steady APIs like this one belong on instance-based.

The counter-example: the invoice renderer

The renderer burns CPU for 800 ms and waits on nothing. On Lambda: 2M × 0.8 s × 2 GB = 3.2M GB-s, or $53.33, plus $0.40 in requests: $53.73. On Cloud Run at concurrency 1, 1 vCPU and 2 GiB, request-based: 1.6M instance-seconds gives $38.40 CPU, $8.00 memory and $0.80 in requests: $47.20. That is a wash. Raising concurrency would not help, because two PDFs on one vCPU take twice as long each and p99 latency doubles. We'd move it as a Cloud Run function at concurrency 1 for the simple deploy, and expect no saving.

The saving comes from waiting, not from Google. I/O-bound APIs with real concurrency get cheaper. CPU-bound functions cost about the same. Model your top five functions before you promise anyone a number.

Where everything else lands

Serverless decision flow: if it is a small function behind an event or webhook, use Cloud Run functions; if it is a container or web app, use a Cloud Run service; if it pulls from a queue with no HTTP endpoint, use a Cloud Run worker pool; if it runs to completion on a schedule, use a Cloud Run job with Cloud Scheduler; if it chains several steps, use Workflows

Piece Our pick Rejected option, and when we'd flip
22 API Lambdas One Cloud Run service, instance-based billing, concurrency 40 22 separate functions: keeps per-route IAM and deploys, but each idles on its own and the concurrency saving shrinks. Flip if routes have very different memory or security needs
API Gateway + JWT authorizer Cloud Run direct, JWT verified in shared middleware Google API Gateway: adds OpenAPI-driven key and JWT checks and its own per-call charge. Apigee: only for monetized partner APIs with quotas and a developer portal. Flip to API Gateway if you sell API keys
Invoice renderer Cloud Run function, concurrency 1 Folding it into the API service: one CPU-heavy route would set instance size for all the cheap ones
Nightly settlement Cloud Run job started by Cloud Scheduler A long HTTP request on the API service: request timeout tops out at 60 minutes [4] and a job retries per task
Hourly stock sync Cloud Scheduler calling a Cloud Run function over HTTP with an OIDC token Eventarc: nothing to route, just a clock
Fargate or App Runner containers (if any) Cloud Run service with the same image; worker pools for queue pullers; jobs for run-to-completion tasks [5] GKE: only for GPUs, host networking or node control

For containers coming off Fargate or App Runner, three checks catch most first-deploy failures: the server listens on $PORT (8080 by default), App Mesh, X-Ray daemon and FireLens sidecars are replaced by Cloud Trace and Cloud Logging, and any work that continues after the response is sent runs on instance-based billing, because request-based instances get CPU only while serving. Cloud Run jobs give each parallel task its index in CLOUD_RUN_TASK_INDEX, the way AWS Batch array jobs use AWS_BATCH_JOB_ARRAY_INDEX.

The smaller differences still each need a decision, usually once for all functions:

Topic AWS Google Cloud
Entry point handler(event, context) Functions Framework (http or cloud_event) or any HTTP server in a container
Longest run 15 minutes 60 minutes for HTTP, 9 minutes for event-driven functions [1]. Longer event work goes to a Cloud Run job
Shared code Layers No layers: a shared package or a base container image
Memory and CPU Set memory; CPU grows with it Set CPU and memory separately
Staying warm Provisioned concurrency or SnapStart Minimum instances, billed at a lower rate while idle on request-based billing [10]
Fan-out to many readers SNS topic Pub/Sub topic with one subscription per reader. Email and SMS subscriptions have no match
Ordered queue SQS FIFO Pub/Sub ordering keys, or Cloud Tasks with a concurrency of one
Rate-limited calls, one-off delayed tasks SQS delay, EventBridge Scheduler one-time schedules Cloud Tasks. Cloud Scheduler is cron only, with time zones
Observability CloudWatch Logs, alarms, X-Ray Cloud Logging, Cloud Monitoring, Cloud Trace. Rebuild alerts before any traffic moves
# The API service as Terraform: was 22 aws_lambda_function resources and an aws_apigatewayv2_api
resource "google_cloud_run_v2_service" "api" {
  name     = "orders-api"
  location = "us-central1"
  template {
    service_account                  = google_service_account.orders_api.email  # was the execution role
    max_instance_request_concurrency = 40
    timeout                          = "30s"
    scaling {
      min_instance_count = 2
      max_instance_count = 15  # derived from the DB connection budget below
    }
    containers {
      image = "us-docker.pkg.dev/shop-prod/apps/orders-api:2.0.0"
      resources {
        limits            = { cpu = "1", memory = "1Gi" }
        cpu_idle          = false  # instance-based billing
      }
    }
    vpc_access {
      network_interfaces { network = "shop-vpc", subnetwork = "run-us-central1" }
      egress = "PRIVATE_RANGES_ONLY"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Decision 2: what breaks at concurrency 40

The cost table assumes the code is safe with 40 requests in one process. Lambda code usually isn't, because it never had to be. Here is the bug we'd look for first:

// Fine on Lambda: one request per execution environment
let currentTenant;  // module scope

exports.handler = async (event) => {
  currentTenant = event.requestContext.authorizer.jwt.claims.tenant_id;
  const order = await loadOrder(event.pathParameters.id);  // yields to the event loop
  return writeAudit(order);  // reads currentTenant
};

// On Cloud Run at concurrency 40, request B overwrites currentTenant while
// request A awaits Postgres. A's audit row is written under B's tenant.
// No error, no log line. Fix: carry request state in AsyncLocalStorage.
const { AsyncLocalStorage } = require("node:async_hooks");
const reqCtx = new AsyncLocalStorage();

app.use((req, res, next) => reqCtx.run({ tenant: req.auth.tenant_id }, next));
const tenant = () => reqCtx.getStore().tenant;
Enter fullscreen mode Exit fullscreen mode

Python has the same problem with module globals, and contextvars is the fix. The other failure modes, in the order they tend to show up:

  • Fixed /tmp paths. /tmp/invoice.pdf written by two requests at once produces one customer's PDF in another's email. Use a per-request temp directory (fs.mkdtemp).
  • Memory, not CPU, runs out first. Cloud Run's filesystem is in memory and counts against the instance limit. 40 concurrent requests each buffering a 20 MB file is 800 MB, which leaves 224 MiB of a 1 GiB instance for the runtime. Expect the instance to be killed for exceeding memory before you see high CPU.
  • Per-request SDK clients. Lambda code often creates a client per invocation because it was cheap there. At concurrency 40 that is 40 TLS handshakes per instance. Create clients once per process.
  • Connection pools that were sized for one. A Lambda that opens one connection is fine. A Cloud Run instance needs a real pool, and the pool size sets the instance cap.

The connection math for this scenario: Postgres allows 400 connections. We reserve 50 for admin and migrations, 100 for the async consumers and 100 for the Lambdas still taking traffic during the split. That leaves 150 for the API service. With a pool of 10 per instance, max_instance_count is 15. Peak demand is 600 req/s × 0.12 s = 72 in flight, which is 2 instances at concurrency 40, so 15 leaves plenty of headroom. On the AWS side, set reserved concurrency on the API Lambdas so they can't exceed their 100 during the split; RDS Proxy can't front Cloud SQL.

How we'd detect a concurrency bug before customers do: deploy at concurrency 1, run a load test that sends requests for many tenants and asserts the tenant in each response and audit row matches the token that made the call, then raise concurrency to 5, 20 and 40 with the same test. A mismatch at 5 that didn't happen at 1 is a shared-state bug. Leave services you haven't audited at concurrency 1; you lose the saving, not correctness.

Decision 3: SQS and EventBridge to Pub/Sub

Both systems deliver at least once. The differences are in who controls the clock and what happens to a message that keeps failing:

Behaviour SQS with a Lambda event source mapping Pub/Sub push subscription to Cloud Run
Delivery unit Batches of up to 10,000 (standard queue), partial batch responses [16] One message per HTTPS request [8]
Time to finish Visibility timeout: 30 s default, extendable per message, 12 hours max [15]. AWS recommends at least 6× the function timeout [16] Ack deadline: 10 s default, 10 to 600 s, set per subscription and not per message for push [9]. If the deadline passes while you're still working, the message is sent again
Success signal Function returns; failed IDs listed in batchItemFailures HTTP 102, 200, 201, 202 or 204. Anything else is a nack [8]
Retries Message reappears after the visibility timeout Immediate by default, or exponential backoff between 10 and 600 s [9]. Push backoff of 100 ms to 60 s applies to the whole subscription after nacks [8]
Dead letters Redrive policy, maxReceiveCount (AWS suggests at least 5) [16] Dead-letter topic, 5 to 100 attempts, default 5, counted approximately. Needs IAM grants for the Pub/Sub service agent or it silently doesn't work [11]
Exactly once FIFO queues, within the deduplication window Pull subscriptions only, within one region [12]. Not push
Retention Per queue 7 days default, up to 31 [9]

What that means for the six consumers:

  • Long handlers can't be push consumers. Our fulfilment consumer sometimes calls a carrier API for 3 minutes. Push works with an ack deadline of 300 s. Anything that can exceed 600 s goes to a pull consumer on a Cloud Run worker pool, which can extend deadlines as it works, or hands the work to a Cloud Run job.
  • One poison message slows everyone. Because push backoff is subscription-wide, a burst of nacks from one malformed order throttles delivery of every healthy order on that subscription. Return 2xx for messages you will never be able to process, write them to your own error table, and save nacks for transient failures.
  • Batches become requests. 10 million messages are 10 million Cloud Run requests, not 1 million batched invocations. At concurrency 40 that is cheap in compute, but it is $4 in request fees on request-based billing, and per-message database writes replace per-batch ones.
  • EventBridge patterns don't carry over as-is. EventBridge rules match on fields inside detail. Pub/Sub subscription filters match only message attributes, are capped at 256 bytes, and can't be changed after the subscription is created [17]. Promote every field a rule matches on (event_type, region, channel) to an attribute at publish time, and decide filters up front. For Google-sourced events (Cloud Storage uploads, audit logs), Eventarc is the router, and it delivers CloudEvents with type, source and data instead of detail-type, source and detail [7].
resource "google_pubsub_subscription" "fulfilment" {
  name                 = "orders-fulfilment"
  topic                = google_pubsub_topic.orders.id
  ack_deadline_seconds = 300  # longest observed handler time plus margin; max 600
  filter               = "attributes.event_type = \"order.paid\""  # immutable

  push_config {
    push_endpoint = "${google_cloud_run_v2_service.fulfilment.uri}/pubsub"
    oidc_token { service_account_email = google_service_account.pubsub_push.email }
  }
  retry_policy {
    minimum_backoff = "10s"
    maximum_backoff = "600s"
  }
  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.orders_dlq.id
    max_delivery_attempts = 10
  }
}

# Without these two grants the dead-letter policy does nothing.
locals { pubsub_agent = "serviceAccount:service-${data.google_project.p.number}@gcp-sa-pubsub.iam.gserviceaccount.com" }
resource "google_pubsub_topic_iam_member" "dlq_publish" {
  topic  = google_pubsub_topic.orders_dlq.id
  role   = "roles/pubsub.publisher"
  member = local.pubsub_agent
}
resource "google_pubsub_subscription_iam_member" "src_subscribe" {
  subscription = google_pubsub_subscription.fulfilment.id
  role         = "roles/pubsub.subscriber"
  member       = local.pubsub_agent
}
Enter fullscreen mode Exit fullscreen mode

Idempotency, keyed on the business event

During the migration, the same business event can arrive twice from two different systems: once from SQS on AWS and once from Pub/Sub via the bridge. They carry different message IDs. Deduplicating on messageId catches Pub/Sub redeliveries but not the cross-cloud duplicate. Key on the business event instead, and record it in the same transaction as the side effect:

BEGIN;
INSERT INTO processed_events (event_key, consumer)
VALUES ('order.paid:ord_8F2K1', 'fulfilment')
ON CONFLICT (event_key, consumer) DO NOTHING;
-- 0 rows inserted: already handled, COMMIT and return 204
-- 1 row inserted: create the shipment in this same transaction, then COMMIT
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Count the DO NOTHING hits as a metric. A steady trickle is normal at-least-once behaviour. A jump during a cutover step means two consumers are live for the same subscription and the step needs a look.

Decision 4: the order workflow on Workflows

Step Functions and Workflows cover the same ground: steps, retries, choices, waits, parallel branches and callbacks. The definitions are rewritten by hand, Amazon States Language JSON into Workflows YAML. The gaps that matter for this state machine:

Concern Step Functions Standard Workflows What we do about it
Data size 256 KiB per state input or output [18] 512 KB for all variables in an execution combined; 2 MB per HTTP response [19] Pass order IDs, not order documents. Services read from Postgres
Fan-out Distributed Map: up to 10,000 parallel child executions [18] 20 concurrent branches or iterations per execution, 10 branches per parallel step [19] Our workflow fans out to 3 to 5 warehouses: fine. Bulk re-processing goes to a Cloud Run job instead
Wait for an external system .waitForTaskToken, then SendTaskSuccess events.create_callback_endpoint and events.await_callback, 12-hour default timeout. The caller needs workflows.callbacks.send [20] The payment provider can't hold a Google token, so its webhook hits our payments service, which calls the callback URL with its own service account
Long runs 1 year max, 25,000 history events [18] 1 year max, 100,000 steps [19] No change
Short, high-volume flows Express workflows, $1.00 per million plus duration [21] No separate flavour If you had Express workflows, consider plain code in a Cloud Run service
Concurrency Open executions in the millions [18] 10,000 concurrent per region by default, backlog up to 100,000 [19]. Executions waiting on callbacks count 1M orders a month with a payment wait of minutes stays far below 10,000. Watch it if waits stretch to days

Cost, at list price as of November 2026: Step Functions Standard is $0.025 per 1,000 state transitions [21], so 1M executions × 12 transitions = $300 a month. Workflows charges $0.01 per 1,000 internal steps and $0.025 per 1,000 external steps [22]. Assignments count as steps, so the rewrite is about 18 steps per order: 17 internal and one external (await_callback). That is 17M × $0.01/1,000 + 1M × $0.025/1,000 = $195 a month.

The catch is in what counts as internal. Calls to Cloud Run on *.run.app URLs are internal steps. Calls to the same services on a custom domain such as orders.shop.example are external [22], and every retry attempt is a step. Point the workflow at custom domains and the 17M internal steps become external: $425 for those alone, more than Step Functions.

main:
  params: [order]
  steps:
    - reserve_stock:
        try:
          call: http.post
          args:
            url: https://inventory-abc123-uc.a.run.app/reserve  # run.app: internal step
            auth: { type: OIDC }
            body:
              order_id: ${order.id}
          result: reservation
        retry:
          predicate: ${http.default_retry_predicate}
          max_retries: 5
          backoff: { initial_delay: 2, max_delay: 60, multiplier: 2 }
    - make_callback:
        call: events.create_callback_endpoint
        args: { http_callback_method: POST }
        result: cb
    - request_payment:
        call: http.post
        args:
          url: https://payments-abc123-uc.a.run.app/charge
          auth: { type: OIDC }
          body:
            order_id: ${order.id}
            callback_url: ${cb.url}
    - await_payment:  # replaces .waitForTaskToken
        call: events.await_callback
        args:
          callback: ${cb}
          timeout: 3600
        result: payment
    - done:
        return: ${payment.http_request.body}
Enter fullscreen mode Exit fullscreen mode

Step Functions errors such as States.Timeout and Lambda.ServiceException don't exist on the other side. Every Retry and Catch block maps to a try with a retry predicate, and custom error names your Lambdas threw become HTTP status codes your services return. Write that mapping down per state before you translate; it is where behaviour quietly changes.

Identity and network

The identity model is the same idea with different names. Each Lambda execution role becomes a service account on the service, and each function URL resource policy becomes roles/run.invoker. The one default to override: Cloud Run uses the Compute Engine default service account if you don't name one, and that account often has broad project rights. Create one service account per service, and one for Pub/Sub push and Cloud Scheduler to sign their OIDC tokens with.

AWS Google Cloud
Lambda execution role, ECS task role Service account on the service or function
Lambda resource policy, IAM auth on a function URL roles/run.invoker on the service
Public function URL or API Allow unauthenticated invocations on the service
SigV4-signed calls between services Google-signed ID tokens between services
Secrets Manager, Parameter Store Secret Manager, mounted as a file or env var

During the migration, GCP services still call AWS (an S3 bucket, a DynamoDB table) and the AWS bridge still publishes to Pub/Sub. Don't solve either with long-lived keys. The bridge Lambda authenticates to Google Cloud through Workload Identity Federation with an AWS provider, and GCP services call AWS by exchanging a Google ID token for an AWS role with AssumeRoleWithWebIdentity.

Network. Lambda joined the VPC. Cloud Run uses Direct VPC egress: --network, --subnet and --vpc-egress=private-ranges-only or all-traffic [6]. Two numbers decide the subnet size. Services use about 2 IP addresses per instance, and addresses are held for up to 20 minutes after scale-down, so during a deploy both revisions hold IPs [6]. Our API caps at 15 instances: 30 IPs steady, 60 during a deploy. Add the consumers and functions and a /26 (64 addresses, minus the 4 Google reserves) is tight; we'd use a /24 for the region and stop thinking about it.

Direct VPC egress addresses are ephemeral [6]. The payment provider allow-lists our outbound IP, so the payments service routes all-traffic through the VPC to Cloud NAT with a reserved static address. Everything else keeps private-ranges-only and reaches the internet directly, which keeps NAT out of the path of services that don't need it.

The cutover, shifted by traffic

No big-bang weekend. Each consumer and the API move on their own, and every step can be reversed by changing a weight back.

  1. Deploy dark. gcloud run deploy orders-api --image IMAGE --no-traffic --tag green gives the new revision its own URL, https://green---orders-api-HASH.a.run.app, with no production traffic [23]. Replay a day of sanitized production requests against it and diff responses against AWS.
  2. Issue the certificate before DNS moves. The Google-managed certificate for api.shop.example on the load balancer needs DNS authorization, because DNS still points at AWS and HTTP validation would fail.
  3. Shift the API with weighted DNS. Route 53 weighted records for api.shop.example, TTL 60 s: 1% to the Google load balancer, then 10%, 50% and 100%. Hold each step through at least one daily peak. Some resolvers ignore TTLs, so keep the AWS stack serving at weight 0 for a week after 100%.
  4. Move consumers one at a time. For each consumer: create its Pub/Sub subscription first as a pull subscription with no endpoint, so it starts retaining bridged messages. Then, in one change, disable the Lambda event source mapping and switch the subscription to push. Messages from the overlap window arrive twice; the processed_events table absorbs them.
  5. Move the workflow last. New orders start on Workflows from the moment the API at 100% calls it. In-flight Step Functions executions finish on AWS; with payment waits measured in minutes, that drains in hours.
  6. Cut the bridge, then clean up. When the AWS queues have been empty for a full week, delete the bridge, the rules and the functions. Buy a Cloud Run committed use discount only after a month of real usage.

What we'd watch at each step, side by side. The gates are our choices for this scenario; set your own before you start, not during.

Signal AWS metric Google Cloud metric Gate to take the next step
Latency per route API Gateway Latency p99 Cloud Run request_latencies p99 GCP p99 within 10% of AWS
Errors API Gateway 5xx Cloud Run request_count where class is 5xx Rate no higher than AWS
Queue lag SQS ApproximateAgeOfOldestMessage Pub/Sub subscription/oldest_unacked_message_age Under 60 s at peak
Dead letters Messages in the DLQ Messages published to the dead-letter topic Zero new ones that aren't explained
Database Connections from Lambda Cloud SQL num_backends Below 80% of max_connections
Duplicates n/a processed_events conflict count No jump after a step
Cost Cost Explorer, per service Billing export, per service Cost per million requests within the model above

There are no servers to copy in a serverless move. The work is in the defaults: concurrency, ack deadlines, event shapes, retries, payload limits and identity. Decide each one on purpose, with numbers, and the rest is typing.

Common questions

What is the Lambda equivalent in GCP?

Cloud Run functions for small, event-driven code, and Cloud Run services for containers. Both run on Cloud Run and both scale to zero. The biggest difference is that one Cloud Run instance handles many requests at once, which changes both the bill and what your code must be safe against.

Is Cloud Run cheaper than Lambda?

For I/O-bound APIs that can run many requests per instance, usually yes: in the worked example above, $960 a month became $156 to $326. For CPU-bound work at concurrency 1, it is about the same. The saving disappears if each instance averages little more than one request in flight.

What replaces SQS on GCP?

Pub/Sub subscriptions for most queues, and Cloud Tasks when you need to rate-limit calls to a service or schedule a single task for later. Plan for one message per push request, an ack deadline of at most 600 seconds, and a dead-letter topic that needs IAM grants to work.

Is there a Step Functions equivalent in GCP?

Workflows. It covers steps, retries, choices, waits, parallel branches and callbacks, written in YAML. Each state machine is rewritten by hand, and large fan-outs, large payloads and Express workflows need a different design.

Do I need API Gateway on GCP?

Often not. Cloud Run services already have HTTPS addresses and IAM checks, and JWT validation fits in middleware. Use Google's API Gateway when you want API keys or JWT checks driven by an OpenAPI spec, and Apigee when you need quotas, monetization or a partner portal.

How MigrateX handles this

MigrateX

MigrateX is our platform and service for moving software, data and infrastructure from one technology to another. Automation does the repetitive work; experienced engineers make the decisions this post walks through.

  1. Assessment. We inventory every function, container service, API, event rule, queue, topic, schedule and state machine, with triggers, timeouts, roles, batch settings and the hidden ties to AWS, like SDK calls and ARNs in config. We flag code that is unsafe at concurrency above one before anything moves.
  2. Model. We build the cost comparison above from your own CloudWatch and billing data, per service, including the billing mode and concurrency each one should run at, so the expected saving is a number you can check.
  3. Migration. Automation rewrites entry points, turns Lambda, API Gateway and ECS Terraform into Cloud Run, and maps rules and queues to Pub/Sub and Eventarc. Our engineers handle shared state, event shapes, idempotency keys and the Step Functions rewrites.
  4. Cutover. Each service runs on both clouds with the gates agreed up front. Traffic moves by weight, and every step can be reversed.
  5. Handover. We turn AWS off service by service, size commitments on real usage, and leave your team owning services they understand.

Planning a move from AWS to GCP? Tell us about your setup through Contact Us, and we'll start with a free conversation about where the work is likely to be.

References

Google Cloud

AWS

Filters, workflows and traffic

CobuildX helps teams migrate legacy applications and cloud infrastructure with MigrateX.


Originally published on the CobuildX blog.

Top comments (0)