In the previous four lessons we have written, without justifying it, addresses like http://catalog-service:3001, inventory-service:50051 or amqp://rabbitmq:5672. They have worked as stable names in the examples, but in a real system each of those names hides a problem: behind "catalog-service" there will be two replicas on a Tuesday morning and eight on Black Friday, each in a container with an IP that is assigned at startup and destroyed at death. Which of those IPs does Orders call when it needs GET /products?ids=? Who knows which ones are alive right now? How is the work spread among them? This lesson answers those three questions.
We will see why addresses cannot be hard-wired (the fallacies from 01-02 come back), service registration (who records that an instance exists: the instance itself or a third party), client-side versus server-side discovery, the classic tools (Consul, Eureka) with a real registration snippet including a health check, the Kubernetes-native DNS-based discovery that TechCorp will adopt, the algorithms and layers of load balancing and where it happens in each model, health checks (liveness versus readiness, with both endpoints in Express) as part of a service's contract, and TechCorp's decision: Kubernetes DNS + stable *-service names as configuration. Actually deploying to Kubernetes belongs to 05-02, the service mesh to 05-05 and resilience (what to do when the chosen instance fails) to 06-03.
Contents
- The problem: ephemeral instances with dynamic IPs
- Service registration: who records that an instance exists
- Client-side discovery
- Server-side discovery
- Comparison of the two models
- Tools: Consul and Eureka
- Kubernetes-native discovery:
Service,Endpointsand DNS - Load balancing: algorithms and layers
- Where balancing happens in each model
- Health checks: liveness versus readiness
- TechCorp's decision
- The problem: ephemeral instances with dynamic IPs
In the monolith, the web called techcorp-shop:3000 and that name pointed to a fixed machine with a fixed IP. With microservices on containers, the situation changes in three ways:
- Many instances per service. Catalog has N identical replicas and N changes with the load (the ×20 peaks from 01-05) or with a deployment (05-04).
- Ephemeral addresses. Each container gets an IP from the internal range at startup (
10.244.3.17) and that IP disappears with it. Restarting means changing address. - Partial failures. A replica may be alive but without a connection to its database, or starting up and not yet able to serve, or about to shut down.
Hard-wiring addresses (CATALOG_URL=http://10.244.3.17:3001) fails because of the fallacies of distributed computing from 01-02: "topology doesn't change" (it changes every minute), "the network is reliable" (it is not), "there is one administrator" (Kubernetes reschedules containers without asking). And a list of IPs in configuration goes stale before it is even deployed.
We need three things: a place where which instances exist and where is recorded (service registry), a mechanism for a caller to find a healthy instance at call time (discovery), and a way to spread calls across the available instances (load balancing).
- Service registration: who records that an instance exists
The registry is a database of "service → list of instances (IP, port, status)". There are two ways to fill it:
| Model | Who registers | How a crash is detected | Advantages | Drawbacks |
|---|---|---|---|---|
| Self-registration | The instance itself, at startup, calls the registry ("I am catalog-service, I am at 10.244.3.17:3001") and deregisters at shutdown |
The instance sends periodic heartbeats; if they stop arriving for a while, the registry marks it as down | Easy to understand; requires no infrastructure that "watches" the service | Every service needs registration code (coupling to the tool); if the process dies abruptly it does not deregister and you have to wait for the heartbeat timeout |
| Third-party registration | An external component (the orchestrator, an agent) watches which instances start and die and updates the registry | The third party runs health checks against the instance | The service does not know a registry exists; no infrastructure code in the business | Requires that third party to exist and be integrated with the platform |
Consul and Eureka are traditionally used in self-registration mode (with clients in the service) or with a local agent; Kubernetes is pure third-party: the orchestrator itself knows which containers exist because it created them.
- Client-side discovery
In client-side discovery, it is the caller who queries the registry, gets the list of healthy instances, picks one (applying a balancing algorithm) and calls it directly.
sequenceDiagram
participant P as orders-service
participant R as Registry (Consul/Eureka)
participant C1 as catalog 10.244.3.17 (3001)
participant C2 as catalog 10.244.5.42 (3001)
C1->>R: registration + heartbeats
C2->>R: registration + heartbeats
P->>R: instances of catalog-service?
R-->>P: [10.244.3.17:3001, 10.244.5.42:3001]
Note over P: picks one (round robin) and caches the list for a few seconds
P->>C2: GET /products?ids=p-501,p-777
C2-->>P: 200 OK
Consequences: the client needs a library that talks to the registry and balances (Netflix Ribbon with Eureka was the classic example); that library must exist for every language in the system; balancing can be very smart (the client knows its own latencies); and there is no extra network hop. But every service carries infrastructure logic inside, and updating that logic means redeploying every service.
- Server-side discovery
In server-side discovery, the caller always calls one stable address (a load balancer, a virtual DNS name, a proxy) and it is that intermediate piece that queries the registry and forwards the request to a healthy instance.
sequenceDiagram
participant P as orders-service
participant LB as Stable address (Service / load balancer)
participant R as Registry (Endpoints)
participant C1 as catalog 10.244.3.17 (3001)
participant C2 as catalog 10.244.5.42 (3001)
Note over R: the orchestrator updates the list as instances are created/destroyed
P->>LB: GET http://catalog-service:3001/products?ids=...
LB->>R: healthy instances of catalog-service
R-->>LB: [10.244.3.17, 10.244.5.42]
LB->>C1: forward
C1-->>LB: 200 OK
LB-->>P: 200 OK
Consequences: the client is trivial (a fetch to a fixed name, like the ones from 03-01); the discovery logic lives in the platform and is updated without touching services; it works the same for Node.js, Go or Java. In exchange, there is one more piece on the path (which must be highly available) and, in some cases, an extra network hop.
- Comparison of the two models
| Criterion | Client-side | Server-side |
|---|---|---|
| Who queries the registry | The calling service | An intermediary (load balancer, proxy, DNS+kube-proxy) |
| Code in the service | Discovery and balancing library | None: it calls a name |
| Polyglot | One library per language | Language-independent |
| Additional network hop | No | Sometimes (depends on the implementation) |
| Balancing intelligence | High (the client knows latencies, can do hedging) | Medium (the intermediary sees all the traffic, not each client's experience) |
| Point of failure | The registry (but the client caches) | The intermediary (must be replicated) |
| Examples | Eureka + Ribbon, Consul + client library | AWS ELB/ALB, Kubernetes Service, NGINX/Traefik, service mesh |
| Fit for TechCorp | Would add infrastructure code to six Node.js services | Kubernetes provides it out of the box |
The industry trend is clear: with orchestrators and service meshes, server-side discovery has won because it takes infrastructure out of the business code. It is consistent with "smart endpoints, dumb pipes" from 02-01: the service thinks about orders, not about IPs.
- Tools: Consul and Eureka
Although TechCorp will use the Kubernetes mechanism, it is worth knowing the two classic tools because they appear in many existing systems and because they explain where the concepts come from.
- Netflix Eureka. A REST registry created by Netflix for AWS. Instances register with
POST /eureka/apps/{app}and send a heartbeat every 30 s withPUT; if three heartbeats are missed, it is marked as down. Clients download the full registry and cache it. Very tied to the Spring Cloud (Java) ecosystem; clients exist for Node.js, but choosing it today outside Java is rare. - HashiCorp Consul. Registry + key-value store + health checks + DNS. Each node runs a local agent; services register against their agent (via HTTP API or file), and Consul runs the health checks (HTTP, TCP, script) from the agent. It exposes the registry via API and via DNS (
catalog-service.service.consulresolves to the healthy instances), which is already a form of server-side discovery. It is the usual option outside Kubernetes (virtual machines, Nomad) and remains relevant.
Snippet with the Node.js consul client registering catalog-service with an HTTP health check:
// infrastructure/consulRegistry.js (would only be used if TechCorp were NOT on Kubernetes)
const Consul = require('consul');
const os = require('node:os');
async function registerInConsul({ name = 'catalog-service', port = 3001 }) {
// 1. Client against the local agent (each node has its own on 8500)
const consul = new Consul({ host: process.env.CONSUL_HOST ?? '127.0.0.1', port: 8500 });
// 2. Unique id per instance: name + host + port. Two replicas → two ids
const instanceId = `${name}-${os.hostname()}-${port}`;
const address = process.env.INSTANCE_IP ?? getLocalIp();
// 3. Registration with health check: Consul will call GET /health/ready every 10 s;
// if it fails for 30 s in a row, the instance deregisters itself (DeregisterCriticalServiceAfter)
await consul.agent.service.register({
id: instanceId,
name,
address,
port,
tags: ['http', 'v1'],
check: {
http: `http://${address}:${port}/health/ready`,
interval: '10s',
timeout: '2s',
deregistercriticalserviceafter: '30s'
}
});
console.log(`Registered in Consul as ${instanceId}`);
// 4. Graceful deregistration on shutdown: without this, the instance shows as "critical" until the check expires
const deregister = async () => { await consul.agent.service.deregister(instanceId); process.exit(0); };
process.on('SIGTERM', deregister);
process.on('SIGINT', deregister);
}
// Client: get healthy instances and pick one (client-side discovery)
async function resolveInstance(consul, name) {
const [instances] = await consul.health.service({ service: name, passing: true }); // only the healthy ones
if (instances.length === 0) throw new Error(`No healthy instances of ${name}`);
const chosen = instances[Math.floor(Math.random() * instances.length)]; // simple random balancing
return `http://${chosen.Service.Address}:${chosen.Service.Port}`;
}Notice everything this code adds to the Catalog service that has nothing to do with products: instance ids, IPs, heartbeats, deregistrations. That is the cost of self-registration and client-side discovery. With Kubernetes, none of this exists in the service's code.
- Kubernetes-native discovery:
Service, Endpoints and DNS
Service, Endpoints and DNSKubernetes ships with registration, discovery and balancing built in. Three objects are enough to understand it (the full deployment YAML belongs to 05-02; here only the mechanism):
- Pod. The unit that runs a container (one Catalog replica). It has an ephemeral cluster IP (
10.244.3.17). - Service. An object with a stable name (
catalog-service) and a stable virtual IP (ClusterIP, e.g.10.96.12.5) that selects pods by labels (app: catalog-service). It lives on even as pods die and are born. - Endpoints (or EndpointSlice in current versions). The list, maintained automatically by Kubernetes, of the IPs of the pods the
Serviceselects and that are ready (readiness, section 10). It is the registry, filled by a third party (the orchestrator): third-party registration without writing a single line.
The flow when Orders does fetch('http://catalog-service:3001/products?ids=...'):
flowchart LR
P[orders-service pod] -- "1. resolve catalog-service" --> DNS[cluster CoreDNS]
DNS -- "2. 10.96.12.5 (ClusterIP)" --> P
P -- "3. TCP to 10.96.12.5:3001" --> KP[kube-proxy / node iptables]
KP -- "4. looks up Endpoints" --> EP[(Endpoints of catalog-service: 10.244.3.17, 10.244.5.42, ...)]
KP -- "5. rewrites destination to a pod" --> C2[catalog pod 10.244.5.42:3001]
C2 --> P
- The Orders pod resolves the name
catalog-servicein CoreDNS, the cluster's internal DNS. The full name iscatalog-service.<namespace>.svc.cluster.local, but from the same namespace the short name is enough. - CoreDNS returns the
Service's ClusterIP, which never changes as long as theServiceexists. - Orders opens a connection to that virtual IP and port 3001.
- On each node, kube-proxy maintains network rules (iptables or IPVS) that intercept traffic to the ClusterIPs.
- Those rules pick one of the pods from the Endpoints list and rewrite the destination to its real IP. If a pod stops being ready, it leaves Endpoints and stops receiving traffic within seconds.
What matters to the developer: the Orders code knows nothing about this. It calls a fixed name and port, exactly the CATALOG_URL from 03-01. Registration, health and balancing belong to the platform. It is server-side discovery and third-party registration, for free.
Snippet of the Catalog Service (just to see where the name and port come from; the Deployment that creates the pods belongs to 05-02):
apiVersion: v1
kind: Service
metadata:
name: catalog-service # → stable DNS name
spec:
selector:
app: catalog-service # pods with this label form the Endpoints
ports:
- port: 3001 # Service port (the one callers use)
targetPort: 3001 # container portA nuance for RabbitMQ and other long-lived connections: kube-proxy balancing happens when the TCP connection is established. An AMQP connection kept open for hours stays with the same pod; for HTTP with keep-alive something similar happens (all the requests on one connection go to the same pod). For HTTP it works well because connections get renewed; to truly spread per request you need layer 7 balancing (section 8), which is one of the things the service mesh from 05-05 provides.
- Load balancing: algorithms and layers
Algorithms for spreading across N instances:
| Algorithm | How it spreads | When it is appropriate | Limitation |
|---|---|---|---|
| Round robin | One to each instance, in turns | Identical instances and similar requests (the Catalog case) | Ignores whether an instance is more loaded |
| Weighted round robin | Turns proportional to a weight | Instances with different capacity; canary deployments (05-04): 95% to the old version, 5% to the new one | The weights have to be maintained |
| Least connections | To the instance with the fewest active connections | Requests of very variable duration (long reports alongside short queries) | Requires the balancer to know the active connections |
| Consistent hashing | A function of the request (IP, customer id, order id) always decides the same instance | Per-instance local caches; session affinity; spreading consumers by key | Uneven distribution if the keys are uneven; reassignments when N changes |
| Random | At random | With many instances it approximates round robin without state | Streaks |
| Latency / load | To the instance that responds fastest | Heterogeneous instances or different zones | Needs measuring; risk of a "stampede" toward the fastest one |
Layers at which balancing happens:
| Layer 4 (transport, TCP/UDP) | Layer 7 (application, HTTP) | |
|---|---|---|
| What it sees | IP and port; decides per connection | Method, path, headers, body; decides per request |
| Speed | Very high (no parsing) | High, but parses HTTP |
| Capabilities | Simple spreading, without understanding the request | Route by path (/api/orders → Orders), by header (canary), retries, per-request timeouts, TLS termination |
| Examples | kube-proxy, HAProxy in TCP mode, cloud network load balancers | NGINX, Traefik, Envoy (Istio), cloud application load balancers, the gateway from 03-04 |
kube-proxy is layer 4: it spreads connections, not requests. The gateway from 03-04 is layer 7: it routes by path and header. A service mesh puts a layer 7 proxy (Envoy) next to every pod and provides per-request balancing, retries and metrics between internal services without touching the code: that is why it appears in 05-05 as the natural evolution.
- Where balancing happens in each model
| Model | Who balances | Typical algorithms | At TechCorp |
|---|---|---|---|
| Client-side discovery | The client library (Ribbon, Consul client) | Round robin, random, by latency | No (we avoid infrastructure code) |
| Dedicated load balancer | NGINX/HAProxy/ALB in front of the instances | All; layer 4 or 7 | The external load balancer in front of the gateway 8080 replicas |
Kubernetes Service |
kube-proxy on each node | Random/round robin per connection (layer 4) | Yes: between internal services and from the gateway to the services |
| Ingress / gateway | Traefik, NGINX Ingress | Layer 7: by path, header, weight | Yes: the gateway from 03-04 as Ingress (05-02) |
| Service mesh | Envoy sidecar next to each pod | Layer 7 per request, least request, hash, with retries and mTLS | Preview: 05-05 |
For internal calls (Orders → Catalog) TechCorp starts with kube-proxy: enough for the current volume and at no cost. When it needs per-request balancing, declarative retries or per-route metrics between services, it will adopt Istio.
- Health checks: liveness versus readiness
None of the above is any use if the registry does not know which instances are healthy. A container with a live process is not the same as a service able to serve. That is why there are two different checks, and confusing them causes incidents:
| Liveness ("am I alive?") | Readiness ("am I ready to serve?") | |
|---|---|---|
| Question | Is the process working or hung/blocked beyond recovery? | Can I serve requests right now? (dependencies connected, cache loaded, not shutting down) |
| What it checks | The bare minimum: that the event loop responds | Connection to the DB, to the broker, configuration loaded, startup/shutdown state |
| If it fails | The orchestrator restarts the container | The orchestrator removes it from balancing (out of Endpoints) but does not restart it; when it passes again, it is put back |
| Should depend on third parties | No (if the DB goes down and liveness fails, Kubernetes would restart every pod in a loop, making everything worse) | Yes, precisely so as not to receive traffic it cannot serve |
| Endpoint at TechCorp | GET /health/live |
GET /health/ready |
Example of both endpoints in Express (Catalog, with MongoDB and RabbitMQ as dependencies):
// routes/health.js (pattern common to all services; packaged in @techcorp/common-http)
const express = require('express');
function createHealthRoutes({ checks = {} } = {}) {
const router = express.Router();
let shuttingDown = false;
// Liveness: if Express can run this, the process is alive. Nothing more.
router.get('/health/live', (_req, res) => {
res.status(200).json({ status: 'alive' });
});
// Readiness: each dependency is checked with a short timeout; one failing → 503
router.get('/health/ready', async (_req, res) => {
if (shuttingDown) {
// During graceful shutdown, we say "not ready" so the balancer stops sending us traffic
return res.status(503).json({ status: 'shutting down' });
}
const results = {};
let allOk = true;
for (const [name, check] of Object.entries(checks)) {
try {
await Promise.race([
check(),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 1000))
]);
results[name] = 'ok';
} catch (err) {
results[name] = `failed: ${err.message}`;
allOk = false;
}
}
res.status(allOk ? 200 : 503).json({ status: allOk ? 'ready' : 'not ready', dependencies: results });
});
// On SIGTERM, we stop being "ready" a few seconds before closing (graceful shutdown)
process.on('SIGTERM', () => { shuttingDown = true; });
return router;
}
module.exports = { createHealthRoutes };And its use in Catalog:
app.use(createHealthRoutes({
checks: {
mongodb: () => mongoClient.db().command({ ping: 1 }), // does MongoDB respond?
rabbitmq: () => Promise.resolve(amqpChannel && !amqpChannel.closed ? true : Promise.reject(new Error('channel closed')))
}
}));Response of GET /health/ready with MongoDB down:
With status code 503. Kubernetes (05-02) will call these endpoints periodically (livenessProbe, readinessProbe), Traefik already used them in the healthCheck from 03-04, and Consul in the check from section 6. That is why we say health checks are part of a service's contract: just as POST /orders responds 202, every TechCorp service responds on /health/live and /health/ready with the semantics of the table; the Platform team takes it for granted when configuring the platform. And two more nuances: /health/* does not require authentication (the infrastructure calls it) but is not exposed through the gateway; and it must not do heavy work (a SELECT 1 query, a ping), because it runs every few seconds for every replica.
- TechCorp's decision
- Discovery: Kubernetes-native. One
Serviceper microservice with a stable namecatalog-service,orders-service,payments-service,customers-service,notifications-service,inventory-service,bff-mobile, plusrabbitmqand the databases. Third-party registration (Kubernetes maintains the Endpoints), server-side discovery (DNS + kube-proxy). No service carries registration code. - Balancing: the
Service's (kube-proxy, layer 4) between internal services; layer 7 at the gateway/Ingress (Traefik) for external traffic; service mesh as the next step when needed (05-05). - Stable names as configuration: each service receives the addresses of its dependencies through environment variables whose value is the
Service's DNS name. In 04-03 we will see how they are managed (ConfigMaps,.envfiles); the contract from today is this:
| Variable | Value in the cluster | Who uses it |
|---|---|---|
CATALOG_URL |
http://catalog-service:3001 |
Orders, mobile BFF, gateway |
CUSTOMERS_URL |
http://customers-service:3004 |
Orders, gateway |
ORDERS_URL |
http://orders-service:3002 |
Mobile BFF, gateway |
INVENTORY_GRPC |
inventory-service:50051 |
Orders (if gRPC is enabled, 03-03) |
RABBITMQ_URL |
amqp://rabbitmq:5672 |
Everyone that publishes or consumes |
ORDERS_DB_URL |
postgres://...@postgres-orders:5432/orders |
Orders only (database per service, 02-04) |
- Health as a contract:
/health/liveand/health/readyin every service, with the semantics from section 10. - In local development (Docker Compose, 05-01) the same names work because Compose also provides DNS by service name: the code does not change between the laptop and the cluster, only the value of the variables if need be.
Common Mistakes and Tips
- Hard-wiring IPs in configuration "only in development". They end up in production. Names from day one.
- Caching DNS resolution forever in the client. Node.js does not cache DNS by default, but some libraries or connection pools do keep the IP; if the
Serviceis recreated with another ClusterIP, the client keeps pointing at the old one. Renew connections and do not pin IPs. - Liveness that checks the database. The DB goes down → every pod "not alive" → Kubernetes restarts them in a loop → when the DB comes back, nobody is up. Liveness only looks at the process.
- Readiness that checks nothing. The pod enters balancing before connecting to RabbitMQ and fails the first requests of every deployment.
- Not switching to "not ready" on shutdown. The pod receives requests until the last millisecond and cuts them off halfway.
SIGTERM→shuttingDown = true→ wait a few seconds → close. - Expensive health checks. A
SELECT COUNT(*) FROM ordersevery 5 s per replica is made-up load.SELECT 1orping. - Exposing
/healththrough the gateway. It is internal information and one more attack surface. Inside the cluster only. - Trusting that kube-proxy spreads per request. It spreads connections. With keep-alive, a very active client may always load the same pod. If the distribution matters, layer 7 (mesh).
- Adding a Consul client "just in case" when you are already on Kubernetes. It duplicates the registry and fills the service with infrastructure code.
Exercises
Exercise 1. The Orders service has three dependencies: PostgreSQL (orders), RabbitMQ and the Catalog service (synchronous, via CATALOG_URL). Decide, justifying each case, which ones should be part of Orders' /health/ready and which should not, and write the resulting call to createHealthRoutes. Hint: think about what would happen to the Orders replicas if Catalog went down and it were in the check.
Exercise 2. Explain step by step what happens in Kubernetes when a Catalog replica starts failing its readinessProbe because MongoDB is slow, and what the difference would be if the liveness failed instead of the readiness. Indicate at which moment Orders stops receiving errors and why the Orders code does not change in either case.
Exercise 3. The Platform team proposes that the Notifications replicas use consistent hashing by customerId so that all the emails of the same customer are sent by the same replica and thus reuse one SMTP connection per customer. Notifications consumes events from RabbitMQ (03-02), it does not receive HTTP. Reason about whether hash balancing makes sense here, which real RabbitMQ (or Kubernetes) mechanism would or would not allow it, and which alternative you would recommend.
Solutions
Solution 1.
- Orders' PostgreSQL: yes. Without its database, Orders cannot serve any request (neither create nor read orders). Better to take it out of balancing than to return
500for everything. - RabbitMQ: yes, with a nuance. Orders publishes through the outbox (02-05):
POST /orderssaves order and event in PostgreSQL and the relay publishes afterwards. Strictly, it could accept orders without RabbitMQ; but it also consumesorders.sagaand without it the saga does not advance. TechCorp includes it: an accepted order that cannot advance is a worse experience than a brief503. (Arguing the opposite is valid if accepting orders is prioritized.) - Catalog: no. It is a dependency on another service. If Catalog went down and were in the readiness, all the Orders replicas would leave balancing at once and
GET /orders/{id}(which does not need Catalog) would stop working: one failure would become two. Orders must stay ready and respond503 DEPENDENCY_UNAVAILABLEonly on the operations that need Catalog (03-01). Rule: readiness includes what this service needs to work at all, not other services (that is cascading failure, the subject of 06-03).
app.use(createHealthRoutes({
checks: {
postgres: () => pool.query('SELECT 1'),
rabbitmq: () => (amqpChannel && !amqpChannel.closed) ? Promise.resolve() : Promise.reject(new Error('channel closed'))
}
}));Solution 2.
Readiness failing: (1) the node's kubelet calls GET /health/ready every N seconds and receives 503 (the MongoDB check exceeds the 1 s timeout); (2) after failureThreshold consecutive failures (3 by default), Kubernetes marks the pod as not ready; (3) the Endpoints controller removes that pod's IP from the Endpoints of catalog-service; (4) kube-proxy updates the rules on every node and new connections go only to the ready replicas; (5) the pod stays alive, keeps trying; when MongoDB recovers and /health/ready returns 200 (successThreshold, 1 by default), it goes back into Endpoints. Orders stops seeing errors at step 4, a few seconds after the first failure, and before that it only saw errors on the fraction of requests kube-proxy sent to that pod.
Liveness failing: Kubernetes kills and restarts the container. If the problem is a slow MongoDB, the restart fixes nothing and, if the liveness checked MongoDB, every replica would restart in a loop: that is why liveness does not touch dependencies.
The Orders code does not change because it calls http://catalog-service:3001, a stable name and ClusterIP; who is behind it and whether it is ready is managed by the platform. All Orders sees is fewer errors.
Solution 3.
Hash balancing is a concept for incoming calls (HTTP/gRPC): the one deciding is a balancer that sees the request and picks the instance. Notifications does not receive calls: its replicas compete for messages from the notifications.orders queue (03-02), and RabbitMQ delivers each message to whichever replica has room in its prefetch; kube-proxy does not intervene (AMQP connections go from the replica to the broker, not the other way around) and it cannot know anything about customerId. Therefore, consistent hashing by customerId does not apply with the current topology.
Alternatives: (a) RabbitMQ has the consistent-hash exchange plugin, which spreads across several queues by hash of the routing key or of a header; you would have to create one queue per replica and lose the symmetry of "one queue per service"; complex and fragile when scaling. (b) Kafka would solve this naturally (partition by key), but changing broker for an SMTP optimization does not pay off. (c) The recommendation: do not do it. A pool of SMTP connections in each replica (reused across customers) gives the same saving without affinity, and the ordering between a customer's emails is better guaranteed by the event (which carries everything needed) than by the topology.
Conclusion
The problem was clear: ephemeral instances with dynamic IPs and a caller that needs to find a healthy one. The solution has three pieces: a registry (filled by the instance itself with heartbeats, or by a third party such as the orchestrator), a discovery mechanism (in the client, with a library, or on the server, with a stable address the platform resolves) and balancing (round robin, weighted, least connections, hash; at layer 4 per connection or at layer 7 per request). We have looked at Consul and Eureka as a reference and understood the Kubernetes-native mechanism: a Service with a stable name and IP, Endpoints as the automatic registry, CoreDNS to resolve catalog-service and kube-proxy to spread; and we have fixed the health checks (/health/live for "I am alive", /health/ready for "I can serve") as part of the contract of every TechCorp service. The decision: Kubernetes DNS, Service balancing, stable *-service names as configuration values (CATALOG_URL=http://catalog-service:3001), and zero infrastructure code in the services.
With this, the services know how to talk (REST, events, gRPC/GraphQL when the time comes), have a door (the gateway) and find each other. The last question of the module remains, and perhaps the one that prevents the most incidents in the long run: when the Catalog team changes the shape of GET /products or the Orders team adds a field to order.created, how do they do it without breaking anyone? Which changes are compatible and which are not, how a REST API, an event, a .proto or a GraphQL schema is versioned, how a version is retired and how the contract is agreed before writing code. That is the subject of the next lesson: API contracts and versioning.
Microservices Course
Module 1: Introduction to Microservices
- Basic Concepts of Microservices
- Advantages and Disadvantages of Microservices
- Comparison with the Monolithic Architecture
- When to Adopt Microservices: Decision Criteria
- The Course Case Study: TechCorp's Online Store
Module 2: Microservice Design
- Microservice Design Principles
- Decomposing Monolithic Applications
- Defining Bounded Contexts
- Data Management: One Database per Service
- Distributed Consistency: Sagas, CQRS and Event Sourcing
Module 3: Communication between Microservices
- RESTful APIs
- Asynchronous Messaging
- Communication Protocols: gRPC, GraphQL
- API Gateway and Backend for Frontend
- Service Discovery and Load Balancing
- API Contracts and Versioning
Module 4: Implementing Microservices
- Choosing Technologies and Tools
- Building a Simple Microservice
- Configuration Management
- Hands-On Integration: Consuming APIs and Publishing Events
- Testing Microservices: Unit, Integration and Contract Tests
Module 5: Deployment and Orchestration
- Containers and Docker
- Orchestration with Kubernetes
- CI/CD for Microservices
- Deployment Strategies: Rolling, Blue-Green and Canary
- Service Mesh: Istio and Linkerd
Module 6: Monitoring and Maintenance
- Monitoring and Logging
- Distributed Tracing with OpenTelemetry
- Error Handling and Recovery
- Scalability and Performance
- SLOs, Alerts and Incident Management
Module 7: Security in Microservices
- Authentication and Authorization
- Communication Security
- Security Practices
- Container and Kubernetes Security
