Plugins as Subprocesses: RPC-Based Architecture in Go
Runtime plugins in Go can be built two ways: loading shared libraries with -buildmode=plugin, or launching separate binaries that communicate over RPC. The latter approach, popularized by HashiCorp's go-plugin package, trades some performance for process isolation and flexibility. Here's how it works in practice.
Understanding go-plugin
HashiCorp developed go-plugin for its own tools, and the package has seen years of production use in projects like Terraform and Vault. The core model is simple: every plugin is an independent binary, launched by the host application as a subprocess. The host and each plugin communicate over a network connection using either net/rpc or gRPC.
Because go-plugin leaves the choice of RPC mechanism to you, its API initially feels backward. You define your own RPC methods for both server (plugin) and client (host) sides, then register them by implementing the package's Plugin interface. This raises a fair question: if you're writing your own RPC anyway, what does go-plugin actually add?
The answer is a set of infrastructure features that sit around the RPC layer:
- Connection handling, with Unix domain sockets on Linux for performance and TCP elsewhere.
- Automatic discovery of the listening address when launching a plugin subprocess, including verification that the binary is the intended plugin.
- Protocol versioning so the host won't talk to incompatible plugin builds.
- Liveness pings to detect hung or dead plugins.
- Optional mTLS between host and plugin, useful when they run on different machines.
- Redirection of plugin stdout, stderr, and logs back to the host process.
- Multiplexing multiple logical plugins onto a single connection via yamux, which also enables plugins to call back into the host application.
Rebuilding htmlize with RPC Plugins
To see how these pieces fit together, consider an RPC-based version of the htmlize example used throughout this plugin series. The full implementation is on GitHub.
Discovery and Registration
Since plugins are just binaries, their location and naming aren't prescribed by go-plugin. The package offers a Discover function as a thin wrapper over filesystem globbing, but our application lets a Manager type scan a directory and treat each file in it as a potential plugin. A HandshakeConfig passed when creating a client verifies that each loaded binary is genuinely a plugin for this application—not something built for a different program or version.
Once a plugin is launched, go-plugin handles the rest. It starts the subprocess, reads its stdout to learn the listening address (Unix domain socket or TCP depending on the OS), establishes the connection, and sets up RPC. The host client then invokes methods through the agreed-upon interface.
Application Hooks via the Exposed Interface
In the RPC model, a plugin's exposed interface is the central point of communication. For htmlize, that interface is:
// Htmlizer is the interface plugins have to implement. To avoid calling the
// plugin for roles it doesn't support, it has to tell the plugin managers
// which roles it wants to be invoked on by implementing the Hooks() method.
type Htmlizer interface {
// Hooks returns a list of the hooks this plugin wants to register.
// Hooks can have one of the following forms:
//
// * "contents": the plugin's ProcessContents method will be called on
// the post's complete contents.
//
// * "role:NN": the plugin's ProcessRole method will be called with role=NN
// and the role's value when a :NN: role is encountered in the
// input.
Hooks() []string
// ProcessRole is called on roles the plugin requested in the list returned
// by Hooks(). It takes the role name, role value in the input and the post
// and should return the transformed role value.
ProcessRole(role string, val string, post content.Post) string
// ProcessContents is called on the entire post contents, if requested in
// Hooks(). It takes the contents and the post and should return the
// transformed contents.
ProcessContents(val string, post content.Post) string
}
These methods are invoked locally in the application; go-plugin transparently translates them into RPC calls. Note from the interface comments how conditional registration is handled. Plugins opt into specific text roles, and the host only calls a plugin when it has registered for the role at hand—avoiding N wasted RPC calls for every role encountered.
go-plugin itself doesn't support this selective pattern, so our Manager implements it manually:
type Manager struct {
roleHooks map[string]Htmlizer
contentsHooks []Htmlizer
pluginClients []*goplugin.Client
}
In its LoadPlugins method:
// Query the plugin for its capabilities -- the hooks it supports.
// Based on this information, register the plugin with the appropriate
// role or contents hooks.
capabilities := impl.Hooks()
for _, cap := range capabilities {
if cap == "contents" {
m.contentsHooks = append(m.contentsHooks, impl)
} else {
parts := strings.Split(cap, ":")
if len(parts) == 2 && parts[0] == "role" {
m.roleHooks[parts[1]] = impl
}
}
}
Each plugin is queried for its supported hooks, and only the matching handlers are registered. When the formatter encounters a role like :tt:, only the plugin that claimed that role gets invoked.
The impl value here is a PluginClientRPC type that implements the Htmlize interface by making RPC calls to the plugin:
// PluginClientRPC is used by clients (main application) to translate the
// Htmlize interface of plugins to RPC calls.
type PluginClientRPC struct {
client *rpc.Client
}
func (c *PluginClientRPC) Hooks() []string {
var reply HooksReply
if err := c.client.Call("Plugin.Hooks", HooksArgs{}, &reply); err != nil {
log.Fatal(err)
}
return reply.Hooks
}
func (c *PluginClientRPC) ProcessContents(val string, post content.Post) string {
var reply ContentsReply
if err := c.client.Call(
"Plugin.ProcessContents",
ContentsArgs{Value: val, Post: post},
&reply); err != nil {
log.Fatal(err)
}
return reply.Value
}
func (c *PluginClientRPC) ProcessRole(role string, val string, post content.Post) string {
var reply RoleReply
if err := c.client.Call(
"Plugin.ProcessRole",
RoleArgs{Role: role, Value: val, Post: post},
&reply); err != nil {
log.Fatal(err)
}
return reply.Value
}
Each RPC method follows the convention of a dedicated argument and response type (RoleArgs, RoleReply, and so on). A matching server-side wrapper on the plugin translates in the opposite direction.
Calling Back into the Host
Passing large data structures to a plugin is one thing—they must be serialized over the wire, which is fine for types like content.Post in this example. But what if a plugin needs to invoke functionality in the host, or access something like a full database handle?
For those cases, go-plugin supports bidirectional communication. By multiplexing multiple RPC channels over the same connection with yamux, the host can open its own RPC server that plugins are free to call. This capability is left out of the htmlize example to keep it readable, but a separate sample demonstrates the pattern with net/rpc.
A Complete Sample Plugin
With the scaffolding in place, writing a new plugin is straightforward. Here's one that renders the :tt: role as an HTML <tt> element:
package main
import (
"fmt"
"example.com/content"
"example.com/plugin"
goplugin "github.com/hashicorp/go-plugin"
)
type TtHtmlizer struct{}
func (TtHtmlizer) Hooks() []string {
return []string{"role:tt"}
}
func (TtHtmlizer) ProcessContents(val string, post content.Post) string {
return val
}
func (TtHtmlizer) ProcessRole(role string, val string, post content.Post) string {
return fmt.Sprintf("<tt>%s</tt>", val)
}
func main() {
goplugin.Serve(&goplugin.ServeConfig{
HandshakeConfig: plugin.Handshake,
Plugins: map[string]goplugin.Plugin{
"htmlize": &plugin.HtmlizePlugin{
Impl: TtHtmlizer{},
},
},
})
}
The setup cost is entirely in the RPC layer—interfaces, argument types, and wrapper code. Once that exists, a plugin is just an interface implementation plus a main function that registers it.
Shared Libraries vs. RPC
Both approaches to runtime plugins have legitimate uses, and the trade-offs mirror their architectures.
Shared library plugins run in-process, so a plugin call is a plain Go function call with no serialization. You can pass references to complex data structures directly across the boundary. The downsides are real, though: strict source compatibility requirements with the host, and no Windows support—with no clear path to ever getting it.
RPC-based plugins pay a performance cost on every call. Even over a Unix domain socket or localhost TCP, each invocation means serializing arguments into a linear buffer, shipping them across the connection, and deserializing at the other end—orders of magnitude slower per call than a direct function invocation.
What RPC gives in return is isolation. A plugin runs in its own process, so a crash doesn't take down the host. A malicious or buggy plugin has limited access to the host's memory and can even run under different system permissions. In principle, a plugin could be sandboxed in a container.
RPC plugins also cross machine boundaries. The interface uses network sockets, and go-plugin's ReattachConfig supports attaching to an already-running plugin elsewhere on the network. Finally, because the interface is RPC, plugins don't have to be written in Go at all—with gRPC, the data contract is protocol buffers, opening the door to plugins in any language.



