I first heard about MRC while out on a run, listening to my weekly networking news podcast. I stopped dead in my tracks and listened to the rest of the discussion. Then I went home and started obsessively researching it.
MRC already puts some of the techniques developed by the Ultra Ethernet Consortium to work in production networks. I think Ultra Ethernet will eventually become the default stack for datacenter networks.
But first, what the hell is MRC?
MRC (Multipath Reliable Connection) extends RoCE v2. OpenAI, Microsoft, and NVIDIA developed the extension; the Open Compute Project published it as an open spec. OpenAI’s GB200 training clusters already run it in production.
It borrows a lot of ideas from Ultra Ethernet spec but it runs on NICs you can buy today.
To understand it, we need to cover a few networking concepts that have been around for years.
ECMP (Equal-Cost Multipath)
ECMP lets a router or Layer 3 switch forward traffic through multiple equal-cost next hops.
Suppose two servers have four equal-cost paths between them. ECMP can use all four paths, but how does it decide which one to use?
Most conventional ECMP implementations use per-flow hashing.
A flow is typically identified by five fields: source IP address, destination IP address, source port, destination port, and transport protocol. These are commonly called the five-tuple.
The switch runs these fields through a hash function to select one of the available next hops. Packets belonging to the same flow normally take the same path, helping preserve packet ordering.
ECMP distributes flows, not individual packets. So Imagine four equal-cost paths between two servers.
Several large, bursty transfers, like KV-cache transfers between disaggregated prefill and decode workers, can hash onto the same path while others have spare capacity. You end up with a few congested links even though there’s plenty of bandwidth available elsewhere in the network.
๐จ๏ธ If you’ve ever dealt with partner-operated OCI FastConnect, Azure ExpressRoute, or AWS Direct Connect links, you’ll know the frustration.
You call the provider because performance has gone to shit, only to hear, โOh yeah, we had a fiber cutโฆ. Failover worked, but ECMP ended up putting too much traffic on one of the remaining links.โ
Your handoff never went down. BGP/BFD is happy, but latency is through the roof. A classic network brownout.
RDMA and RoCE
You may have come across RDMA in HCI solutions like Storage Spaces Direct (S2D), VMware vSAN or Proxmox VE.
Itโs used to reduce CPU overhead for storage traffic between hosts.
How does it work?
RDMA (Remote Direct Memory Access) lets network adapters move data directly between application memory on different servers, without the receiving CPU processing each packet.
For an RDMA write, Server Aโs application tells its RDMA capable network card or NIC, what to send and where.
The NIC reads the data from local memory and sends it to Server B.
Server Bโs NIC writes it directly into a registered application buffer. The receiving CPU does not process each packet.
Server Bโs CPU then reads the data from memory once the application knows it is ready. A plain RDMA write does not automatically notify the receiving application, so the software coordinates that separately.
Why MRC?
Sender
๐จ๏ธ Going back to our ECMP example above, with fixed hash inputs, packets from one connection keep taking the same path. Two large transfers can end up competing for the same link while another link sits unused.
MRC takes advantage of the hashing the switches already do.
With MRC the sending NIC varies the UDP source port from packet to packet.
The switches continue hashing normally because the source port is one of the fields switches already use in their ECMP hash. (Switches donโt need new firmware to understand MRC)
But now the changing input lets packets from the same RDMA connection take different paths. They call this packet spraying.
Now one transfer can spread its packets across all four paths.
Receiver
UDP source ports differ but the packets still belong to one RDMA connection since they all use the same RDMA transport header
The receiving NIC (with MRC support) uses the headers to ID the connection and track packet delivery.
Each data packet also carries the placement information needed to write its payload into the correct destination memory location.
If packet 3 arrives before packet 2 (out of order), the NIC can write packet 3โs data straight into its destination memory location and record that packet 2 has not arrived. It does not have to hold or buffer packet 3 while waiting for packet 2.
The receiver sends selective acknowledgements (SACKs) to report which packets have arrived and where there are gaps. In this example, its feedback effectively says: โI have packets 1 and 3; packet 2 has not arrived yet.โ
Packet 2 might just be delayed. The sender uses that feedback, along with timing information, to decide whether to wait or retransmit it. No need to resend the data that already arrived.
The operation completes only once every packet has been placed and the sender has received acknowledgement for all of them.
What do we need to run it?
MRC is designed to run over best-effort Ethernet. Packets can get dropped, and the endpoints handle recovering them without relying on Ethernet flow control.
Ethernet flow control (PFC)?
Say your server is sending RDMA traffic into a switch faster than the switch can forward it. The buffer holding that traffic starts filling up.
Before it overflows, the switch sends a PFC pause frame back to the serverโs NIC saying: โStop sending this traffic class for a moment. I need to clear some buffer space.โ The NIC pauses that traffic, then resumes when the pause expires or the switch tells the server nic to resume.
All connections sharing that paused priority have to wait, including those that did not cause the congestion
But this is not a turnkey solution. Just like RDMA, both endpoints (NICs) need the firmware support. The hostโs drivers, RDMA libraries and communication software also need to support and use the MRC protocol.
Major vendors already have MRC implementations on High end 800GE NICs like NVIDIA ConnectX-8, AMD Pensando and Broadcom Thor Ultra.
Right now MRC is for frontier labs and hyperscales with training clusters at a scale only a handful of companies can pay for.
But we will all reap the benefits of this in more tokens, faster and cheaper, once it gets adopted widely across the world.
The previous post covered what happens when you hit enter. Tokens flow through layers, probabilities get shaped, text comes out. System prompts anchor the model in activation space. Temperature controls how tightly it follows that anchor.
This post goes one level deeper, into the model’s layers. What are those activations? What shape do they take?
For this experiment, I used a well-documented workflow built around Google’s Gemma 2 2B model and the Gemma Scope residual stream SAEs. These are Sparse Autoencoders trained by Google on Gemma’s residual stream activations. They act as auxiliary models that decompose dense internal states into sparse, more interpretable features. The tools are Gemma-specific but the concepts apply to any transformer model.
Vectors: The Model’s Internal Language
Think about the gas laws. Compress a gas, it gets hotter. The macroscopic behavior is simple. Underneath, there’s a seething mass of atoms bouncing around, and the real explanation to why it gets hot when compressed lives in those microscopic dynamics.
Neural networks work the same way.
The macroscopic behavior is “the model talks about France.”
The microscopic dynamics are activation patterns flowing through 2304-dimensional space.
We’re mapping the microscopic level
A vector is a list of numbers. In Gemma 2 2B, the vector at each token position is a list of 2304 numbers. That’s the model’s d_model dimension, its internal working width.
No single number in that list is human-readable. But the pattern across all 2304 numbers encodes what the model “knows” at that point in the text.
When Gemma processes “The capital of France is”, it does not have a dedicated slot for “France” and another slot for “capital”. Instead, the model has a specific geometric direction for “France” and another direction for “capital”. It adds those vectors together.
The vector at the last token position is just the mathematical sum of all those active concepts.
Because it was built through vector addition, that single coordinate in 2304-dimensional space contains the combined geometry of “France”, “geography”, and “Paris is next”.
We cannot visualize that space directly, but the model moves through it constantly, and every prediction depends on exactly where that final coordinate lands.
The Residual Stream: The Highway Everything Rides On
Vectors are the payload; the Residual stream is the bus. It is the main data pipeline through the transformer.
That 2304-dimensional vector persists across all 26 layers. Each layer does not replace it outright. It reads the current residual stream, computes an update, and adds that update back in.
The token embedding creates the initial vector. Layer 0 attention reads it, computes an update, adds it back. Layer 0 MLP does the same. This continues through all 26 layers, and the final vector gets mapped to token probabilities. That additive pattern is why the residual stream is such an important place to inspect. Earlier information can remain available while later layers keep reshaping it. What survives, what gets amplified, and what becomes less useful depends on the sequence of updates across the network.
A prompt goes in. The model processes it token by token.
At layer 12, each token has a vector of shape (2304,) in the residual stream.
The SAE encoder maps that dense vector into a sparse feature space (16,384 dimensions for the coarse SAE, 262,144 for the fine SAE) where only a small number of features activate.
The SAE decoder maps the sparse representation back into (2304,) to approximate the original residual state.
A steering vector is the decoder vector for a specific SAE feature. Adding it into the residual stream biases the model toward that feature’s pattern. Phase 2 will test this.
Why Layer 12?
Gemma 2 2B has 26 transformer layers, numbered 0 through 25. I’m hooking into layer 12 โ roughly 46% depth.
The hook point is blocks.12.hook_resid_post, capturing the residual stream after layer 12 finishes processing.
Production inference engines and local runners use quantized formats and custom C++ engines for speed, which strips out the Python-level hook system.
To get around this, we load the exact same Gemma 2 2B weights from HuggingFace as native PyTorch tensors (raw weights), which gives us the ability to tap any layer.
Gemma Scope trained SAEs on every layer of Gemma 2 2B, so layer 12 was a deliberate choice was based on Google’s own interpretability research papers.
Coarse (16K SAE): fewer features, each covering more conceptual ground.
Fine (262K SAE): more features, each with higher resolution on specific facets.
An SAE decomposes a single 2304-number vector into thousands of sparse components called features.
The residual stream is dense and compressed. Gemma has to represent a huge amount of structure in just 2304 dimensions, so many different patterns get packed together in superposition. That means individual residual dimensions are not clean, human-readable concept slots. A single dimension can participate in multiple unrelated behaviors depending on the context.
This is where the Sparse Autoencoder helps. Instead of trying to interpret the raw 2304-dimensional state directly, the SAE projects it into a much larger sparse feature space, such as 16K or 262K features. In that expanded space, only a small number of features activate for a given input, and those features are often easier to interpret than the original dense residual dimensions.
So back to the actual numbers. The SAE takes that tightly packed, dense circuitry and expands it out into a much wider space so we can see which patterns actually fired.
How sparse is sparse, what is a SAE?
Think of an audio spectrum analyzer. It takes a single, dense audio wave where the kick drum, bass, and vocals are all mashed together, and splits them out into distinct frequency bands. Most bands stay flat. Only the frequencies actually present in the audio spike up.
The SAE is a semantic spectrum analyzer.
The model’s Layer 12 vector is the dense audio wave, a 2304-number mess where all the active concepts are added together.
The SAE takes that dense wave and splits it out across 16,384 distinct “frequency bands” (features).
Because a single sentence only contains a handful of concepts, the SAE only spikes about 82 of those bands to represent the entire input.
The other 16,000+ bands stay flat at zero simply because those concepts are not in the sentence. Out of 16,384 possible patterns the SAE can detect, a typical input lights up fewer than 1% of them. The rest stay silent. That is what sparse means.
Feature #3999: mean activation 3.67, selectivity 0.97 โ France (strongest signal)
Feature #11333: mean activation 4.48, selectivity 0.89 โ France-related
Feature #9473: mean activation 1.21, selectivity 0.88 โ France-related
Feature #14805: mean activation 1.24, selectivity 0.86 โ France-related
Feature #6211: mean activation 1.27, selectivity 0.86 โ France-related
...
Feature #6,004: activation 0.00 โ silent
Feature #6,005: activation 0.00 โ silent
[~11,000 more at zero]
The key column we should focus on is selectivity: how exclusively does a feature fire on France prompts versus neutral ones?
Feature #3999 scores 0.97. This means it almost exclusively fires on France-related input. Notice #11333 actually fires harder (mean 4.48 vs 3.67) but has lower selectivity. It bleeds into neutral prompts too. A feature that fires on everything is not telling you anything useful, no matter how loud it is.
I chose France as a sanity-check target because the interpretability method was already grounded in prior work, and Gemma Scope gave me a validated SAE setup to test whether my own pipeline outputted correct Data.
The experiment follows standard A/B logic.
Run 400 prompts total through Gemma: 200 France-themed prompts and 200 neutral filler prompts.
Capture the layer 12 activations for both sets.
Decompose both through the same SAEs.
Use Python to compare features that fire consistently on the France set but stay quiet on the neutral sets.
Now we have the candidates for “France features.” Features that fire on everything else can be safely considered noise.
Two Resolutions, One Concept
The 16K SAE (coarse) might give you one feature, call it #3999, that broadly responds to “France.” One fat bucket for the whole concept.
The 262K SAE (fine) might split that same concept into multiple features.
Feature #86473 fires on a subset of France prompts. Feature #249284 fires on a different subset. Feature #243533 on another.
Each one potentially encodes a different facet (cuisine, geography, landmarks, language), though we do not know which facets they are yet. More on that problem in a minute.
This is hierarchical feature decomposition.
Same logic as hand-designing a vision network:
Edges combine into shapes
Shapes combine into objects
Objects together in a scene change the meaning again (meaning as in what regions get excited to infer what it is)
but instead of happening across multiple sequential layers, this hierarchy exists at different levels of granularity within the exact same layer.
The coarse SAE has a smaller dictionary, so it is forced to find the whole object.
The fine SAE has a massive dictionary, so it can afford to isolate the specific parts and edges. Nobody hand-wired these decompositions. They emerged naturally from the training data.
The cross-resolution mapping step figures out which fine features correspond to which coarse features. It measures two things:
Activation correlation: Do they fire on the same prompts? (behavioral evidence)
Decoder cosine similarity: Do their decoder vectors point in the same direction in the 2304-dimensional residual stream? (Geometric evidence)
Here are the actual results from the experiment.
The Heatmap: Decoder Cosine Similarity
The heatmap shows the same data from a different angle. Each cell measures how much a coarse features and a fine features point in the same direction inside the model.
High values mean they’re encoding the same thing at different zoom levels.
Low values mean they’re unrelated.
Coarse
Strongest Fine Match
Similarity
Reading
C-3999
F-249284
0.80
Near-identical direction. Almost certainly the same concept at different granularity
C-11333
F-86473
0.54
Strong overlap, plus two more matches at 0.42 and 0.34
C-9473
F-243533
0.47
One clear sub-feature
Values near zero mean unrelated. Above 0.3 is meaningful geometric correspondence.
Each of the 262,000 fine features was compared against C-3999 to see how closely they point in the same direction. Almost all of them scored near zero, meaning no geometric alignment. The histogram shows where the crowd is: piled up at zero, with a long empty stretch before the red line at 0.3. The handful of features past that line are the ones that actually share a direction with C-3999. That’s why 0.3 is the cutoff. It’s where the crowd ends and the signal begins.
Coarse-to-Fine Feature Decomposition
Every prompt was decoded through both SAEs at the same time. The coarse SAE gave us three broad France features. The fine SAE gave us six narrow ones.
The interactive dashboard below maps every possible pair of these coarse and fine features to measure how strong their relationship actually is.
All data shown is derived from real activations collected by running 400 prompts through Gemma 2 2B and decomposing layer 12 through both Gemma Scope SAEs.
The left side shows the decomposition graph. It maps exactly which fine features branch off from our three coarse anchors.
Link width represents behavioral correlation (do they fire together?).
Link color represents geometric similarity (do they point the same way?).
The right side plots these exact same pairs in metric space:
The X-axis is behavioral correlation: do they fire on the exact same prompts?
The Y-axis is geometric similarity: do their vectors point in the same direction inside the model?
Look at the color coding on the scatter plot. It tells the whole story:
Blue dots (Top Right): These are the true sub-feature matches. They have high correlation and high geometric similarity.
They co-fire AND point the same way inside the model. The strongest pair is C-3999 to F-249284.
Orange dots (Middle): These are partial overlaps.
They fire together often, but their geometry is drifting apart.
Red dots (Bottom): These are co-occurring concepts. They sit in the lower half with moderate correlation but cosine similarity near zero. These features fire on many of the same prompts but point in completely different directions.
They co-occur with France, but they are not encoding the same concept. They are related ideas that travel together, not the same idea at two zoom levels.
Reading the scatter plot
To better understand the graph, think of these features as passive sensors hooked up to the Layer 12 data bus:
1. The Empty Top-Left (High Similarity, Low Correlation) If Sensor A and Sensor B point in the exact same direction, they are going to catch the exact same meandering vectors. Always. You physically cannot have a vector pass by that trips Sensor A but misses Sensor B. That is why high geometric similarity mathematically forces high correlation. The top-left is empty because it defies physics.
2. The Bottom-Right (Low Similarity, High Correlation) Sensor A points at “France”. Sensor B points at “Food”. They point in completely different directions (low geometric similarity). But when the vector for “French Cuisine” meanders down the data bus, it contains enough geometry to trip both sensors at the same time. They fire together (high correlation) even though they are looking for different things. These are your co-occurring concepts.
3. Low Selectivity (The noisy features) If a sensor points in a direction that catches “France” but also catches a bunch of other random vectors meandering by (like “Germany” or “cheese”), it will have a lower selectivity score. This is exactly what you saw with Feature #11333 earlier in the postโit fired loud, but it fired on too much unrelated traffic to be a clean “France” feature.
The Interpretation Gap
C-3999 gets labeled “France” because it reliably activates on France prompts and not on neutral ones. That’s standard practice in interpretability work. Label a feature by what activates it.
But the model doesn’t know the word “France.” It has a direction in 2304-dimensional space that, for whatever internal reason, turned out to be useful for predicting the next token when France-related patterns show up. We call it “France” because that’s the human category our test prompts were organized around. The model’s internal geometry might carve the world along boundaries that don’t align with our conceptual categories at all โ we just can’t tell, because we only test with prompts organized around our categories.
What we actually know
What we’re assuming
C-3999 fires on France prompts, not neutral
C-3999 “means” France
F-86473 fires on a subset of France prompts
F-86473 is a France sub-concept
C-3999 and F-249284 point in similar directions
They encode related meanings
Injecting C-3999’s direction changes output toward France-like text
The feature is causally involved in France generation
The labels (“French cuisine,” “Paris landmarks”) are human interpretations based on which prompts activate a feature. The model doesn’t have those labels. It has frozen weights and an activation landscape that we’re projecting human categories onto.
Neural nets don’t memorize data. They find regularities and generalize those regularities to new data. A model will generate plausible text about unicorns even though it’s never seen one described as real, because it’s learned the relational structure of mythical creatures, horses, and horns. The internal representation that enables that generalization doesn’t need to map onto our concept of “unicorn.” It just needs to be useful for next-token prediction. When we label a feature “France,” we’re assuming the model’s useful regularity aligns with our semantic boundary. Sometimes it does. Sometimes we don’t know.
This is the wall that people like Neel Nanda keep writing about in mechanistic interpretability research. It was interesting to actually hit it myself. I can identify which features fire and when.
We can measure geometric relationships between them. But mapping that to human-readable meaning is always an inference, never ground truth.
When I started this project, I wanted to build something like the Activation Space Navigator from the previous post, but using real model data derived from SAEs, I pictured clean clusters with labeled regions where you could point and say, โthatโs France.โ
The real data did not look like that. What it gave me instead were directions in the modelโs internal representation that reliably correlate with France-themed input.
Every feature in the SAE is a literal 2304-dimensional vector stored in the SAE’s decoder matrix. Feature C-3999 is just row #3999 in that matrix. It acts as a static reference coordinate for “France”.
This exact mechanic applies to any concept. If we were testing Python code, HTML tables, or HTTP status codes, there would be a different row acting as the reference coordinate for that specific pattern.
The reference vectors in the SAE do not move. They are fixed in place like highway signs. The model’s dynamic state passes by them.
When the France prompt generates a vector that passes close to the C-3999 sign, that specific band on our semantic spectrum analyzer spikes.
Neutral prompts pass further away, so the band stays flat. Those spikes are the sparse values we actually record.
One thing worth naming: this is all an approximation. The SAE reconstructs the residual stream from its learned directions, and the reconstruction is not perfect. Some signal is always lost. We are working with a useful approximation of the model’s internal state, not the thing itself.
So it is not that “France activated these regions of the model’s weights.” The weights are frozen. The model has learned internal directions for France-related patterns, and when France text flows through, the residual stream aligns with those directions. That alignment is what we are measuring.
Closing the loop
The previous post described system prompts as activation-space manipulation. This experiment gives me supporting evidence for that framing.
The directions those prompts appear to push activations toward are measurable, and some of that structure can be decomposed into narrower features.
What I found is suggestive structure, not full semantic ground truth.
The coarse-to-fine matches look real enough to justify the next step, which is testing whether steering along those directions’ changes generation in a targeted way.
What’s Next for This Project
The activation mapping is done. I found and observed suggestive structure:
coarse France-related features that appear to decompose into finer sub-features, supported by both behavioral correlation and geometric similarity.
The GO/NO-GO question was whether the coarse-to-fine mapping would produce 3+ meaningful sub-features per coarse anchor.
C-11333 has three above threshold. That’s a GO.
Phase 2 is the actual Steering Experiment.
Take those mapped features and test whether multi-resolution steering (coarse “France” + fine sub-features) produces better, more targeted output than single-resolution steering alone.
If it does, that’s evidence the cross-resolution structure isn’t just a statistical artifact. It’s a lever we can pull to tweak the behavior of a model
If it doesn’t, the structure is real but doesn’t actually control what the model generates. Correlation isn’t causation, even inside a neural network.
Either way, I’ll know more about what those frozen weights are actually doing when we hit enter.
I’ve been diving deep into systems architecture lately, specifically Kubernetes
Strip away the UIs, the YAML, and the ceremony, and Kubernetes boils down to:
A very stubborn event driven collection of control loops
aka the reconciliation (Control) loop, and everything I read is calling this the โgold standardโ for distributed control planes.
Because it decomposes the control plane into many small, independent loops, each continuously correcting drift rather than trying to execute perfect one-shot workflows. these loops are triggered by events or state changes, but what they do is determined by the the spec. vs observed state (status)
Now we have both:
spec: desired state
status: observed state
Kubernetes lives in that gap.
When spec and status match, everything’s quiet. When they don’t, something wakes up to ensure current state matches the declared state.
The Architecture of Trust
In Kubernetes, they donโt coordinate via direct peer-to-peer orchestration; They coordinate by writing to and watching one shared “state.”
That state lives behind the API server, and the API server validates it and persists it into etcd.
Role of the API server
The API server is the front door to the clusterโs shared truth: itโs the only place that can accept, validate, and persist declared intent as Kubernetes API objects (metadata/spec/status).
When you install a CRD, youโre extending the API itself with a new type (a new endpoint) or a schema the API server can validate against
When we use kubectl apply (or any client) to submit YAML/JSON to the API server, the API server validates it (built-in rules, CRD OpenAPI v3 schema / CEL rules, and potentially admission webhooks) and rejects invalid objects before theyโre stored.
If the request passes validation, the API server persists the object into etcd (the whole API object, not just โintentโ), and controllers/operators then watch that stored state and do the reconciliation work to make reality match it.
Once stored, controllers/operators (loops) watch those objects and run reconciliation to push the real world toward whatโs declared.
it turns out In practice, most controllers donโt act directly on raw watch events, they consume changes through informer caches and queue work onto a rate-limited workqueue. They also often watch related/owned resources (secondary watches), not just the primary object, to stay convergent.
spec is often user-authored as discussed above, but it isnโt exclusively human-written, the scheduler and some controllers also update parts of it (e.g., scheduling decisions/bindings and defaulting).
Role of etcd cluster
etcd is the control planeโs durable record of โthe authoritative reference for what the cluster believes that should exist and what it currently reports.”
If an intent (an API object) isnโt in etcd, controllers canโt converge on itโbecause thereโs nothing recorded to reconcile toward
This makes the system inherently self-healing because it trusts the declared state and keeps trying to morph the world to match until those two align.
One tidbit worth noting:
In production, Nodes, runtimes, cloud load balancers can drift independently. Controllers treat those systems as observed state, and they keep measuring reality against what the API says should exist.
How the Loop Actually Works
Kubernetes isn’t one loop. It’s a bunch of loops(controllers) that all behave the same way:
read desired state (what the API says should exist)
observe actual state (what’s really happening)
calculate the diff
push reality toward the spec
As an example, let’s look at a simple nginx workload deployment
1) Intent (Desired State)
To Deploy the Nginx workload. You run:
kubectl apply -f nginx.yaml
The API server validates the object (and its schema, if it’s a CRD-backed type) and writes it into etcd.
At that point, Kubernetes has only recorded your intent. Nothing has “deployed” yet in the physical sense. The cluster has simply accepted:
“This is what the world should look like.”
2) Watch (The Trigger)
Controllers and schedulers aren’t polling the cluster like a bash script with a sleep 10.
They watch the API server.
When desired state changes, the loop responsible for it wakes up, runs through its logic, and acts:
“New desired state: someone wants an Nginx Pod.”
watches aren’t gospel. Events can arrive twice, late, or never, and your controller still has to converge. Controllers use list+watch patterns with periodic resync as a safety net. The point isn’t perfect signals it’s building a loop that stays correct under imperfect signals.
Controllers also don’t spin constantly they queue work. Events enqueue object keys; workers dequeue and reconcile; failures requeue with backoff. This keeps one bad object from melting the control plane.
3) Reconcile (Close the Gap)
Here’s the mental map that made sense to me:
Kubernetes is a set of level-triggered control loops. You declare desired state in the API, and independent loops keep working until the real world matches what you asked for.
Controllers (Deployment/ReplicaSet/etc.) watch the API for desired state and write more desired state.
Example: a Deployment creates/updates a ReplicaSet; a ReplicaSet creates/updates Pods.
The scheduler finds Pods with no node assigned and picks a node.
It considers resource requests, node capacity, taints/tolerations, node selectors, (anti)affinity, topology spread, and other constraints.
It records its decision by setting spec.nodeName on the Pod.
The kubelet on the chosen node notices โa Pod is assigned to meโ and makes it real.
pulls images (if needed) via the container runtime (CRI)
sets up volumes/mounts (often via CSI)
triggers networking setup (CNI plugins do the actual wiring)
starts/monitors containers and reports status back to the API
Each component writes its state back into the API, and the next loop uses that as input. No single component โruns the whole workflow.โ
One property makes this survivable: reconcile must be safe to repeat(idempotent). The loop might run once or a hundred times (retries, resyncs, restarts, duplicate/missed watch events), and it should still converge to the same end result.
if the desired state is already satisfied, reconcile should do nothing; if something is missing, it should fill the gap, without creating duplicates or making things worse.
When concurrent updates happen (two controllers might try to update the same object at the same time)
Kubernetes handles this with optimistic concurrency. Every object has a resourceVersion (what version of this object did you read?โ). If you try to write an update using an older version, the API server rejects it (often as a conflict).
Then the flow is: re-fetch the latest object, apply your change again, and retry.
4) Status (Report Back)
Once the pod is actually running, status flows back into the API.
The Loop Doesn’t Protect You From Yourself
What if the declared state says to delete something critical like kube-proxy or a CNI component? The loop doesn’t have opinions. It just does what the spec says.
A few things keep this from being a constant disaster:
Control plane components are special. The API server, etcd, scheduler, controller-manager these usually run as static pods managed directly by kubelet, not through the API. The reconciliation loop can’t easily delete the thing running the reconciliation loop as long as its manifest exists on disk.
DaemonSets recreate pods. Delete a kube-proxy pod and the DaemonSet controller sees “desired: 1, actual: 0” and spins up a new one. You’d have to delete the DaemonSet itself.
RBAC limits who can do what. Most users can’t touch kube-system resources.
Admission controllers can reject bad changes before they hit etcd.
But at the end, if your source of truth says “delete this,” the system will try. The model assumes your declared state is correct. Garbage in, garbage out.
This Pattern Outside Kubernetes
This pattern can be useful anywhere you manage state over time.
Scripts are fine until they aren’t:
they assume the world didn’t change since last run
Used for securing communication between producers/consumers and Kafka brokers.
SASL (Simple Authentication and Security Layer): Authenticates clients (producers/consumers) to brokers, using SCRAM .
SSL/TLS (Secure Sockets Layer/Transport Layer Security): Encrypts the data in transit, ensuring confidentiality and integrity during transmission.
Digest-MD5 (Dashed Yellow Lines):
Secures communication between Kafka brokers and the Zookeeper cluster.
Digest-MD5: A challenge-response authentication mechanism providing basic encryption
Notes:
While functional, Digest-MD5 is an older algorithm. we opted for this to reduce complexity and the fact the zookeepers have issues with connecting with Brokers via SSL/TLS
We need to test and switch over KRAFT Protocol, this removes the use of Zookeeper altogether
Add IP ACLs for Zookeeper connections using firewalld to limit traffic between the nodes for replication
PKI and Certificate Signing
CA cert for local PKI,
We need to share this PEM file(without the private key) with the customer to authenticate
Internal applications the CA file must be used for authentication – Refer to the Configuration example documents
kafka-configs.sh --bootstrap-server ext-kafka01.fleetcam.io:9092 --alter --add-config 'SCRAM-SHA-512=[password=zookeeper-password]' --entity-type users --entity-name ftszk
kafka-configs.sh --zookeeper ext-kafka01.fleetcam.io:2181 --alter --add-config 'SCRAM-SHA-512=[password=kafkaadmin-password]' --entity-type users --entity-name ftskafkaadminAfter the users are created without errors, press Ctrl+C to shut down the services we started earlier.
SASL_SSL configuration with SCRAM
Zookeeper configuration Notes
Zookeeper is configured with SASL/MD5 due to the SSL issues we faced during the initial setup
Zookeeper Traffic is isolated with in the Broker nodes to maintain security
There are few ways to handle this, you can add a script under profile.d or use a custom Zookeeper launch script for the systemd service
Systemd service
Create the launch shell script for Zookeeper
/opt/kafka/kafka_2.13-3.8.0/bin/zk-start.s
#!/bin/bash
#export the env variable
export KAFKA_OPTS="-Djava.security.auth.login.config=/opt/kafka/kafka_2.13-3.8.0/config/zookeeper-jaas.conf"
#Start the zookeeper service
/opt/kafka/kafka_2.13-3.8.0/bin/zookeeper-server-start.sh /opt/kafka/kafka_2.13-3.8.0/config/zookeeper.properties
#debug - launch config with no SSL - we need this for initial setup and debug
#/opt/kafka/kafka_2.13-3.8.0/bin/zookeeper-server-start.sh /opt/kafka/kafka_2.13-3.8.0/config/zookeeper-NOSSL_AUTH.properties
#!/bin/bash
#export the env variable
export KAFKA_OPTS="-Djava.security.auth.login.config=/opt/kafka/kafka_2.13-3.8.0/config/kafka_server_jaas.conf"
#Start the kafka service
/opt/kafka/kafka_2.13-3.8.0/bin/kafka-server-start.sh /opt/kafka/kafka_2.13-3.8.0/config/server.properties
#debug - launch config with no SSL - we need this for initial setup and debug
#/opt/kafka/kafka_2.13-3.8.0/bin/kafka-server-start.sh /opt/kafka/kafka_2.13-3.8.0/config/server-NOSSL_AUTH.properties
You can use any Linux environment with JDK17 or 11
install dependencies
dnf install -y wget nano java-11-openjdk-devel
Download Kafka and extract it (in going to extract it to the home DIR under kafka)
# 1. Download Kafka (Choose a version compatible with your server)
wget https://dlcdn.apache.org/kafka/3.8.0/kafka_2.13-3.8.0.tgz
# 2. Extract
tar xzf kafka_2.13-3.8.0.tgz
Copy the jks files (You should generate them with the CA JKS, or use one from one of the nodes) to ~/
This guide covers how to add an NFS StorageClass and a dynamic provisioner to Kubernetes using the nfs-subdir-external-provisioner Helm chart. This enables us to mount NFS shares dynamically for PersistentVolumeClaims (PVCs) used by workloads.
Example use cases:
Database migrations
Apache Kafka clusters
Data processing pipelines
Requirements:
An accessible NFS share exported with: rw,sync,no_subtree_check,no_root_squash
NFSv3 or NFSv4 protocol
Kubernetes v1.31.7+ or RKE2 with rke2r1 or later
lets get to it
1. NFS Server Export Setup
Ensure your NFS server exports the shared directory correctly:
mount.nfs: access denied by server while mounting 192.168.162.100:/pl-elt-kakfka
This usually means the NFS path is misspelled or not exported properly.
Broken State: root_squash enabled
failed to provision volume with StorageClass "nfs-client": unable to create directory to provision new pv: mkdir /persistentvolumes/โฆ: permission denied
Fix by changing the export to use no_root_squash or chown the directory to nobody:nogroup.
ImagePullBackOff
Ensure nodes have internet access and can reach registry.k8s.io.
RBAC errors
Make sure the ServiceAccount used by the provisioner has permissions to watch PVCs and create PVs.
Once test-nfs-pvc is bound and the pod starts successfully, your setup is working. You can now safely use storageClass: nfs-client in other workloads (e.g., Strimzi KafkaNodePool).
Output will show the Slot ID, Usage and then the Bus Address
Designation: CPU SLOT1 PCI-E 4.0 X16
Current Usage: Available
Bus Address: 0000:ff:00.0
Designation: CPU SLOT2 PCI-E 4.0 X8
Current Usage: In Use
Bus Address: 0000:41:00.0
Designation: CPU SLOT3 PCI-E 4.0 X16
Current Usage: In Use
Bus Address: 0000:c1:00.0
Designation: CPU SLOT4 PCI-E 4.0 X8
Current Usage: Available
Bus Address: 0000:ff:00.0
Designation: CPU SLOT5 PCI-E 4.0 X16
Current Usage: In Use
Bus Address: 0000:c2:00.0
Designation: CPU SLOT6 PCI-E 4.0 X16
Current Usage: Available
Bus Address: 0000:ff:00.0
Designation: CPU SLOT7 PCI-E 4.0 X16
Current Usage: In Use
Bus Address: 0000:81:00.0
Designation: PCI-E M.2-M1
Current Usage: Available
Bus Address: 0000:ff:00.0
Designation: PCI-E M.2-M2
Current Usage: Available
Bus Address: 0000:ff:00.0
We can use lspci -s #BusAddress# to locate whats installed on each slot
Im sure there is a much more elegant way to do this, but this worked as a quick ish way to find what i needed. if you know a better way please share in the comments
just something that came up while setting up a monitoring script using mailx, figured ill note it down here so i can get it to easily later when I need it ๐
Important prerequisites
You need to enable smtp basic Auth on Office 365 for the account used for authentication
Create an App password for the user account
nssdb folder must be available and readable by the user running the mailx command
Assuming all of the above prerequisite are $true we can proceed with the setup
Install mailx
RHEL/Alma linux
sudo dnf install mailx
NSSDB Folder
make sure the nssdb folder must be available and readable by the user running the mailx command
certutil -L -d /etc/pki/nssdb
The Output might be empty, but that’s ok; this is there if you need to add a locally signed cert or another CA cert manually, Microsoft Certs are trusted by default if you are on an up to date operating system with the local System-wide Trust Store
Append/prepend the following lines and Comment out or remove the same lines already defined on the existing config files
set smtp=smtp.office365.com
set smtp-auth-user=###[email protected]###
set smtp-auth-password=##Office365-App-password#
set nss-config-dir=/etc/pki/nssdb/
set ssl-verify=ignore
set smtp-use-starttls
set from="###[email protected]###"
This is the bare minimum needed other switches are located here – link
-v switch will print the verbos debug log to console
Connecting to 52.96.40.242:smtp . . . connected.
220 xxde10CA0031.outlook.office365.com Microsoft ESMTP MAIL Service ready at Sun, 6 Aug 2023 22:14:56 +0000
>>> EHLO vls-xxx.multicastbits.local
250-MN2PR10CA0031.outlook.office365.com Hello [167.206.57.122]
250-SIZE 157286400
250-PIPELINING
250-DSN
250-ENHANCEDSTATUSCODES
250-STARTTLS
250-8BITMIME
250-BINARYMIME
250-CHUNKING
250 SMTPUTF8
>>> STARTTLS
220 2.0.0 SMTP server ready
>>> EHLO vls-xxx.multicastbits.local
250-xxde10CA0031.outlook.office365.com Hello [167.206.57.122]
250-SIZE 157286400
250-PIPELINING
250-DSN
250-ENHANCEDSTATUSCODES
250-AUTH LOGIN XOAUTH2
250-8BITMIME
250-BINARYMIME
250-CHUNKING
250 SMTPUTF8
>>> AUTH LOGIN
334 VXNlcm5hbWU6
>>> Zxxxxxxxxxxxc0BmdC1zeXMuY29t
334 UGsxxxxxmQ6
>>> c2Rxxxxxxxxxxducw==
235 2.7.0 Authentication successful
>>> MAIL FROM:<###[email protected]###>
250 2.1.0 Sender OK
>>> RCPT TO:<[email protected]>
250 2.1.5 Recipient OK
>>> DATA
354 Start mail input; end with <CRLF>.<CRLF>
>>> .
250 2.0.0 OK <[email protected]> [Hostname=Bsxsss744.namprd11.prod.outlook.com]
>>> QUIT
221 2.0.0 Service closing transmission channel
Now you can use this in your automation scripts or timers using the mailx command
#!/bin/bash
log_file="/etc/app/runtime.log"
recipient="[email protected]"
subject="Log file from /etc/app/runtime.log"
# Check if the log file exists
if [ ! -f "$log_file" ]; then
echo "Error: Log file not found: $log_file"
exit 1
fi
# Use mailx to send the log file as an attachment
echo "Sending log file..."
mailx -s "$subject" -a "$log_file" -r "[email protected]" "$recipient" < /dev/null
echo "Log file sent successfully."
The above commands change the file’s owner and group to root, then set the file permissions to 600, which means only the owner (root) has read and write permissions and other users have no access to the file.
Use Environment Variables: Avoid storing sensitive information like passwords directly in the mail.rc file, consider using environment variables for sensitive data and reference those variables in the configuration.
For example, in the mail.rc file, you can set:
set smtp-auth-password=$MY_EMAIL_PASSWORD
You can set the variable using another config file or store it in the Ansible vault during runtime or use something like Hashicorp.
Sure, I would just use Python or PowerShell core, but you will run into more locked-down environments like OCI-managed DB servers with only Mailx is preinstalled and the only tool you can use ๐
the Fact that you are here means you are already in the same boat. Hope this helped… until next time
After looking around for a few hours digging in to the logs i figured out the issue, hopefully this helps some one else our there in the situation save some time.
Make sure the IPVS mode is enabled on the cluster configuration
If you are using :
RKE2 – edit the cluster.yaml file
RKE1 – Edit the cluster configuration from the rancher UI > Cluster management > Select the cluster > edit configuration > edit as YAML
Locate the services field under rancher_kubernetes_engine_config and add the following options to enable IPVS
Make sure the Kernel modules are enabled on the nodes running control planes
Background
Example Rancher – RKE1 cluster
sudo docker ps | grep proxy # find the container ID for kubproxy
sudo docker logs ####containerID###
0313 21:44:08.315888 108645 feature_gate.go:245] feature gates: &{map[]}
I0313 21:44:08.346872 108645 proxier.go:652] "Failed to load kernel module with modprobe, you can ignore this message when kube-proxy is running inside container without mounting /lib/modules" moduleName="nf_conntrack_ipv4"
E0313 21:44:08.347024 108645 server_others.go:107] "Can't use the IPVS proxier" err="IPVS proxier will not be used because the following required kernel modules are not loaded: [ip_vs_lc]"
Kubproxy is trying to load the needed kernel modules and failing to enable IPVS
This guide will walk you through on how to extend and increase space for the root filesystem on a alma linux. Cent OS, REHL Server/Desktop/VM
Method A – Expanding the current disk
Edit the VM and Add space to the Disk
install the cloud-utils-growpart package, as the growpart command in it makes it really easy to extend partitioned virtual disks.
sudo dnf install cloud-utils-growpart
Verify that the VM’s operating system recognizes the new increased size of the sda virtual disk, using lsblk or fdisk -l
sudo fdisk -l
Notes -
Note down the disk id and the partition number for Linux LVM - in this demo disk id is sda and lvm partition is sda 3
lets trigger a rescan of a block devices (Disks)
#elevate to root
sudo su
#trigger a rescan, Make sure to match the disk ID you noted down before
echo 1 > /sys/block/sda/device/rescan
exit
Now sudo fdisk -l shows the correct size of the disks
Use growpart to increase the partition size for the lvm
sudo growpart /dev/sda 3
Confirm the volume group name
sudo vgs
Extend the logical volume
sudo lvextend -l +100%FREE /dev/almalinux/root
Grow the file system size
sudo xfs_growfs /dev/almalinux/root
Notes -
You can use this same steps to add space to different partitions such as home, swap if needed
Method B -Adding a second Disk to the LVM and expanding space
Why add a second disk?
may be the the current Disk is locked due to a snapshot and you cant remove it, Only solution would be to add a second disk/
Check the current space available
sudo df -h
Notes -
If you have 0% ~1MB left on the cs-root command auto-complete with tab and some of the later commands wont work, You should clear up atleast 4-10mb by clearing log files, temp files, etc
Mount an additional disk to the VM (Assuming this is a VM) and make sure the disk is visible on the OS level
sudo lvmdiskscan
OR
sudo fdisk -l
Confirm the volume group name
sudo vgs
Lets increase the space
First lets initialize the new disk we mounted
sudo mkfs.xfs /dev/sdb
Create the Physical volume
sudo pvcreate /dev/sdb
extend the volume group
sudo vgextend cs /dev/sdb
Volume group "cs" successfully extended
Extend the logical volume
sudo lvextend -l +100%FREE /dev/cs/root
Grow the file system size
sudo xfs_growfs /dev/cs/root
Confirm the changes
sudo df -h
Just making easy for us!!
#Method A - Expanding the current disk
#AlmaLinux
sudo dnf install cloud-utils-growpart
sudo lvmdiskscan
sudo fdisk -l #note down the disk ID and partition num
sudo su #elevate to root
echo 1 > /sys/block/sda/device/rescan #trigger a rescan
exit #exit root shell
sudo lvextend -l +100%FREE /dev/almalinux/root
sudo xfs_growfs /dev/almalinux/root
sudo df -h
#Method B - Adding a second Disk
#CentOS
sudo lvmdiskscan
sudo fdisk -l
sudo vgs
sudo mkfs.xfs /dev/sdb
sudo pvcreate /dev/sdb
sudo vgextend cs /dev/sdb
sudo lvextend -l +100%FREE /dev/cs/root
sudo xfs_growfs /dev/cs/root
sudo df -h
#AlmaLinux
sudo lvmdiskscan
sudo fdisk -l
sudo vgs
sudo mkfs.xfs /dev/sdb
sudo pvcreate /dev/sdb
sudo vgextend almalinux /dev/sdb
sudo lvextend -l +100%FREE /dev/almalinux/root
sudo xfs_growfs /dev/almalinux/root
sudo df -h
if you want to prevent directory traversal we need to setup chroot with vsftpd (not covered on this KB)
For the demo I just used Unencrypted FTP on port 21 to keep things simple, Please utilize SFTP with the letsencrypt certificate for better security. i will cover this on another article and link it here
Update and Install packages we need
sudo dnf update
sudo dnf install net-tools lsof unzip zip tree policycoreutils-python-utils-2.9-20.el8.noarch vsftpd nano setroubleshoot-server -y
Setup Groups and Users and security hardening
if you want to prevent directory traversal we need to setup chroot with vsftpd (not covered on this KB)
Create the Service admin account
sudo useradd ftpadmin
sudo passwd ftpadmin
Create the group
sudo groupadd FTP_Root_RW
Create FTP only user shell for the FTP users
echo -e '#!/bin/sh\necho "This account is limited to FTP access only."' | sudo tee -a /bin/ftponly
sudo chmod a+x /bin/ftponly
echo "/bin/ftponly" | sudo tee -a /etc/shells
Create FTP users
sudo useradd ftpuser01 -m -s /bin/ftponly
sudo useradd ftpuser02 -m -s /bin/ftponly
user passwd ftpuser01
user passwd ftpuser02
Add the users to the group
sudo usermod -a -G FTP_Root_RW ftpuser01
sudo usermod -a -G FTP_Root_RW ftpuser02
sudo usermod -a -G FTP_Root_RW ftpadmin
Disable SSH Access for the FTP users.
Edit sshd_config
sudo nano /etc/ssh/sshd_config
Add the following line to the end of the file
DenyUsers ftpuser01 ftpuser02
Open ports on the VM Firewall
sudo firewall-cmd --permanent --add-port=20-21/tcp
#Allow the passive Port-Range we will define it later on the vsftpd.conf
sudo firewall-cmd --permanent --add-port=60000-65535/tcp
#Reload the ruleset
sudo firewall-cmd --reload
Setup the Second Disk for FTP DATA
Attach another disk to the VM and reboot if you haven’t done this already
lsblk to check the current disks and partitions detected by the system
sudo mv /etc/vsftpd/user_list /etc/vsftpd/user_listBackup
echo "ftpuser01" | sudo tee -a /etc/vsftpd/user_list
echo "ftpuser02" | sudo tee -a /etc/vsftpd/user_list
instead of putting our hands up and disabling SElinux, we are going to setup the policies correctly
Find the available policies using getsebool -a | grep ftp
getsebool -a | grep ftp
ftpd_anon_write --> off
ftpd_connect_all_unreserved --> off
ftpd_connect_db --> off
ftpd_full_access --> off
ftpd_use_cifs --> off
ftpd_use_fusefs --> off
ftpd_use_nfs --> off
ftpd_use_passive_mode --> off
httpd_can_connect_ftp --> off
httpd_enable_ftp_server --> off
tftp_anon_write --> off
tftp_home_dir --> off
[lxadmin@vls-BackendSFTP02 _logs]$
[lxadmin@vls-BackendSFTP02 _logs]$
[lxadmin@vls-BackendSFTP02 _logs]$ getsebool -a | grep ftp
ftpd_anon_write --> off
ftpd_connect_all_unreserved --> off
ftpd_connect_db --> off
ftpd_full_access --> off
ftpd_use_cifs --> off
ftpd_use_fusefs --> off
ftpd_use_nfs --> off
ftpd_use_passive_mode --> off
httpd_can_connect_ftp --> off
httpd_enable_ftp_server --> off
tftp_anon_write --> off
tftp_home_dir --> off
Set SELinux boolean values
sudo setsebool -P ftpd_use_passive_mode on
sudo setsebool -P ftpd_use_cifs on
sudo setsebool -P ftpd_full_access 1
"setsebool" is a tool for setting SELinux boolean values, which control various aspects of the SELinux policy.
"-P" specifies that the boolean value should be set permanently, so that it persists across system reboots.
"ftpd_use_passive_mode" is the name of the boolean value that should be set. This boolean value controls whether the vsftpd FTP server should use passive mode for data connections.
"on" specifies that the boolean value should be set to "on", which means that vsftpd should use passive mode for data connections.
Enable ftp_home_dir --> on if you are using chroot
Add a new file context rule to the system.
sudo semanage fcontext -a -t public_content_rw_t "/FTP_DATA_DISK/FTP_Root/(/.*)?"
"fcontext" is short for "file context", which refers to the security context that is associated with a file or directory.
"-a" specifies that a new file context rule should be added to the system.
"-t" specifies the new file context type that should be assigned to files or directories that match the rule.
"public_content_rw_t" is the name of the new file context type that should be assigned to files or directories that match the rule. In this case, "public_content_rw_t" is a predefined SELinux type that allows read and write access to files and directories in public directories, such as /var/www/html.
"/FTP_DATA_DISK/FTP_Root/(/.)?" specifies the file path pattern that the rule should match. The pattern includes the "/FTP_DATA_DISK/FTP_Root/" directory and any subdirectories or files beneath it. The regular expression "/(.)?" matches any file or directory name that may follow the "/FTP_DATA_DISK/FTP_Root/" directory path.
In summary, this command sets the file context type for all files and directories under the "/FTP_DATA_DISK/FTP_Root/" directory and its subdirectories to "public_content_rw_t", which allows read and write access to these files and directories.
Reset the SELinux security context for all files and directories under the “/FTP_DATA_DISK/FTP_Root/”
sudo restorecon -Rvv /FTP_DATA_DISK/FTP_Root/
"restorecon" is a tool that resets the SELinux security context for files and directories to their default values.
"-R" specifies that the operation should be recursive, meaning that the security context should be reset for all files and directories under the specified directory.
"-vv" specifies that the command should run in verbose mode, which provides more detailed output about the operation.
"/FTP_DATA_DISK/FTP_Root/" is the path of the directory whose security context should be reset.
Setup Fail2ban
Install fail2ban
sudo dnf install fail2ban
Create the jail.local file
This file is used to overwrite the config blocks in /etc/fail2ban/fail2ban.conf
journalctl -u fail2ban will help you narrow down any issues with the service
Testing
sudo tail -f /var/log/fail2ban.log
Fail2ban injects and manages the following rich rules
Client will fail to connect using FTP until the ban is lifted
Remove the ban IP list
#get the list of banned IPs
sudo fail2ban-client get vsftpd banned
#Remove a specific IP from the list
sudo fail2ban-client set vsftpd unbanip <IP>
#Remove/Reset all the the banned IP lists
sudo fail2ban-client unban --all
This should get you up and running, For the demo I just used Unencrypted FTP on port 21 to keep things simple, Please utilize SFTP with the letsencrypt certificate for better security. i will cover this on another article and link it here