Migrations between clouds get pitched as either trivial ("it's all just Kubernetes") or impossible ("total rewrite required"). Neither is honest. The real experience is closer to: most of the application layer ports over cleanly, and the places it doesn't are predictable once you know where to look.

The service mapping that made planning possible

Before writing a line of migration code, map every AWS service in use to its Alibaba Cloud equivalent and mark the confidence level on each mapping:

AWS service Alibaba Cloud equivalent Migration confidence
EC2 ECS High — near 1:1
S3 OSS High — API-compatible SDK path exists
RDS (PostgreSQL/MySQL) ApsaraDB RDS High — same engines supported
EKS ACK High — standard Kubernetes underneath
Lambda Function Compute Medium — event source mapping differs
SQS Message Service (MNS) Medium — API shape differs enough to need an adapter
CloudFront Alibaba Cloud CDN Medium — edge behavior rules need rewriting
IAM RAM High — concepts map directly, syntax differs

The "High confidence" row items — compute, storage, managed relational databases, Kubernetes — accounted for roughly 70% of the workload's total infrastructure footprint and ported with mechanical, low-risk changes. The "Medium confidence" items were where the actual migration time went.

What went smoother than expected

The Kubernetes layer was closest to a non-event. Application manifests, Helm charts, and the CI/CD deployment logic needed only StorageClass and LoadBalancer annotation changes (covered in my ACK vs. self-managed piece) — the actual application containers didn't change at all.

RDS-to-ApsaraDB was a snapshot-and-restore, not a re-architecture. Both support standard pg_dump/mysqldump workflows, and for a lower-downtime cutover, Alibaba Cloud's Data Transmission Service (DTS) handled ongoing replication from the AWS-side database until the cutover window:

aliyun dts CreateDtsJob \
  --SourceEndpoint.InstanceType "express" \
  --SourceEndpoint.Ip "<aws-rds-public-or-vpn-ip>" \
  --SourceEndpoint.Port 5432 \
  --SourceEndpoint.EngineName "PostgreSQL" \
  --DestinationEndpoint.InstanceType "RDS" \
  --DestinationEndpoint.InstanceId "rm-xxxxxxxxxxxx" \
  --JobType "MIGRATION" \
  --SyncArchitecture "oneway" \
  --StructureInitialization true \
  --DataInitialization true \
  --DataSynchronization true

DataSynchronization: true is what keeps the job running past the initial copy — it streams ongoing writes from the AWS side so the Alibaba Cloud replica stays current right up to the actual cutover, instead of going stale the moment the snapshot finishes.

What turned out to need real rework

Lambda to Function Compute wasn't a drop-in. The trigger and event-source model differs enough — particularly around how event payloads are structured for object-storage triggers — that every function needed its handler signature adjusted, not just redeployed. Budget real engineering time here; this was the single largest line item in the migration.

// AWS Lambda — S3 trigger
exports.handler = async (event) => {
  const bucket = event.Records[0].s3.bucket.name;
  const key = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, ' '));
  return processFile(bucket, key);
};
// Alibaba Cloud Function Compute — OSS trigger
exports.handler = async (event, context) => {
  const evt = JSON.parse(event.toString());
  const record = evt.events[0];
  const bucket = record.oss.bucket.name;
  const key = decodeURIComponent(record.oss.object.key);
  return processFile(bucket, key);
};

The business logic (processFile) never changed — every migrated function's actual rewrite was this same handful of lines at the top, unwrapping a differently-shaped event envelope. Worth writing a small adapter once and reusing it, rather than hand-editing each function.

CloudFront's edge rules needed a full rewrite, not a translation. Alibaba Cloud CDN's rule engine covers the same functional territory — cache behaviors, header manipulation, redirect rules — but the configuration syntax and the console's mental model are different enough that treating it as a port-and-adjust task underestimated the work by roughly 3x against the original estimate.

IAM policies took longer to re-author than expected, not because RAM is harder to use, but because the migration was a good forcing function to actually audit what the AWS policies had accumulated over several years. About a third of the permissions in the original IAM policies turned out to be unused entirely — worth doing this audit regardless of whether a migration is the reason.

AWS Access Advisor made the audit possible — it reports which permissions a policy grants but the principal has never actually used:

# Pull last-accessed data for every attached policy before rewriting it as a RAM policy
aws iam generate-service-last-accessed-details --arn <role-arn>
aws iam get-service-last-accessed-details --job-id <job-id-from-above> \
  --query "ServicesLastAccessed[?LastAuthenticated==null].ServiceName"

That last query is the useful part — it filters down to services the role was granted but has never called, which is exactly the unused third that got dropped from the rewritten RAM policies rather than carried over as dead weight.

The cutover strategy that avoided a big-bang risk

Rather than a single cutover weekend, the migration ran as:

  1. Stand up the full stack on Alibaba Cloud in parallel, with the database replicating continuously from AWS.
  2. Shift read traffic first, via weighted DNS, to validate the new stack under real load without risking write-path correctness.
  3. Shift write traffic in a single short maintenance window, once read-path metrics matched the AWS baseline for two full weeks.
  4. Keep the AWS stack warm for 30 days post-cutover as a rollback path, then decommission.

Step 2 in practice — weighted routing via Alibaba Cloud DNS, starting at a small percentage and ramping up as the new stack proves itself:

aliyun alidns AddDNSSDomainRecord \
  --DomainName example.com \
  --RR "api" \
  --Type "A" \
  --Value "<alibaba-cloud-lb-ip>" \
  --Line "default" \
  --Weight 10   # 10% of read traffic; raise incrementally, watch error rate at each step

The rollback for any step is one command — set --Weight 0 on the new record — which is why this shipped with confidence instead of a maintenance-window gamble.

That 30-day overlap cost real money in duplicate infrastructure, but it was the single decision that made the whole migration feel low-risk to stakeholders — nobody has to trust a migration is correct on faith when there's a working rollback path sitting right there.

Would I do it again the same way

Yes, with one change: I'd start the RAM/IAM audit in week one instead of treating it as a migration task that happens alongside the infrastructure work. It's genuinely a separate project, and bundling it into the migration timeline made an already multi-variable project harder to estimate accurately.