<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html" version="2.0">
  <!-- Source: https://medium.com/feed/@gayan.dissanayake -->
  <channel>
    <title><![CDATA[Stories by Gayan Dissanayake on Medium]]></title>
    <description><![CDATA[Stories by Gayan Dissanayake on Medium]]></description>
    <link>https://siftrss.com/f/9gnDlBdyw5</link>
    <image>
      <url>https://cdn-images-1.medium.com/fit/c/150/150/0*2DrhR4aq5368TthM</url>
      <title>Stories by Gayan Dissanayake on Medium</title>
      <link>https://medium.com/@gayan.dissanayake?source=rss-5016ec278afa------2</link>
    </image>
    <generator>Medium</generator>
    <lastBuildDate>Thu, 30 Jul 2026 20:40:47 GMT</lastBuildDate>
    <atom:link href="https://siftrss.com/f/9gnDlBdyw5" rel="self" type="application/rss+xml"/>
    <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
    <atom:link href="http://medium.superfeedr.com" rel="hub"/>
    <item>
      <title><![CDATA[Deploying MySQL Router on OpenShift with a 3-Node MySQL InnoDB Cluster on OpenShift Virtualization]]></title>
      <link>https://medium.com/@gayan.dissanayake/deploying-mysql-router-on-openshift-with-a-3-node-mysql-innodb-cluster-on-openshift-virtualization-52ee626a8b74?source=rss-5016ec278afa------2</link>
      <guid isPermaLink="false">https://medium.com/p/52ee626a8b74</guid>
      <dc:creator><![CDATA[Gayan Dissanayake]]></dc:creator>
      <pubDate>Fri, 06 Mar 2026 08:10:50 GMT</pubDate>
      <atom:updated>2026-03-06T08:10:50.690Z</atom:updated>
      <content:encoded><![CDATA[<h3>Bridging containerized workloads with VM-hosted databases using OVN-Kubernetes and MySQL Router</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*snFUgDOA4K61idAI4sz_-w.png" /></figure><h3>Introduction</h3><p>Running stateful database workloads on Kubernetes has always been a challenge. Many organizations running OpenShift choose to host their MySQL databases inside virtual machines managed by <strong>OpenShift Virtualization</strong> (formerly KubeVirt) — benefiting from the operational familiarity of VMs while staying within the Kubernetes ecosystem.</p><p>However, your application pods still need a reliable, topology-aware way to connect to that MySQL cluster. That’s where <strong>MySQL Router</strong> comes in.</p><p>In this article, we’ll walk through how to deploy MySQL Router as a Kubernetes Deployment on OpenShift, connecting it to a 3-node MySQL InnoDB Cluster (Group Replication) running on OpenShift Virtualization VMs. We&#39;ll also cover how to use a NetworkAttachmentDefinition with OVN-Kubernetes to give MySQL Router a dedicated L2 overlay network interface for reaching the database VMs — and how to validate everything is working correctly.</p><h3>Architecture Overview</h3><pre>┌─────────────────────────────────────────────────────┐<br>│              OpenShift Cluster                      │<br>│                                                     │<br>│  ┌──────────────────────┐                           │<br>│  │   App Pods           │                           │<br>│  │  (connects via SVC)  │                           │<br>│  └──────────┬───────────┘                           │<br>│             │ ClusterIP Service                     │<br>│             │ :6446 (RW) / :6447 (RO)               │<br>│  ┌──────────▼───────────┐                           │<br>│  │   MySQL Router Pod   │◄── L2 Overlay Network     │<br>│  │   (Deployment)       │    (OVN-Kubernetes)       │<br>│  └──────────┬───────────┘                           │<br>│             │                                       │<br>│  ┌──────────▼────────────────────────────────────┐  │<br>│  │         OpenShift Virtualization              │  │<br>│  │  ┌──────────┐ ┌──────────┐ ┌──────────┐       │  │<br>│  │  │ dbnodea  │ │ dbnodeb  │ │ dbnodec  │       │  │<br>│  │  │ PRIMARY  │ │SECONDARY │ │SECONDARY │       │  │<br>│  │  │ :3306    │ │ :3306    │ │ :3306    │       │  │<br>│  │  └──────────┘ └──────────┘ └──────────┘       │  │<br>│  └───────────────────────────────────────────────┘  │<br>└─────────────────────────────────────────────────────┘</pre><p>The key components are:</p><ul><li><strong>MySQL InnoDB Cluster</strong> — a 3-node Group Replication cluster running inside OpenShift Virtualization VMs</li><li><strong>MySQL Router</strong> — deployed as a Kubernetes Deployment with an init container that bootstraps the router configuration automatically</li><li><strong>OVN-Kubernetes L2 Overlay</strong> — a NetworkAttachmentDefinition that gives the router pod a secondary network interface on the same L2 segment as the database VMs</li><li><strong>Kubernetes Service</strong> — a ClusterIP service that exposes the router&#39;s RW/RO ports to application pods</li></ul><h3>Prerequisites</h3><p>Before diving in, make sure you have:</p><ul><li>An OpenShift cluster with the <strong>OpenShift Virtualization</strong> operator installed</li><li>A working <strong>3-node MySQL InnoDB Cluster</strong> (Group Replication) running on VMs managed by OpenShift Virtualization</li><li>The <strong>Multus CNI</strong> plugin enabled (it ships with OpenShift by default)</li><li>A NetworkAttachmentDefinition configured for L2 overlay networking (covered below)</li><li>A mysql-router-secret Kubernetes Secret containing the MySQL Router bootstrap password</li></ul><h3>Step 1: Setting Up the L2 Overlay Network</h3><p>MySQL Router needs to reach the database VMs on their internal IPs. The cleanest way to achieve this on OpenShift with OVN-Kubernetes is to create a <strong>Layer 2 overlay network</strong> using a NetworkAttachmentDefinition.</p><p>yaml</p><pre>apiVersion: k8s.cni.cncf.io/v1<br>kind: NetworkAttachmentDefinition<br>metadata:<br>  name: abc-production-l2-overlay-nad<br>  namespace: abc-system-production<br>spec:<br>  config: |-<br>    {<br>        &quot;cniVersion&quot;: &quot;0.3.1&quot;,<br>        &quot;name&quot;: &quot;abc-production-l2-overlay-nad&quot;,<br>        &quot;type&quot;: &quot;ovn-k8s-cni-overlay&quot;,<br>        &quot;netAttachDefName&quot;: &quot;abc-system-production/abc-production-l2-overlay-nad&quot;,<br>        &quot;topology&quot;: &quot;layer2&quot;,<br>        &quot;subnets&quot;: &quot;192.168.100.0/24&quot;,<br>        &quot;excludeSubnets&quot;: &quot;192.168.100.10/32,192.168.100.11/32,192.168.100.12/32&quot;<br>    }</pre><p>The excludeSubnets field is important — it prevents OVN from auto-assigning IPs that are already in use by your MySQL VM nodes. With this configuration in place, you no longer need to hard-code a specific IP in the pod annotation. OVN will automatically assign a free IP from the subnet. The pod annotation becomes simply:</p><p>yaml</p><pre>annotations:<br>  k8s.v1.cni.cncf.io/networks: |<br>    [<br>      {<br>        &quot;name&quot;: &quot;abc-production-l2-overlay-nad&quot;,<br>        &quot;namespace&quot;: &quot;abc-system-production&quot;<br>      }<br>    ]</pre><p>This is much cleaner than pinning a specific IP, which would cause pod scheduling conflicts if the pod is rescheduled.</p><h3>Step 2: Creating the Secret</h3><p>MySQL Router needs credentials to bootstrap against the MySQL cluster. Store the password in a Kubernetes Secret:</p><p>yaml</p><pre>apiVersion: v1<br>kind: Secret<br>metadata:<br>  name: mysql-router-secret<br>  namespace: abc-system-production<br>type: Opaque<br>data:<br>  router-password: &lt;base64-encoded-password&gt;</pre><p>Generate the base64 value with:</p><p>bash</p><pre>echo -n &#39;your_password_here&#39; | base64</pre><h3>Step 3: Deploying MySQL Router</h3><p>The Deployment has two key parts: an <strong>init container</strong> that bootstraps the router configuration, and a <strong>main container</strong> that runs the router itself. Both share an emptyDir volume mounted at /etc/mysqlrouter.</p><h4>Why an Init Container?</h4><p>MySQL Router’s bootstrap process (mysqlrouter --bootstrap) connects to the cluster, queries the metadata, and writes a complete mysqlrouter.conf including all member IPs. This cannot be done at image build time because the cluster topology is dynamic. The init container solves this elegantly — it runs once, writes the config to the shared volume, and exits. The main container then starts the router using that generated config.</p><h4>URL-Encoding the Password</h4><p>A subtle but important detail: MySQL Router’s bootstrap command embeds credentials in a connection URI. If your password contains special characters (like @, #, or /), the connection string will break unless the password is URL-encoded. The init container script handles this with a pure-bash urlencode() function before passing the credentials to mysqlrouter --bootstrap.</p><p>Here is the complete Deployment:</p><p>yaml</p><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: mysqlrouter-dep<br>  namespace: abc-system-production<br>  labels:<br>    app: mysqlrouter<br>spec:<br>  replicas: 1<br>  selector:<br>    matchLabels:<br>      app: mysqlrouter<br>  strategy:<br>    type: RollingUpdate<br>    rollingUpdate:<br>      maxUnavailable: 25%<br>      maxSurge: 25%<br>  template:<br>    metadata:<br>      labels:<br>        app: mysqlrouter<br>      annotations:<br>        k8s.v1.cni.cncf.io/networks: |<br>          [<br>            {<br>              &quot;name&quot;: &quot;abc-production-l2-overlay-nad&quot;,<br>              &quot;namespace&quot;: &quot;abc-system-production&quot;<br>            }<br>          ]<br>    spec:<br>      restartPolicy: Always<br>      terminationGracePeriodSeconds: 30<br>      securityContext:<br>        seLinuxOptions:<br>          level: &#39;s0:c37,c14&#39;<br>        fsGroup: 1001360000<br>        seccompProfile:<br>          type: RuntimeDefault<br>      tolerations:<br>        - key: node.kubernetes.io/not-ready<br>          operator: Exists<br>          effect: NoExecute<br>          tolerationSeconds: 300<br>        - key: node.kubernetes.io/unreachable<br>          operator: Exists<br>          effect: NoExecute<br>          tolerationSeconds: 300<br>      volumes:<br>        - name: mysqlrouter-config<br>          emptyDir: {}<br>      initContainers:<br>        - name: mysqlrouter-bootstrap<br>          image: mysql/mysql-router:8.0<br>          imagePullPolicy: IfNotPresent<br>          command:<br>            - /bin/bash<br>            - &#39;-c&#39;<br>            - |<br>              set -e<br>              urlencode() {<br>                local string=&quot;${1}&quot;<br>                local encoded=&quot;&quot;<br>                local pos c o<br>                for (( pos=0; pos&lt;${#string}; pos++ )); do<br>                  c=${string:$pos:1}<br>                  case &quot;$c&quot; in<br>                    [-_.~a-zA-Z0-9]) o=&quot;${c}&quot; ;;<br>                    *) printf -v o &#39;%%%02X&#39; &quot;&#39;$c&quot; ;;<br>                  esac<br>                  encoded+=&quot;${o}&quot;<br>                done<br>                echo &quot;${encoded}&quot;<br>              }<br>              echo &quot;=== Port check ===&quot;<br>              timeout 5 bash -c &quot;cat &lt; /dev/null &gt; /dev/tcp/${MYSQL_HOST}/${MYSQL_PORT}&quot; \<br>                &amp;&amp; echo &quot;PORT REACHABLE&quot; \<br>                || { echo &quot;PORT NOT REACHABLE - aborting&quot;; exit 1; }<br>              echo &quot;=== Starting bootstrap ===&quot;<br>              ENCODED_PASSWORD=$(urlencode &quot;${MYSQL_ROUTER_PASSWORD}&quot;)<br>              mysqlrouter \<br>                --bootstrap ${MYSQL_USER}:${ENCODED_PASSWORD}@${MYSQL_HOST}:${MYSQL_PORT} \<br>                --directory /etc/mysqlrouter \<br>                --conf-bind-address 0.0.0.0 \<br>                --force \<br>                2&gt;&amp;1<br>              echo &quot;=== Bootstrap completed ===&quot;<br>          env:<br>            - name: MYSQL_HOST<br>              value: &quot;&lt;mysql-primary-node-ip&gt;&quot;<br>            - name: MYSQL_PORT<br>              value: &quot;3306&quot;<br>            - name: MYSQL_USER<br>              value: root<br>            - name: MYSQL_ROUTER_PASSWORD<br>              valueFrom:<br>                secretKeyRef:<br>                  name: mysql-router-secret<br>                  key: router-password<br>          securityContext:<br>            capabilities:<br>              drop: [ALL]<br>            runAsUser: 1001360000<br>            runAsNonRoot: true<br>            allowPrivilegeEscalation: false<br>          resources:<br>            limits:<br>              cpu: 500m<br>              memory: 512Mi<br>            requests:<br>              cpu: 100m<br>              memory: 128Mi<br>          volumeMounts:<br>            - name: mysqlrouter-config<br>              mountPath: /etc/mysqlrouter<br>      containers:<br>        - name: mysqlrouter<br>          image: mysql/mysql-router:8.0<br>          imagePullPolicy: IfNotPresent<br>          command:<br>            - /bin/bash<br>            - &#39;-c&#39;<br>            - |<br>              set -e<br>              exec mysqlrouter -c /etc/mysqlrouter/mysqlrouter.conf<br>          ports:<br>            - name: rw-classic<br>              containerPort: 6446<br>              protocol: TCP<br>            - name: ro-classic<br>              containerPort: 6447<br>              protocol: TCP<br>            - name: rw-x<br>              containerPort: 6448<br>              protocol: TCP<br>            - name: ro-x<br>              containerPort: 6449<br>              protocol: TCP<br>            - name: http<br>              containerPort: 8443<br>              protocol: TCP<br>          livenessProbe:<br>            tcpSocket:<br>              port: 6446<br>            initialDelaySeconds: 10<br>            timeoutSeconds: 1<br>            periodSeconds: 15<br>            failureThreshold: 3<br>          readinessProbe:<br>            tcpSocket:<br>              port: 6446<br>            initialDelaySeconds: 5<br>            timeoutSeconds: 1<br>            periodSeconds: 10<br>            failureThreshold: 3<br>          securityContext:<br>            capabilities:<br>              drop: [ALL]<br>            runAsUser: 1001360000<br>            runAsNonRoot: true<br>            allowPrivilegeEscalation: false<br>          resources:<br>            limits:<br>              cpu: 500m<br>              memory: 512Mi<br>            requests:<br>              cpu: 100m<br>              memory: 128Mi<br>          volumeMounts:<br>            - name: mysqlrouter-config<br>              mountPath: /etc/mysqlrouter</pre><h4>A Note on OpenShift Security Context Constraints (SCC)</h4><p>OpenShift enforces SCCs, which is why we explicitly set runAsUser: 1001360000 (a UID in the range assigned to the namespace), runAsNonRoot: true, allowPrivilegeEscalation: false, and drop all Linux capabilities. The seLinuxOptions and seccompProfile are similarly required for a restricted or restricted-v2 SCC. Without these, your pod will fail to schedule.</p><h3>Step 4: Exposing MySQL Router via a Service</h3><p>Create a ClusterIP service so that application pods can connect to MySQL Router using a stable DNS name (mysql-router.abc-system-production.svc.cluster.local) rather than pod IPs:</p><p>yaml</p><pre>apiVersion: v1<br>kind: Service<br>metadata:<br>  name: mysql-router<br>  namespace: abc-system-production<br>  labels:<br>    app: mysqlrouter<br>spec:<br>  type: ClusterIP<br>  selector:<br>    app: mysqlrouter<br>  ports:<br>    - name: rw-classic<br>      protocol: TCP<br>      port: 6446<br>      targetPort: 6446<br>    - name: ro-classic<br>      protocol: TCP<br>      port: 6447<br>      targetPort: 6447<br>    - name: rw-x<br>      protocol: TCP<br>      port: 6448<br>      targetPort: 6448<br>    - name: ro-x<br>      protocol: TCP<br>      port: 6449<br>      targetPort: 6449</pre><p>Your application should connect reads to port 6447 (round-robin across secondaries with fallback to primary) and writes to port 6446 (always routed to the current primary).</p><h3>Step 5: Validating the Deployment</h3><p>Use the following Job to run 5 parallel test connections and verify that routing is working correctly:</p><p>yaml</p><pre>apiVersion: batch/v1<br>kind: Job<br>metadata:<br>  name: mysql-router-test<br>  namespace: abc-system-production<br>spec:<br>  ttlSecondsAfterFinished: 120<br>  completions: 5<br>  parallelism: 5<br>  template:<br>    spec:<br>      restartPolicy: Never<br>      containers:<br>      - name: mysql-test<br>        image: mysql:8.0<br>        command:<br>        - bash<br>        - -c<br>        - |<br>          echo &quot;=== Testing R/W Port 6446 (Expected: PRIMARY) ===&quot;<br>          mysql -h mysql-router -P 6446 -u root -p&quot;${MYSQL_ROOT_PASSWORD}&quot; \<br>            -e &quot;SELECT @@hostname AS hostname, @@read_only AS read_only, @@port AS port;&quot;</pre><pre>          echo &quot;=== Testing Read-Only Port 6447 (Expected: REPLICA) ===&quot;<br>          mysql -h mysql-router -P 6447 -u root -p&quot;${MYSQL_ROOT_PASSWORD}&quot; \<br>            -e &quot;SELECT @@hostname AS hostname, @@read_only AS read_only, @@port AS port;&quot;</pre><pre>          echo &quot;=== Cluster Member Status ===&quot;<br>          mysql -h mysql-router -P 6446 -u root -p&quot;${MYSQL_ROOT_PASSWORD}&quot; \<br>            -e &quot;SELECT MEMBER_HOST, MEMBER_PORT, MEMBER_STATE, MEMBER_ROLE<br>                FROM performance_schema.replication_group_members;&quot;<br>        env:<br>        - name: MYSQL_ROOT_PASSWORD<br>          valueFrom:<br>            secretKeyRef:<br>              name: mysql-router-secret<br>              key: router-password<br>        resources:<br>          requests:<br>            cpu: &quot;100m&quot;<br>            memory: &quot;128Mi&quot;<br>          limits:<br>            cpu: &quot;200m&quot;<br>            memory: &quot;256Mi&quot;</pre><p>A successful output will look like this:</p><pre>=== Testing R/W Port 6446 (Expected: PRIMARY) ===<br>hostname    read_only   port<br>dbnodea     0           3306<br><br>=== Testing Read-Only Port 6447 (Expected: REPLICA) ===<br>hostname    read_only   port<br>dbnodec     1           3306</pre><pre>=== Cluster Member Status ===<br>MEMBER_HOST         MEMBER_PORT   MEMBER_STATE   MEMBER_ROLE<br>192.168.100.10      3306          ONLINE         SECONDARY<br>192.168.100.11      3306          ONLINE         PRIMARY<br>192.168.100.12      3306          ONLINE         SECONDARY</pre><p>Port 6446 returns read_only=0, confirming it is the PRIMARY. Port 6447 returns read_only=1, confirming it is a SECONDARY. All three cluster members are ONLINE.</p><h3>How MySQL Router Stays in Sync</h3><p>You might wonder: what happens when the primary fails over? MySQL Router’s metadata cache is the key. The generated mysqlrouter.conf contains a [metadata_cache:mycluster] section that lists all three bootstrap server addresses and sets use_gr_notifications = 1. This means the router subscribes to Group Replication notifications and instantly knows when a new node becomes primary — without waiting for a TTL to expire.</p><p>The relevant section from the bootstrapped config:</p><p>ini</p><pre>[metadata_cache:mycluster]<br>cluster_type = gr<br>bootstrap_server_addresses = IP1:3306,IP2:3306,IP3:3306<br>user = mysqlrouter<br>ttl = 0.5<br>use_gr_notifications = 1</pre><pre>[routing:primary]<br>bind_port = 6446<br>destinations = metadata-cache://mycluster/?role=PRIMARY<br>routing_strategy = first-available<br>protocol = classic</pre><pre>[routing:secondary]<br>bind_port = 6447<br>destinations = metadata-cache://mycluster/?role=SECONDARY<br>routing_strategy = round-robin-with-fallback<br>protocol = classic</pre><p>The round-robin-with-fallback strategy on the secondary port means that if all secondaries go offline, reads will automatically fall back to the primary — a sensible default for most workloads.</p><h3>Key Takeaways</h3><p>Deploying MySQL Router on OpenShift to front a VM-hosted InnoDB Cluster is well worth the setup effort. A few things worth remembering from this walkthrough:</p><p><strong>Use an init container for bootstrapping.</strong> The bootstrap process is stateful — it writes a config file based on the live cluster topology. An init container with a shared emptyDir volume is the idiomatic Kubernetes way to handle this.</p><p><strong>URL-encode your password.</strong> If your MySQL Router password contains special characters, the bootstrap URI will silently fail or connect to the wrong host. The pure-bash urlencode() function in the init container script solves this robustly.</p><p><strong>Use the </strong><strong>subnets + </strong><strong>excludeSubnets approach in your NAD.</strong> Hard-coding pod IPs creates operational headaches. Let OVN-Kubernetes assign IPs automatically while protecting the IPs reserved for your database VMs.</p><p><strong>Set OpenShift-compatible security contexts.</strong> Don’t fight the SCC — set the right runAsUser, drop all capabilities, and use seccompProfile: RuntimeDefault. It only takes a few lines and prevents hours of debugging.</p><p><strong>Validate with a parallel Job.</strong> Running 5 completions in parallel quickly verifies that both the RW and RO routing paths are working, and shows you the real cluster topology from the router’s perspective.</p><h3>Conclusion</h3><p>MySQL Router is a lightweight but powerful component that transforms a raw MySQL Group Replication cluster into a topology-aware, highly-available data tier. Running it as a Kubernetes Deployment on OpenShift — with an OVN-Kubernetes L2 overlay connecting it to your OpenShift Virtualization database VMs — gives you the best of both worlds: the simplicity of VM-based MySQL operations combined with cloud-native connectivity and observability.</p><p>The patterns shown here — init containers for bootstrapping, secondary network interfaces for DB connectivity, parallel validation Jobs — are broadly applicable beyond MySQL Router to any scenario where containerized workloads need to reach VM-hosted databases on OpenShift.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=52ee626a8b74" width="1" height="1" alt="">]]></content:encoded>
    </item>
  </channel>
</rss>
