Add VMs to the mesh
Onboard workloads that run in a virtual machine to your ambient mesh.
About
As you build your ambient mesh, you might want to add a workload that runs on an external machine to your cluster environment. For example, you might run an app or service in a virtual machine (VM) that must communicate with services in the Istio ambient mesh that runs in your Kubernetes cluster.
To extend the mesh to include workloads running on VMs, you generate a bootstrap token and deploy a ztunnel instance on the VM that uses that token to onboard to your mesh. For VMs with multiple workloads, use the istioctl vm add-workload command to automatically create the necessary Kubernetes resources and per-workload identities. For single-workload VMs, you can use istioctl bootstrap to generate the token manually. Once onboarded, workloads on your VM can communicate bidirectionally with in-mesh services in your cluster via the ztunnel.
Before you begin
Set up an ambient mesh in a single or multicluster setup.
If you have not yet set up an ambient mesh, be sure to include theREQUIRE_3P_TOKEN=“false”environment variable in istiod when you follow either of these guides to install an ambient mesh. For details, see the first step in the next section.Deploy the
bookinfosample app.If you have not already, get the Solo distribution of Istio binary and install
istioctl, which you use for the bootstrap command in this guide.Save the Solo distribution of Istio version that you installed.
- Istio 1.29 and later:
export ISTIO_VERSION=1.30.1 export ISTIO_IMAGE=${ISTIO_VERSION}-solo export REPO=us-docker.pkg.dev/soloio-img/istio export HELM_REPO=us-docker.pkg.dev/soloio-img/istio-helm - Istio 1.28 and earlier:
Save the repo key for the minor version of the Solo distribution of Istio. This value is the 12-character hash at the end of the repo URL
us-docker.pkg.dev/gloo-mesh/istio-<repo-key>, which you can find in the Istio images built by Solo.io support article.export ISTIO_VERSION=1.30.1 export ISTIO_IMAGE=${ISTIO_VERSION}-solo # 12-character hash at the end of the repo URL export REPO_KEY=<repo_key> export REPO=us-docker.pkg.dev/gloo-mesh/istio-${REPO_KEY} export HELM_REPO=us-docker.pkg.dev/gloo-mesh/istio-helm-${REPO_KEY}
- Istio 1.29 and later:
Download the Solo distribution of Istio binary and install
istioctl. This script automatically detects your OS and architecture, downloads the appropriate Solo distribution of Istio binary, and verifies the installation.bash <(curl -sSfL https://raw.githubusercontent.com/solo-io/doc-examples/main/istio/install-istioctl.sh) export PATH=${HOME}/.istioctl/bin:${PATH}
If you haven’t already, create an east-west gateway to facilitate traffic between the VM, istiod, and other workloads within the mesh.
Create an east-west gateway.
- Solo distribution of
istioctl: For more information about this command, see the CLI reference.kubectl create namespace istio-eastwest istioctl multicluster expose --namespace istio-eastwest --generate > ew-gateway.yaml kubectl apply -f ew-gateway.yaml - Helm: For more information about the
peeringchart, see the Helm values reference. For recommendations on customizing the east-west gateway for resiliency and availability with the Helm chart, see the best practices for multicluster peering.helm upgrade -i peering-eastwest oci://${HELM_REPO}/peering \ --version ${ISTIO_IMAGE} \ --namespace istio-eastwest \ --create-namespace \ -f - <<EOF eastwest: create: true cluster: ${CLUSTER_NAME} # The network that the istio-system namespace is labeled with. # In prod environments, network and cluster are likely not the same value. network: ${CLUSTER_NAME} deployment: {} EOF
- Solo distribution of
Verify that the east-west gateway is successfully deployed.
kubectl get pods -n istio-eastwestExample output:
NAME READY STATUS RESTARTS AGE istio-eastwest-5d4f757664-6hw7b 1/1 Running 0 9s
Install
dockeron the VM, which you will use to run to run ztunnel as a container alongside the application.Choose the steps that match your deployment scenario:
- Multiple workloads (recommended): The VM runs one or more applications that each get their own mesh identity. Note that this approach uses
istioctl vm add-workloadand is the recommended configuration even for single-workload VMs, because it is more flexible as deployment requirements change. - Single workload (legacy): A limited, manual approach using
istioctl bootstrapfor VMs that run a single application.
- Multiple workloads (recommended): The VM runs one or more applications that each get their own mesh identity. Note that this approach uses
Multi-workload VM (recommended)
This is the recommended approach for onboarding VMs to the ambient mesh, even if the VM currently runs only a single application. Each workload on the VM gets its own SPIFFE identity, certificates, and authorization policy enforcement, so that you can add workloads later without re-onboarding the VM. This method also gives you the flexibility to target policies to specific workload identities.
Ztunnel runs on the VM in a shared proxy mode. A gateway WorkloadEntry represents the VM itself, and each application gets its own WorkloadEntry with a per-workload identity. Only a single HBONE port (15008) is required on the host.
This feature is available in Solo Enterprise for Istio version 1.30 and later.
Before you begin: Complete the Before you begin prerequisites.
Onboard the VM and its workloads
Save the VM namespace, IP address, and hostname as environment variables. The IP address can be a private or public IP, but it must be reachable by other mesh workloads.
export VM_NAMESPACE=vm-apps export VM_IP=<VM_IP_address> export VM_HOSTNAME=$(ssh <vm-user>@${VM_IP} hostname)Create the namespace and label it for ambient mesh inclusion.
kubectl create namespace ${VM_NAMESPACE} kubectl label namespace ${VM_NAMESPACE} istio.io/dataplane-mode=ambientAdd the workloads and start ztunnel using one of the following approaches.
Run
istioctl vm add-workloadfor each workload on the VM. The first invocation creates the gateway WorkloadEntry and ServiceAccount and outputs the bootstrap token needed to start ztunnel. Subsequent invocations add resources for additional workloads without regenerating the token.Add the first workload. Use
--output-dirto write the per-workload token to a file. For more information about this command, runistioctl vm add-workload --helpor see the CLI reference.istioctl vm add-workload app1 \ --external \ --address ${VM_IP} \ --namespace ${VM_NAMESPACE} \ --ports http:80:8080 \ --hostname ${VM_HOSTNAME} \ --output-dir ./vm-tokensExample output:
Created namespace "vm-apps" Configured service account vm-apps/app1 Created WorkloadEntry vm-apps/vm-app1 Configured gateway service account vm-apps/vm-gateway Created gateway WorkloadEntry vm-apps/vm-<hostname>-gateway Start ztunnel on the VM with: BOOTSTRAP_TOKEN=<generated_token> ztunnelSave the
BOOTSTRAP_TOKENvalue from the output, which is needed in later steps to start ztunnel on the VM.export BOOTSTRAP_TOKEN=<generated_token>Add the second workload. The gateway already exists from the previous command, so no new bootstrap token is generated.
istioctl vm add-workload app2 \ --address ${VM_IP} \ --namespace ${VM_NAMESPACE} \ --ports http:80:9090 \ --output-dir ./vm-tokensDistribute the workload tokens to the VM. Each token must exist at the path
/etc/ztunnel/tokens/<namespace>/<workload-name>/token.ssh <vm-user>@${VM_IP} "sudo mkdir -p /etc/ztunnel/tokens/${VM_NAMESPACE}/app1 /etc/ztunnel/tokens/${VM_NAMESPACE}/app2" scp ./vm-tokens/app1.token <vm-user>@${VM_IP}:/etc/ztunnel/tokens/${VM_NAMESPACE}/app1/token scp ./vm-tokens/app2.token <vm-user>@${VM_IP}:/etc/ztunnel/tokens/${VM_NAMESPACE}/app2/tokenSSH into the VM and start ztunnel by using the bootstrap token from the output of step 3a. This command mounts the per-workload token directory so ztunnel can discover each workload’s identity.
docker run -d \ --name ztunnel \ --network host \ -e BOOTSTRAP_TOKEN="${BOOTSTRAP_TOKEN}" \ -v /etc/ztunnel:/etc/ztunnel:ro \ us-docker.pkg.dev/soloio-img/istio/ztunnel:1.30.1-solo
Alternatively, if you installed the ztunnel binary directly on the VM, you can start it withBOOTSTRAP_TOKEN=${BOOTSTRAP_TOKEN} ztunnel.Save a unique identifier for the VM. This value must be unique within the network and must match the
NODE_NAMEyou set when you start ztunnel.export VM_NODE_NAME=<unique-vm-identifier>Create a ServiceAccount for the gateway identity and a gateway WorkloadEntry.
kubectl create serviceaccount vm-gateway -n ${VM_NAMESPACE}kubectl apply -n ${VM_NAMESPACE} -f - << EOF apiVersion: networking.istio.io/v1 kind: WorkloadEntry metadata: name: vm-gateway namespace: ${VM_NAMESPACE} spec: address: ${VM_IP} serviceAccount: vm-gateway EOFCreate a WorkloadEntry, Service, and ServiceAccount for each workload. Each WorkloadEntry uses two annotations that tell istiod how to route traffic to it:
solo.io/network-gateway: References the gateway WorkloadEntry. Istiod routes all ztunnel traffic through the VM gateway instead of connecting directly.solo.io/node: A unique identifier for the VM, matching theNODE_NAMEset when starting ztunnel. Istiod uses this to provision per-workload certificate managers and inbound listeners.
kubectl apply -n ${VM_NAMESPACE} -f - << EOF apiVersion: networking.istio.io/v1 kind: WorkloadEntry metadata: name: vm-app1 namespace: ${VM_NAMESPACE} annotations: solo.io/network-gateway: vm-gateway solo.io/node: ${VM_NODE_NAME} labels: app: app1 spec: address: 127.0.0.1 ports: http: 8080 serviceAccount: vm-app1 --- apiVersion: v1 kind: Service metadata: name: app1 namespace: ${VM_NAMESPACE} spec: ports: - name: http port: 80 targetPort: 8080 selector: app: app1 --- apiVersion: networking.istio.io/v1 kind: WorkloadEntry metadata: name: vm-app2 namespace: ${VM_NAMESPACE} annotations: solo.io/network-gateway: vm-gateway solo.io/node: ${VM_NODE_NAME} labels: app: app2 spec: address: 127.0.0.1 ports: http: 9090 serviceAccount: vm-app2 --- apiVersion: v1 kind: Service metadata: name: app2 namespace: ${VM_NAMESPACE} spec: ports: - name: http port: 80 targetPort: 9090 selector: app: app2 EOFkubectl create serviceaccount vm-app1 -n ${VM_NAMESPACE} kubectl create serviceaccount vm-app2 -n ${VM_NAMESPACE}Generate a ServiceAccount token for each workload and copy it to the VM at the path
/etc/ztunnel/tokens/<namespace>/<workload-name>/token. Note thatkubectl create tokengenerates short-lived tokens. For production environments, use a longer token expiry or a token rotation process.kubectl create token vm-app1 -n ${VM_NAMESPACE} > /tmp/vm-app1-token ssh <vm-user>@${VM_IP} "sudo mkdir -p /etc/ztunnel/tokens/${VM_NAMESPACE}/vm-app1" scp /tmp/vm-app1-token <vm-user>@${VM_IP}:/etc/ztunnel/tokens/${VM_NAMESPACE}/vm-app1/token kubectl create token vm-app2 -n ${VM_NAMESPACE} > /tmp/vm-app2-token ssh <vm-user>@${VM_IP} "sudo mkdir -p /etc/ztunnel/tokens/${VM_NAMESPACE}/vm-app2" scp /tmp/vm-app2-token <vm-user>@${VM_IP}:/etc/ztunnel/tokens/${VM_NAMESPACE}/vm-app2/tokenGenerate a bootstrap token for the VM gateway identity.
istioctl bootstrap --namespace ${VM_NAMESPACE} --service-account vm-gatewaySSH into the VM and start ztunnel with
PROXY_MODE=shared_vmand theNODE_NAMEthat matches thesolo.io/nodeannotation on your WorkloadEntries. This command mounts the per-workload token directory so ztunnel can discover each workload’s identity.docker run -d \ --name ztunnel \ --network host \ -e BOOTSTRAP_TOKEN="${BOOTSTRAP_TOKEN}" \ -e PROXY_MODE=shared_vm \ -e NODE_NAME=${VM_NODE_NAME} \ -v /etc/ztunnel:/etc/ztunnel:ro \ us-docker.pkg.dev/soloio-img/istio/ztunnel:1.30.1-solo
Alternatively, if you installed the ztunnel binary directly on the VM, you can start it withBOOTSTRAP_TOKEN=${BOOTSTRAP_TOKEN} PROXY_MODE=shared_vm NODE_NAME=${VM_NODE_NAME} ztunnel.Ztunnel automatically discovers its local workloads from XDS. When istiod pushes workload resources matching
solo.io/node: ${VM_NODE_NAME}, ztunnel dynamically provisions a certificate manager, an inbound listener, and a SOCKS5 username mapping for each workload. No restart is required when workloads are added or removed.
Route traffic to the VM workloads
The WorkloadEntries and Services created in Step 1 enable other mesh workloads to reach the VM by using a standard Kubernetes service hostname. Verify that all resources exist before testing connectivity.
Verify that the WorkloadEntries and Services were created in the VM namespace.
kubectl get workloadentries -n ${VM_NAMESPACE} kubectl get svc -n ${VM_NAMESPACE}Example output:
NAME AGE vm-app1 30s vm-app2 30s vm-<hostname>-gateway 30s NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE app1 ClusterIP 10.96.1.10 <none> 80/TCP 30s app2 ClusterIP 10.96.1.11 <none> 80/TCP 30sNote the in-mesh hostnames for each workload. After the resources are created, pods in the ambient mesh can reach each VM workload at
<workload-name>.${VM_NAMESPACE}.svc.cluster.local. For example:app1.${VM_NAMESPACE}.svc.cluster.localapp2.${VM_NAMESPACE}.svc.cluster.local
To test outbound connectivity from the VM to in-mesh services, SSH into the VM and use the SOCKS5 proxy to send requests as each workload’s identity. A
200 OKresponse indicates that the VM workload has successfully joined the mesh and can communicate with in-mesh services.ALL_PROXY=socks5h://app1.${VM_NAMESPACE}:pass@127.0.0.1:15080 curl productpage.bookinfo:9080 ALL_PROXY=socks5h://app2.${VM_NAMESPACE}:pass@127.0.0.1:15080 curl productpage.bookinfo:9080Test inbound connectivity from Kubernetes pods to the VM workloads.
From your cluster, open an interactive shell in a temporary client pod.
kubectl run client -n ${VM_NAMESPACE} --image=curlimages/curl --rm -it --restart=Never -- shFrom inside the client pod, send requests to each VM workload using its in-mesh hostname.
curl app1.${VM_NAMESPACE} curl app2.${VM_NAMESPACE}Successful responses from both workloads confirm that inbound traffic from the mesh reaches each workload independently through the VM gateway.
Single-workload VM (legacy)
This method is a limited, manual approach for VMs that run a single application. It uses istioctl bootstrap and requires you to create the Kubernetes routing resources yourself. For a more flexible approach that supports adding workloads later, see Multi-workload VM.
Before you begin: Complete the Before you begin prerequisites.
Onboard the VM and its workload
If you haven’t already, update your istiod installation to add the
REQUIRE_3P_TOKEN="false"environment variable on istiod, which is required for the ztunnel that you deploy to the VM in later steps to connect to istiod. In a multicluster mesh setup, enable this environment variable on the istiod installation in the cluster you want to connect the VM to.Create the following
gloo-extensions-configconfigmap.kubectl apply -n gloo-mesh -f -<<EOF apiVersion: v1 kind: ConfigMap metadata: name: gloo-extensions-config namespace: gloo-mesh data: values.istiod: | env: REQUIRE_3P_TOKEN: false EOFGet the current values for the istiod Helm release and upgrade it with the
--set env.REQUIRE_3P_TOKEN="false"setting.helm get values istiod -n istio-system -o yaml > istiod.yaml helm upgrade istiod oci://${HELM_REPO}/istiod \ -n istio-system \ -f istiod.yaml \ --set env.REQUIRE_3P_TOKEN="false"Save the VM name, namespace, service account, and IP address. The IP address can be a private or public IP, but it must be reachable by other mesh workloads. The name, namespace, and service account can be named whatever you prefer. The service account represents the VM in the cluster so that Istio control plane manages it the same way as any other pod in the mesh. If you later want to apply Istio resources to your VM workload, you can use this service account and namespace in the configuration.
export VM_NAME=vm-example export VM_NAMESPACE=vm-ns export VM_SERVICE_ACCOUNT=vm-sa export VM_IP=<VM_IP_address>In your cluster, generate an Istio bootstrap configuration.
- This command creates a bootstrap token that includes the necessary certificates and metadata for the VM to join the ambient mesh. ztunnel will use this token to authenticate with istio.
- For more information about this command, run
istioctl bootstrap --helpor see the CLI reference.
kubectl create namespace ${VM_NAMESPACE} kubectl label namespace ${VM_NAMESPACE} istio.io/dataplane-mode=ambient kubectl --namespace ${VM_NAMESPACE} create serviceaccount ${VM_SERVICE_ACCOUNT} istioctl bootstrap --namespace ${VM_NAMESPACE} --service-account ${VM_SERVICE_ACCOUNT}SSH into the VM to save the bootstrap token that you generated as an environment variable.
export BOOTSTRAP_TOKEN=<generated_token>Start a ztunnel instance on the VM. Ztunnel is a lightweight data plane component that enables the VM to participate in the ambient mesh. This command pulls the ztunnel container image and starts it with the necessary configuration to connect to the mesh.
docker run -d -e BOOTSTRAP_TOKEN=${BOOTSTRAP_TOKEN} --network=host us-docker.pkg.dev/soloio-img/istio/ztunnel:1.30.1-soloTest connectivity from the VM to services in the mesh, such as to the
productpageservice in thebookinfonamespace. For example, the following curl commands test connectivity by using productpage’s Kubernetes DNS name and mesh-internal DNS name. Two200 OKresponses indicate that the VM has successfully joined the mesh and can communicate with other in-mesh services.export ALL_PROXY=socks5h://127.0.0.1:15080 curl productpage.bookinfo:9080 curl productpage.bookinfo.mesh.internal:9080
Route traffic to the VM
To route traffic from other mesh workloads to the VM, create a ServiceEntry and WorkloadEntry to represent the VM within the mesh.
Save the following routing details in environment variables.
# The port that clients in the mesh route to export CLIENT_PORT=80 # The port ztunnel proxies inbound requests to on the VM export APPLICATION_PORT=8080 # The Istio network name export NETWORK=$(kubectl get namespace istio-system -o jsonpath='{.metadata.labels.topology\.istio\.io\/network}')Create a ServiceEntry and WorkloadEntry to represent the VM within the mesh. After you apply these resources, the ambient mesh now routes traffic from Kubernetes pods to the VM via the address
vm-example.vm-ns.svc.cluster.local.kubectl apply -n ${VM_NAMESPACE} -f - << EOF apiVersion: networking.istio.io/v1beta1 kind: ServiceEntry metadata: name: ${VM_NAME} namespace: ${VM_NAMESPACE} spec: hosts: - ${VM_NAME}.${VM_NAMESPACE}.svc.cluster.local ports: - number: ${CLIENT_PORT} name: http protocol: HTTP targetPort: ${APPLICATION_PORT} resolution: STATIC workloadSelector: labels: app: ${VM_NAME} --- apiVersion: networking.istio.io/v1beta1 kind: WorkloadEntry metadata: name: ${VM_NAME} namespace: ${VM_NAMESPACE} labels: app: ${VM_NAME} annotations: ambient.istio.io/redirection: enabled spec: address: ${VM_IP} serviceAccount: ${VM_SERVICE_ACCOUNT} network: ${NETWORK} EOF
Traffic flows and limitations
Outbound traffic from the VM
Applications on the VM use the SOCKS5 proxy at port 15080 for outbound requests. To send traffic as a specific workload identity, set the SOCKS5 username to <workload-name>.<namespace>, where the workload name matches the ServiceAccount name.
# Send a request as app1's identity
ALL_PROXY=socks5h://app1.${VM_NAMESPACE}:pass@127.0.0.1:15080 curl http://<service>.<namespace>
# Send a request as app2's identity
ALL_PROXY=socks5h://app2.${VM_NAMESPACE}:pass@127.0.0.1:15080 curl http://<service>.<namespace>If no username is provided, ztunnel uses the VM gateway identity for outbound connections.
Inbound traffic to the VM
Kubernetes pods reaching a VM workload go through a double-HBONE flow:
- The client ztunnel sees the
solo.io/network-gatewayannotation and establishes double-HBONE to the VM gateway at port 15008. - The VM gateway ztunnel terminates the outer HBONE layer.
- The gateway routes the inner HBONE to the workload’s dedicated inbound listener (assigned sequentially from port 15100).
- The workload-specific inbound listener terminates the inner HBONE with the workload’s identity.
- Plain TCP reaches the application on localhost.
Authorization policies target the workload’s ServiceAccount identity (spiffe://cluster.local/ns/${VM_NAMESPACE}/sa/<workload-name>) and work the same as for any in-cluster pod.
Per-workload authorization
Because each workload on a multi-workload VM has its own ServiceAccount, you can write authorization policies that target a specific workload’s SPIFFE identity without affecting other workloads on the same instance.
By identity (principal): Target a workload by its SPIFFE identity. This is useful for controlling which workloads can reach a destination, regardless of where they run.
kubectl apply -f - <<EOF
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: httpbin-deny-app2
namespace: ${VM_NAMESPACE}
spec:
selector:
matchLabels:
run: httpbin
action: DENY
rules:
- from:
- source:
principals: ["cluster.local/ns/${VM_NAMESPACE}/sa/app2"]
EOFThis policy denies app2 from reaching httpbin while leaving app1 (and any other identity) unaffected.
By workload label: The istioctl vm add-workload command applies the vm.solo.io/workload label to each WorkloadEntry it creates, using the workload name as the value. You can use this label to target inbound traffic to a specific workload on the VM.
kubectl apply -f - <<EOF
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: deny-inbound-app1
namespace: ${VM_NAMESPACE}
spec:
selector:
matchLabels:
vm.solo.io/workload: app1
action: DENY
rules:
- from:
- source:
namespaces: ["${VM_NAMESPACE}"]
EOFThis policy denies inbound traffic to app1 while leaving other workloads on the same VM unaffected.
Limitations
- Applications must support SOCKS5 for outbound traffic (
ALL_PROXYenvironment variable). - Per-workload ServiceAccount tokens must be distributed to the VM manually and rotated before expiry.
- Transparent traffic interception is not supported.
- Multi-workload VM support does not apply to EC2-backed ECS clusters. This support is planned for a future release.
Cleanup
Stop and remove ztunnel on the VM.
docker stop ztunnel && docker rm ztunnelRemove the VM namespace and all resources within it from your cluster.
kubectl delete namespace ${VM_NAMESPACE}If you created an east-west gateway for this guide, you can optionally remove it.
kubectl delete namespace istio-eastwest