GKE - Part 6: Multi-Cloud Service Connectivity Between GKE and EKS

gcpgkeawsekskubernetestailscalemulticloudnetworkingplatform

GKE - Part 6: Multi-Cloud Service Connectivity Between GKE and EKS

multicloud service connecting GKE and EKS via Tailscale

I have been gradually moving more of the nonprod platform onto GKE.

Most of the work has been straightforward because many applications already communicate through normal API endpoints. But I eventually ran into one workflow that made moving only part of the stack less clean.

One application calls another through an internal Kubernetes Service URL such as http://app-b.namespace.svc.cluster.local.

That works perfectly while both workloads live in the same cluster.

The problem starts when I want to move the caller to GKE while its dependency remains in EKS.

A Kubernetes Service DNS name is cluster-local. GKE cannot resolve or reach an EKS Service just because both clusters run Kubernetes.

I could expose the EKS application publicly, but turning an internal backend service into a public endpoint only to support this move felt like the wrong direction.

Around the same time, I attended Temporal Replay and stopped by the Tailscale counter.

I was already using the Tailscale Kubernetes operator for private Ingress, but during that conversation I learned more about Tailscale Egress.

That immediately made me think about this problem.

Could a workload in GKE call a private service in EKS through the tailnet? And would the same model work in the opposite direction?

This part is about testing that private multi-cloud connection.

The problem I needed to solve

Inside one Kubernetes cluster, service-to-service communication is simple:

appA
  |
  v
Kubernetes Service
  |
  v
appB

That is exactly what I want when both applications live in the same cluster.

The awkward case is when they do not:

AWS EKS                 GCP GKE

appB  <---------------  appA

Moving appA to GKE breaks the assumption that appB is always reachable through the same Kubernetes DNS domain.

I wanted to keep appB private and still allow the GKE workload to reach it.

Why not use the public endpoint?

One option would be to point the GKE workload at the same public API endpoint used by external clients.

That works, but now an internal backend dependency relies on public DNS, public ingress, TLS, and internet-facing load balancing.

None of those are inherently bad. They are just unnecessary for this communication path.

What I wanted instead was a private service-to-service path between GKE and EKS.

Since both clusters already had the Tailscale Kubernetes operator, the tailnet was the obvious place to experiment.

Ingress plus Egress

I was already familiar with Tailscale Ingress.

I use it to expose internal Kubernetes services such as Argo CD privately through .ts.net addresses.

Ingress handles traffic coming from the tailnet into Kubernetes.

Egress handles the opposite direction: a normal Kubernetes workload can reach something that exists elsewhere on the tailnet.

Together, the path looks like:

GKE workload
     |
     v
Tailscale Egress
     |
     v
Tailnet
     |
     v
Tailscale Ingress
     |
     v
EKS workload

The useful part is that the application itself does not need to run Tailscale.

There is no Tailscale sidecar in every application pod. The Kubernetes operator manages the proxy resources and keeps the cross-cloud networking in the platform layer.

Building a disposable test

Before changing a real workload, I wanted to prove the pattern with something disposable.

I created a tiny backend application in EKS called eks-app. It only needed to return a successful HTTP response.

I also created a small test application that made repeated HTTP requests against a configured target and displayed the result in a browser.

The local EKS path stayed completely normal:

eks-test -> Kubernetes Service -> eks-app

Then I exposed eks-app privately to the tailnet.

At a high level, the important Service settings were:

type: LoadBalancer
loadBalancerClass: tailscale

with:

annotations:
  tailscale.com/hostname: eks-app

That gave the test backend a private address such as eks-app.tailnet-name.ts.net.

No public AWS load balancer, Route53 record, or public Gateway was needed for this path.

Creating the GKE side

On GKE, I created a Kubernetes Service representing the application that actually lives in EKS.

The important annotation was:

tailscale.com/tailnet-fqdn: eks-app.tailnet-name.ts.net

The Tailscale operator creates the egress proxy resources behind that Service.

From the calling application’s perspective, it can still use something as simple as http://eks-app:8080.

Underneath, the call crosses the tailnet and enters the EKS cluster through the Tailscale-managed ingress path.

That separation is what makes the design useful to me.

The application knows which service it needs.

The platform decides how that service is reached.

Testing it from a browser

I wanted the test to be easy to repeat without constantly using kubectl exec and curl.

So I exposed the test applications themselves privately through Tailscale.

Opening the EKS test page triggered requests from the test pod running in EKS. Opening the GKE test page triggered requests from the test pod running in GKE.

The browser was only a control panel. The actual backend requests originated inside Kubernetes.

That let me compare a normal same-cluster path against the GKE-to-EKS path without including my laptop’s network connection in the test.

I intentionally kept the application simple.

The goal was not to build a benchmarking platform. I only wanted to prove that the private cross-cloud service path worked.

Reversing the direction

After the EKS-hosted test worked, I reversed the setup.

This time I created gke-app in GKE, kept the local GKE call on normal Kubernetes networking, and exposed gke-app to the tailnet.

EKS then created the corresponding Tailscale egress Service.

The direction became:

EKS workload
     |
     v
Tailscale
     |
     v
GKE workload

That proved the same connectivity model worked in both directions.

For this experiment, that was enough.

I did not need to decide whether one cloud would permanently host a particular service or whether both applications would eventually end up in the same cluster.

The important part was proving that a private dependency could cross the cluster boundary without being exposed publicly.

What this solves for the GKE move

The immediate problem was not building a complete multi-cloud network.

It was making sure a workload moved to GKE could still reach a private dependency that remained in EKS.

With Tailscale Egress and Ingress working together, that dependency no longer forces me to expose the backend publicly or keep the caller in the same cluster.

The reverse test also worked, which means the connectivity itself is not tied to EKS always being the destination.

For now, that is enough.

I have a private path between GKE and EKS when an application dependency crosses the cluster boundary, while normal Kubernetes Service networking still handles traffic that stays inside one cluster.

Keeping cloud knowledge out of the application

I do not want application code deciding how to route traffic based on whether it is running in AWS or GCP.

The deployment configuration can provide the appropriate endpoint.

When the dependency is local, use the local Kubernetes Service.

When the dependency is in the other cloud, use a Kubernetes Service backed by Tailscale Egress.

The application still makes a normal HTTP request.

It does not need to understand EKS, GKE, MagicDNS, or the cross-cloud networking underneath.

That keeps the networking problem in the platform layer, where I want it.

Why not build a full AWS-to-GCP network?

A traditional multi-cloud network is still an option.

AWS Site-to-Site VPN, GCP Cloud VPN, Transit Gateway, Cloud Router, Direct Connect, or Interconnect all have a place when the requirement becomes broader.

If I eventually have many services communicating across clouds, large traffic volumes, or a need for shared private routing, I would revisit that architecture.

But my current requirement is smaller: a workload moving to GKE still needs to reach a private service in EKS.

Both clusters already have the Tailscale Kubernetes operator.

Using Tailscale Ingress and Egress gives me a small private bridge without requiring me to build the entire multi-cloud network first.

Where this leaves the GKE setup

GKE and EKS are no longer completely isolated application environments.

Selected workloads can communicate privately across the two clouds when they need to.

Local service traffic still stays inside the local Kubernetes cluster.

Public APIs still use the normal public ingress path.

Tailscale fills the gap when a private application dependency crosses from one cluster to the other.

That is the part I wanted to prove.

And in this case, the idea came from learning about a part of a tool I was already using.

I went to the Tailscale counter at Temporal Replay thinking mostly about Ingress.

I came away thinking about Egress.