Chapter 2.5: A Brief gRPC Primer
Just enough to make Chapter 3 make sense — not a full gRPC book. This chapter goes slower than the rest of the book, on purpose. Everything from Chapter 3 onward assumes these ideas feel comfortable, so it's worth sitting with them properly now, even if some of this feels obvious.
Start with something you already know: calling a function
Before "remote," before "procedure call," before any of the jargon — start with the plainest possible thing: calling a function in a normal Go program.
func Add(a, b int) int {
return a + b
}
func main() {
result := Add(2, 3)
fmt.Println(result) // 5
}
When main calls Add(2, 3), here's what actually happens, mechanically:
your program jumps to the place in memory where Add's code lives, hands
it 2 and 3, Add runs, and control jumps back to main with 5 in
hand. This works because Add and main are part of the same running
program, sitting in the same computer's memory. Jumping to Add's code
is cheap and instant — it's all right there.
That's a "procedure call" — "procedure" is just an old, language-neutral word for "function." Nothing remote about it yet.
Now the problem: what if the function lives somewhere else?
Here's the situation CSI actually puts you in. Your driver is one running
program. The thing that wants to ask it "what's your name and version?"
(GetPluginInfo, which we'll build in Chapter 3) is a different running
program — a sidecar container, possibly even on a different machine
entirely.
You can't just write driver.GetPluginInfo() from the sidecar's code the
way main called Add() above. There's no shared memory to jump into —
the sidecar's program and your driver's program are two separate processes
that don't know anything about each other's internals. All they can do is
send each other bytes over some connection (a network, or, as we'll get to
below, a file acting like a pipe).
So the real question is: how do you make something that feels like
calling a function — you write driver.GetPluginInfo(), you get a normal
return value back — when the actual function is running in a different
program, maybe on a different machine, and the only way to reach it is by
sending bytes back and forth?
That question, and the standard answer to it, is what "RPC" is.
What RPC actually means, word by word
RPC stands for Remote Procedure Call. Read it backwards, it explains itself:
- Call — you're calling a function, same as
Add(2, 3)above. - Procedure — the function you're calling.
- Remote — except this time, the function isn't in your program. It's somewhere else — another process, another machine.
RPC is a general pattern (not tied to Go, or to gRPC specifically, or even to any one programming language) for making a call to a remote function feel like calling a local one, even though under the hood, something much more involved has to happen: your arguments need to be packaged up, sent across a connection, unpacked and run on the other side, and the result needs to make the same trip back.
An analogy that makes the mechanics concrete
Think about ordering food at a restaurant through a waiter.
You (the calling code) don't walk into the kitchen and cook your own burger. You tell the waiter "I'd like a burger." The waiter doesn't cook either — they write your order down, walk it to the kitchen, and hand it to the chef. The chef (the real function) does the actual work — cooks the burger — and hands the finished plate back to the waiter, who carries it back and puts it in front of you.
From where you're sitting, you asked for a burger and a burger appeared. You never had to know anything about the kitchen, the chef, or how the order got there. The waiter is what made that illusion possible.
RPC's "stub" plays the exact role of that waiter, on both ends:
- Your code calls what looks like a normal local function — this fake local function is called the client stub. It exists purely to play along with the illusion.
- Instead of running any real logic, the client stub packages up your arguments into a message and sends that message across a connection to the other program. Packaging data into a format that can be sent as raw bytes is called serializing it (turning a Go struct in memory into a flat sequence of bytes that can travel over a wire); the receiving side reverses this — deserializing — to turn those bytes back into a usable value.
- On the other side, a matching piece of code — the server stub —
receives those bytes, deserializes them back into arguments, and calls
the real function, the one that actually does the work (in our case,
a method on your
Driverstruct). - The real function runs, produces a return value, and the whole thing happens in reverse: the server stub serializes the result, sends it back across the connection, and the client stub on your side deserializes it and hands it back to your code as an ordinary return value.
Your code never sees any of steps 2 through 4. It just called a function
and got a value back — same experience as Add(2, 3), even though a
network round trip happened in between. That's the whole point of RPC:
hide the network behind something that looks like an ordinary function
call.
So what is gRPC, then?
RPC is the idea — "make a remote call look local." It doesn't say exactly how you describe what functions exist, exactly how arguments get turned into bytes, or exactly what carries those bytes from one program to the other. Those are implementation details, and over the decades, lots of different specific systems have implemented the RPC idea in different ways.
gRPC is one specific, concrete implementation of the RPC idea, built by Google (the lowercase "g" has meant different things across Google's own documentation over the years — not worth memorizing, just know gRPC is a specific product, not a generic term). gRPC answers the two questions RPC leaves open:
Question 1: how do you describe, precisely, what functions exist and what their inputs and outputs look like?
gRPC's answer is a language called Protocol Buffers (nicknamed
"protobuf"), used to write .proto files. A .proto file describes a
function signature the same way a Go interface would, but in a way that
isn't tied to any one programming language:
service Identity {
rpc GetPluginInfo(GetPluginInfoRequest) returns (GetPluginInfoResponse);
}
Read that plainly: "there's a service called Identity. It has one
function, GetPluginInfo. You call it with a GetPluginInfoRequest and
you get back a GetPluginInfoResponse." That's it — it's just a function
signature, written in a language-neutral way so that a Go program and a
Python program could both agree on exactly what this function looks like,
without either one needing to understand the other's syntax.
A separate tool, protoc (the "protobuf compiler"), reads that .proto
file and generates real, runnable code in whatever language you need —
Go, in our case. That generated code includes the client stub and the
server-side interface described above, already written for you:
type IdentityServer interface {
GetPluginInfo(context.Context, *GetPluginInfoRequest) (*GetPluginInfoResponse, error)
}
This is exactly the "server stub" idea from the waiter analogy, except you
never hand-write it — protoc generates it from the .proto file. Your
only job is to write a Go struct with a GetPluginInfo method matching
that signature; gRPC and the generated code handle everything about
turning network bytes into a Go function call and back.
One thing worth knowing now, because it'll save confusion later: for
this book, you will never touch a .proto file or run protoc
yourself. The CSI project already did that step. The
container-storage-interface/spec Go package you pulled in last chapter
is the already-generated code — the IdentityServer, ControllerServer,
and NodeServer interfaces are sitting there, ready to import, the same
way IdentityServer above was generated from the .proto snippet. Your
job starts one level in from where most gRPC tutorials start: write the
struct, implement the methods the interface already demands.
Question 2: once your arguments are serialized into bytes, what actually carries those bytes from one program to the other?
gRPC's answer is HTTP/2. You've probably used HTTP before, even if only by typing a URL into a browser — HTTP is the protocol (a shared, agreed-upon set of rules) that web browsers and web servers use to talk to each other. HTTP/2 is a newer, more efficient version of that same protocol, and gRPC uses it as its "carrier" — the actual mechanism moving your serialized bytes from the client stub, across a connection, to the server stub. You don't need to know HTTP/2's internals for this book; just know it's the transport gRPC rides on, playing the same role a road plays for a delivery truck. The truck (your serialized message) doesn't care much about the road's engineering — it just needs a reliable way to get from A to B.
Why gRPC, specifically, and why CSI picked it
Two reasons matter for what you're building:
- Language independence. The sidecars (external-provisioner,
node-driver-registrar, and friends) are Go binaries maintained by
kubernetes-csi, but your own driver doesn't have to be written in Go — CSI drivers exist in Rust, Python, C++, and more. This works because the actual contract is the.protofile, not any particular programming language. Any language with a protobuf/gRPC library can play. - A real, typed, checked contract, up to a point. Because the
.protofile spells out exactly what fields exist and what types they are, both sides get that checked automatically — in Go's case, at compile time, before your program even runs. "Which fields are required," though, is a different, weaker guarantee: CSI's spec marks fieldsREQUIREDin comments and inspec.md's own prose, not in a way protobuf enforces. Generated Go code will happily construct a request with an emptyNameor a nilVolumeCapability— the compiler has no objection. Enforcing "required" is exactly the job this book's own handler code takes on starting next chapter, and it's also whycsi-sanity(Chapter 9) exists as a separate conformance suite: compile- time types catch "wrong shape," not "missing what the spec demands." Compare that to a typical REST API (an HTTP API returning JSON, without gRPC), where "what fields does this endpoint actually require" often lives only in documentation that may or may not be accurate or current — protobuf at least gives you the shape for free; CSI still needs runtime checks for the rest.
One more piece: how the bytes actually travel — sockets
Zoom in one more level on "a connection" from the waiter analogy. In networking, the mechanism two programs use to open a connection and pass bytes back and forth is called a socket. Think of a socket as an address plus a plumbing hookup: something you can "connect to," after which bytes can flow in both directions until one side closes the connection.
Almost every gRPC tutorial you'll find sets up a socket as a TCP
socket — an address made of an IP address and a port number, like
localhost:50051. This is what lets two programs talk over an actual
network, potentially across the internet.
CSI doesn't do that. Instead, the driver and its sidecars talk over a
Unix domain socket — which, despite having "socket" in the name and
working the same way from your code's point of view, isn't a network
address at all. It's a special kind of file that lives on disk (typically
at the path /csi/csi.sock), and two programs on the same machine can
open it and use it to exchange bytes, the same way they'd use a TCP
socket, just without any actual network involved.
Why CSI does it this way: your driver container and the sidecar
containers (external-provisioner, node-driver-registrar, etc.) all run
inside the same Kubernetes pod, sharing a directory via something called
an emptyDir volume. Since they're already on the same machine and
sharing a filesystem, there's no reason to expose your driver over the
network at all — doing so would mean anything else in the cluster could
potentially try to call your Controller service's CreateVolume, with
nothing checking who's asking. A file-based socket sidesteps that
entirely: only programs that can already see that file on disk (i.e.,
containers in that same pod) can connect to it. The socket file itself
is the access boundary — nothing about the CSI RPCs adds authentication
on top of that.
In Go, switching from a TCP socket to a Unix domain socket changes exactly one line versus what most tutorials show you:
// what most gRPC tutorials show — a TCP socket, a network port:
lis, err := net.Listen("tcp", ":50051")
// what CSI drivers actually do — a Unix domain socket, a file path:
lis, err := net.Listen("unix", "/csi/csi.sock")
Everything else about setting up the server — grpc.NewServer(),
registering your service, calling Serve() — is identical either way. We
write this for real in Chapter 3.
Putting the whole round trip together
Worth having the entire path in your head, plainly, before we build it.
Say a sidecar calls GetPluginInfo:
- The sidecar (or, when you're testing by hand, a tool called
grpcurl, which we'll use in Chapter 3) opens a connection to your Unix socket file — same idea as the waiter approaching your table. - It calls
GetPluginInfo, handing over a request. Behind the scenes, the client stub (generated from the.protofile, remember) serializes that request into protobuf bytes and sends it over the socket, carried by HTTP/2. - Your server deserializes those bytes back into a
*csi.GetPluginInfoRequestGo struct and hands it to yourDriverstruct'sGetPluginInfomethod — the real function, the "chef." - Your method does its (very small, in this case) work and returns a
*csi.GetPluginInfoResponseand a Goerror— completely ordinary Go return values, nothing gRPC-specific about writing this part. - If that
erroris non-nil, gRPC needs a way to communicate what kind of failure happened — not just "something went wrong," but which of a known, fixed set of reasons. So gRPC translates your Go error into a gRPC status code: a fixed enum of reasons likeOK,NotFound,AlreadyExists,InvalidArgument, and so on (this is a gRPC-specific concept, a different list from HTTP status codes like 404 or 500, though it plays a similar role). The CSI spec is written in terms of exactly these codes — it says things like "this method MUST returnALREADY_EXISTS" when a volume by that name already exists — so this status-code mechanism is what turns a plain-English spec requirement into real, checkable Go code, almost always written asstatus.Error(codes.AlreadyExists, "...")using thegoogle.golang.org/grpc/statuspackage. You'll type that pattern constantly starting next chapter. - The response (or the error, translated into its status code) gets serialized and sent back over the same socket to the sidecar, which deserializes it and — from its point of view — just got a return value from a function call. The illusion holds all the way through.
One thing to not worry about yet
Streaming RPCs (calls that send many messages over time instead of one
request/one response), interceptors (middleware that runs before or after
every call), TLS (encrypting the connection), and deadlines/cancellation
propagation (telling a slow call to give up early) are all real gRPC
concepts — none of them are needed to get a working CSI driver's happy
path running. Every RPC in CSI is a simple unary call: one request goes
out, one response comes back, exactly like the GetPluginInfo walkthrough
above. We'll mention deadlines briefly when we get to Probe in Chapter
3. Interceptors turn out not to stay off this list either — Chapter 11
comes back to them once there's a real operational reason (logging,
metrics) to want code that runs before and after every RPC. Streaming
and TLS never come up again; this driver has no need for either.
That's the whole primer. Chapter 3 builds directly on top of every piece
of it — the stub idea, protobuf, sockets, status codes — starting with
writing the Driver struct and getting the Identity service answering
real calls over that Unix socket.