What it actually is
AWS Cloud Map is a registry. You give it a logical name, like orders in a namespace called shop.internal, and it stores the addresses and metadata of the things that currently answer to that name. Applications ask it where things are, either through an API call or through ordinary DNS, and it hands back only the instances it believes are healthy.
The mental model the docs bury: Cloud Map is closer to a small database than to a load balancer or a mesh. It stores rows (instances with up to 30 custom key-value attributes), it lets you query them with filters, and it hands back a randomized list. It does not proxy a single byte of your traffic. Whoever calls it still opens the connection, still retries, still decides what to do when the answer is empty.
That distinction explains most of the confusion around it. Cloud Map is not a service mesh, not an Elastic Load Balancer, and not Route 53 with a nicer console. It is the directory that those things, and ECS, sit on top of.
The model
There are three nouns, and they nest.
A namespace groups the services of one application. It defines the name you search under and, more importantly, how callers are allowed to find things. The documentation describes three discovery paths: DiscoverInstances API calls, DNS queries inside a VPC, and public DNS queries. In the API this maps to three namespace types: an HTTP namespace (API only), a private DNS namespace, and a public DNS namespace. Creating a DNS namespace also creates a Route 53 hosted zone for you, and that hosted zone counts against your account's hosted zone quota.
A service is a template for one kind of resource, say your web tier or your database. It decides which DNS record types get created (A, AAAA, SRV or CNAME, for DNS namespaces) and whether health checking is on, and which kind. If you pass Type="HTTP" the service is only discoverable through the API and no DNS records are created at all.
An instance is a registered thing. You call RegisterInstance with an InstanceId that is unique inside the service and an Attributes map. Some keys are reserved: AWS_INSTANCE_IPV4, AWS_INSTANCE_IPV6, AWS_INSTANCE_PORT, AWS_INSTANCE_CNAME, AWS_ALIAS_DNS_NAME (an alias to a load balancer), AWS_EC2_INSTANCE_ID (HTTP namespaces only) and AWS_INIT_HEALTH_STATUS. Everything else is yours: version, stage, shard, zone. You can add up to 30 custom attributes, each name up to 255 characters, each value up to 1,024, and the total size of all keys and values together must stay under 5,000 characters.
Three behaviors matter more than the diagram suggests.
First, registration is asynchronous. RegisterInstance and CreateHttpNamespace return an OperationId, and you poll GetOperation to know the work finished. Second, one RegisterInstance call must complete before you can submit another with the same service ID and instance ID. Third, re-registering an existing instance ID is an update, not an error, and if the service has a Route 53 health check the old one is deleted and a new one created.
Health comes in two flavors. A HealthCheckConfig makes Cloud Map create a Route 53 health check, and the docs list it as available for public DNS and HTTP namespaces only. A HealthCheckCustomConfig creates no checker at all: you, or ECS on your behalf, report status with UpdateInstanceCustomHealthStatus. You cannot add, update or delete a custom health config on an existing service, so decide up front.
There is also a revision counter. DiscoverInstances returns an InstancesRevision that changes when an instance is registered or deregistered. Per the API reference, health status updates do not change it. That detail will matter in the failure section.
When to use it, when not to
You need | Reach for | Why |
|---|---|---|
A directory of addresses plus metadata, queried by your own code | Cloud Map | Attributes and filters, no data path in the middle |
ECS services calling each other inside one namespace | ECS Service Connect (backed by Cloud Map) | Proxy per task, metrics and retries built in |
One stable name in front of many targets with health-aware balancing | ALB or NLB | It sits in the data path and spreads load for you |
Weighted or latency routing of public names | Route 53 directly | Cloud Map's DNS side is a thin layer over it |
Full mesh with mTLS and traffic policy | VPC Lattice or App Mesh style tooling | Cloud Map has no traffic policy at all |
The honest summary: use Cloud Map directly when the consumer is your own code and you want rich lookups, or when a non-ECS workload needs to find ECS tasks. Use Service Connect when the question is only "how do my ECS services find each other". The ECS docs say Service Connect uses a Cloud Map namespace and that there is no additional charge for Cloud Map when it is used by Service Connect, but it only interconnects services in the same namespace, so anything outside ECS needs another path. That other path is often plain Cloud Map.
What it costs
Two dimensions, both on the pricing page. A registered resource costs $0.10 per month, and lookup (discovery) API calls cost $1.00 per million. The page does not mention a free tier.
DNS-based namespaces add Route 53 charges on top, billed by Route 53: $0.50 per hosted zone per month for the first 25, and $0.40 per million DNS queries for the first billion. Route 53 health checks are billed too if you use them.
The surprise is the hosted zone. An HTTP namespace has none, so a pure API-discovery setup costs the registry fee plus calls. Every DNS namespace carries a hosted zone, and teams that create a namespace per environment per team find a small recurring bill and a quota conversation. A second surprise is call volume: a fleet of clients that call DiscoverInstances on every request, instead of caching for a few seconds, turns a rounding error into a line item and, as the next section shows, into throttling.
The pricing page's own example puts 75 EC2 instances and 10 DynamoDB tables in the registry with 13.12 dollars of discovery calls for a total of 21.62 dollars per month. That is the realistic order of magnitude for a mid-size microservice estate.
The limits that bite
The quotas page lists these defaults, per Region per account:
Quota | Default | Adjustable |
|---|---|---|
Namespaces per Region | 50 | Yes |
Instances per namespace | 2,000 | Yes |
Instances per service | 1,000 | No |
Custom attributes per instance | 30 | No |
DiscoverInstances steady rate | 1,000 per second | Yes |
DiscoverInstances burst rate | 2,000 | Yes |
DiscoverInstancesRevision rate | 3,000 | Yes |
The throttling page describes a token bucket: 2,000 tokens, refilling at 1,000 per second, shared across all endpoints in the Region for the account. Exceed it and you get RequestLimitExceeded. This is the limit that bites in practice, because it is per account and per Region, not per application. One noisy client that polls in a tight loop can starve every other service in the account.
The ECS-side limits are separate and stricter. The ECS docs state that services configured for service discovery are limited to 1,000 tasks per service because of a Route 53 service quota. Also: if you have eight or fewer healthy records, Route 53 answers every DNS query with all of them, and when all records are unhealthy it answers with up to eight unhealthy ones. That second behavior is a fail-open on the DNS path, and it is not the same as the API path, where you choose.
Build it
We will build the API-discovery flavor: an HTTP namespace, a service with custom health, three instances with attributes, filtered lookups, and a health flip. No VPC, no hosted zone, no compute. It is the cheapest way to feel the data model.
Prerequisites. Python 3.9+, pip install boto3, AWS credentials for a sandbox account, and a Region (the script uses us-east-1). The IAM principal needs the following. Treat it as a starting point and tighten the resource ARNs for real use.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"servicediscovery:CreateHttpNamespace",
"servicediscovery:GetOperation",
"servicediscovery:ListNamespaces",
"servicediscovery:CreateService",
"servicediscovery:RegisterInstance",
"servicediscovery:DiscoverInstances",
"servicediscovery:UpdateInstanceCustomHealthStatus",
"servicediscovery:DeregisterInstance",
"servicediscovery:DeleteService",
"servicediscovery:DeleteNamespace",
"servicediscovery:ListServices",
"servicediscovery:ListInstances"
],
"Resource": "*"
}]
}Step 1: namespace and service. Namespace creation is asynchronous, so we poll the operation.
import time
import boto3
sd = boto3.client("servicediscovery", region_name="us-east-1")
NS_NAME = "shop-demo"
def wait_op(op_id):
while True:
op = sd.get_operation(OperationId=op_id)["Operation"]
if op["Status"] in ("SUCCESS", "FAIL"):
if op["Status"] == "FAIL":
raise RuntimeError(op.get("ErrorMessage", "operation failed"))
return op
time.sleep(2)
op = sd.create_http_namespace(Name=NS_NAME, CreatorRequestId="shop-demo-1")
done = wait_op(op["OperationId"])
ns_id = done["Targets"]["NAMESPACE"]
svc = sd.create_service(
Name="orders",
NamespaceId=ns_id,
Type="HTTP",
HealthCheckCustomConfig={"FailureThreshold": 1},
)["Service"]
svc_id = svc["Id"]
print("namespace", ns_id, "service", svc_id)Step 2: register three instances. Two are version v1, one is v2, spread across two zones. The addresses are documentation-range IPs: Cloud Map stores whatever you give it and never checks that it is reachable.
fleet = [
("orders-a", "10.0.1.10", "v1", "use1-az1"),
("orders-b", "10.0.2.11", "v1", "use1-az2"),
("orders-c", "10.0.1.12", "v2", "use1-az1"),
]
for i, (iid, ip, ver, az) in enumerate(fleet):
r = sd.register_instance(
ServiceId=svc_id,
InstanceId=iid,
CreatorRequestId=f"reg-{iid}-{i}",
Attributes={
"AWS_INSTANCE_IPV4": ip,
"AWS_INSTANCE_PORT": "8080",
"version": ver,
"az": az,
},
)
wait_op(r["OperationId"])Step 3: discover. Note the separate data-plane endpoint is handled by the SDK; you do not configure it. We query by attribute, then read the revision.
def discover(**kw):
return sd.discover_instances(
NamespaceName=NS_NAME, ServiceName="orders", **kw
)
everyone = discover()
print("all:", sorted(i["InstanceId"] for i in everyone["Instances"]),
"revision", everyone["InstancesRevision"])
v1 = discover(QueryParameters={"version": "v1"})
print("v1 only:", sorted(i["InstanceId"] for i in v1["Instances"]))
prefer = discover(OptionalParameters={"az": "use1-az2"})
print("prefer az2:", sorted(i["InstanceId"] for i in prefer["Instances"]))QueryParameters is a hard filter: only instances matching every pair come back. OptionalParameters is opportunistic: if some instances match, only those are returned, otherwise the filter is ignored and you get the unfiltered set. That makes it the right tool for "prefer my own zone, but never return nothing".
Step 4: flip health and watch the contract.
sd.update_instance_custom_health_status(
ServiceId=svc_id, InstanceId="orders-b", Status="UNHEALTHY"
)
time.sleep(2)
healthy = discover(HealthStatus="HEALTHY")
print("healthy:", sorted(i["InstanceId"] for i in healthy["Instances"]),
"revision", healthy["InstancesRevision"])
fail_open = discover(HealthStatus="HEALTHY_OR_ELSE_ALL")
print("fail-open:", len(fail_open["Instances"]))Verification. Expect the first line to list orders-a, orders-b, orders-c; the v1 filter to list orders-a and orders-b; the zone preference to return only orders-b; and after the flip the healthy list to drop orders-b while the revision number is unchanged from the first call. That last observation is the documented behavior: health changes do not bump InstancesRevision, so a client that only watches the revision will not notice a health flip. If your numbers differ, print the whole response and compare it against the DiscoverInstances reference.
Cost of following along. Three registered resources at $0.10 per month is 30 cents if they lived a full month, and they will live minutes. A dozen discovery calls cost a fraction of a cent. An HTTP namespace has no hosted zone. Realistically the whole run is a few cents at most.
The ECS path, in brief. If your workload is on ECS, the console and the API do the registering for you. You create a private DNS namespace, which creates a Route 53 private hosted zone, and point the service's serviceRegistries at a Cloud Map service. The ECS docs list the behaviors worth knowing: tasks must use the awsvpc, bridge or host network mode; with awsvpc you can create A, SRV, or both (plus AAAA on dual-stack subnets); with bridge or host only SRV records are supported; Fargate needs platform version 1.1.0 or later; and the DNS name is <service-name>.<namespace> resolvable only inside the VPC, which needs the VPC DNS attributes enabled. ECS registers the private IP of the task, even in a public namespace. The recommended health model is HealthCheckCustomConfig: tasks register as UNHEALTHY until container health checks pass, and are deregistered if they fail. This is the same custom-health mechanism you just drove by hand.
When it breaks
RequestLimitExceeded on DiscoverInstances. You exhausted the token bucket, 2,000 burst and 1,000 per second sustained, shared by the whole account in that Region. Cache results for a few seconds in every client, retry with exponential backoff and jitter, and request a quota increase only after you have cached.
NamespaceNotFound on a namespace you just created. DiscoverInstances takes the namespace's HttpName, which usually equals Name. If you reuse a name, the docs say a generated hash is added to the HttpName, so read it from the namespace's properties rather than assuming. Also remember creation is asynchronous: wait for the operation.
ResourceInUse on delete. You cannot delete a service that still has instances, and you cannot delete a namespace that still has services. Cleanup order matters.
InstanceNotFound right after registering. The API reference for custom health updates says the instance may be recently registered and not yet propagated. Wait for the registration operation to report success before updating health.
An empty answer that looks like success. If a service has no health check configured, HealthStatus is ignored and everything is returned. If it has one and every instance is unhealthy, HEALTHY returns nothing, which is correct and awkward. Decide per consumer: HEALTHY_OR_ELSE_ALL fails open on purpose, and for a read-heavy dependency that is often the right trade.
Leftovers after ECS. The ECS docs say the Cloud Map resources created for service discovery must be cleaned up manually. Deleting the ECS service leaves the namespace, service and hosted zone behind, and the hosted zone keeps billing.
Public namespaces leak names. The API reference warns not to put sensitive data in attributes, instance IDs or service names when the namespace is discoverable by public DNS. Treat everything in a public namespace as published.
Cleanup
Delete in reverse order: instances, then service, then namespace. Namespace deletion is asynchronous too.
for iid in ("orders-a", "orders-b", "orders-c"):
r = sd.deregister_instance(ServiceId=svc_id, InstanceId=iid)
wait_op(r["OperationId"])
sd.delete_service(Id=svc_id)
r = sd.delete_namespace(Id=ns_id)
wait_op(r["OperationId"])
left = [n for n in sd.list_namespaces()["Namespaces"] if n["Name"] == NS_NAME]
print("namespaces left:", left)An empty list confirms the registry is gone. Because this build used an HTTP namespace, there is no hosted zone to hunt down. If you also tried the ECS path with a private DNS namespace, check Route 53 for a leftover private hosted zone and the ECS console for services still pointing at the registry.

