Discord Twitter GitHub

How to Implement a Service Weaver Deployer

Michael Whittaker
April 5, 2023 (Updated August 14, 2023)

Service Weaver allows you to deploy an application in many different ways. For example, you can deploy an application in a single process, across multiple processes, or in the cloud. The code that deploys a Service Weaver application is called, unsurprisingly, a deployer. This blog post explains what deployers are and how to implement one. We'll assume you're familiar with how to write Service Weaver applications. If you're not, we recommend you read the step-by-step tutorial.

Overview

A Service Weaver application consists of a number of components. The application is compiled into a single application binary. A deployer deploys an application by running the binary multiple times, often across multiple machines. Every instance of the binary runs a subset of the components. To learn which components to run, the binary links in a small background agent called a weavelet, which a deployer communicates with using an envelope. This is illustrated below.

An architecture diagram of a deployer. An app with components A, B, and C is compiled into a binary which is deployed by a deployer across three weavelets.

In this blog post, we provide a high-level overview of weavelets, deployers, and envelopes. Then, we get down to the nitty-gritty of exactly how they work by implementing a multiprocess deployer completely from scratch.

Weavelets

To understand deployers, we must first understand weavelets. A Service Weaver application is compiled into a single executable binary. The Service Weaver libraries linked into the binary include a small agent called a weavelet, which is created when you call weaver.Run. A weavelet's main responsibility is to start and manage a set of components.

When a Service Weaver application is deployed, there isn't just one weavelet. If there were, Service Weaver applications wouldn't be very distributed. Rather, deployers run your binary multiple times—in different processes across different machines—to launch multiple weavelets which work together to execute your distributed application.

Every weavelet hosts a potentially different set of components. Because components are replicated, a component may be hosted by multiple weavelets. For example, consider an application with components A, B, and C. An example deployment consisting of three weavelets is shown in the figure below. Weavelet 1 hosts components A and B; weavelet 2 hosts components B and C, and weavelet 3 hosts component C.

Three weavelets hosting different subsets of components A, B, and C. Each weavelet has its own network address.

You'll also notice that every weavelet has a unique network address. Weavelets use these addresses to execute remote methods calls. For example, imagine component A on weavelet 1 in the figure above wants to call a method on component C. Weavelet 1 will contact either weavelet 2 on address 2.2.2.2 or weavelet 3 on address 3.3.3.3 to execute the method.

Deployers

A deployer distributes a Service Weaver application by launching and managing a set of weavelets. Managing weavelets involves four main responsibilities related to (1) components, (2) listeners, (3) telemetry, and (4) security.

  1. Components. A deployer starts weavelets and tells them which components to host. A deployer also ensures that weavelets know the addresses of other weavelets. If a deployer starts a new weavelet, for example, the deployer notifies all other weavelets of the existence of the new weavelet, including its address and the components it's hosting. Conversely, if a deployer detects that a weavelet has failed, the deployer notifies all other weavelets of its failure.

  2. Listeners. When a component wants to serve external traffic, it requests a network listener. The deployer picks an address for the listener and ensures that the listener is publicly accessible. Multiple weavelets may share the same listener, and a deployer must ensure that traffic is balanced across them. This often involves running or configuring a proxy.

  3. Telemetry. A deployer collects, aggregates, and exports all telemetry produced by weavelets. This includes logs, metrics, traces, and profiles.

  4. Security. A deployer can optionally enable mTLS between components. When mTLS is enabled, a deployer is responsible for distributing and validating certificates. To keeps things as simple as possible, we'll leave mTLS disabled for the remainder of this article.

A deployer and a weavelet communicate by making remote procedure calls over Unix domain sockets to each other. We call the part of a deployer that communicates with a weavelet an envelope. New deployers can be built by using ServiceWeaver's Envelope API.

Communication between an envelope and a weavelet is either weavelet initiated or envelope initiated. Weavelet initiated communication shows up as a method call to an EnvelopeHandler interface supplied by the deployer implementation.

Envelope initiated communication is performed by invoking a method on Envelope.

A Simple Multiprocess Deployer

In this section, we implement a fully working multiprocess deployer. We'll compile our deployer into an executable called deploy. We'll then be able to deploy Service Weaver binaries by running ./deploy <Service Weaver binary>. To make things simple, our deployer won't co-locate or replicate any components. Every component will run by itself in a separate process. We begin by declaring types for the deployer and for weavelets.

package main

import ...

// deployer is a simple multiprocess deployer that doesn't implement
// co-location or replication. That is, every component is run in its own OS
// process, and there is only one replica of every component.
type deployer struct {
    mu       sync.Mutex          // guards handlers
    handlers map[string]*handler // handlers, by component
}

// A handler handles messages from a weavelet. It implements the
// EnvelopeHandler interface.
type handler struct {
    deployer *deployer          // underlying deployer
    envelope *envelope.Envelope // envelope to the weavelet
    address  string             // weavelet's address
}

// Check that handler implements the envelope.EnvelopeHandler interface.
var _ envelope.EnvelopeHandler = &handler{}

Next, we implement a spawn method that spawns a weavelet to host a component.

  1. To spawn the weavelet and get an Envelope to communicate with it, we call the envelope.NewEnvelope function. This function takes in a WeaveletArgs that's passed to the weavelet and an AppConfig that describes the application. NewEnvelope runs the provided Service Weaver binary—flag.Arg(0) in this case—in a subprocess. It then returns an Envelope which communicates with the weavelet via remote procedure calls.
  2. We call the UpdateComponents method to tell the weavelet which component to run. A deployer should call UpdateComponents whenever there is a change to the set of components a weavelet should be running.
  3. We call envelope.Serve to handle requests from the weavelet.
// The unique id of the application deployment.
var deploymentId = uuid.New().String()

// spawn spawns a weavelet to host the provided component (if one hasn't
// already spawned) and returns a handler to the weavelet.
func (d *deployer) spawn(component string) (*handler, error) {
    d.mu.Lock()
    defer d.mu.Unlock()

    // Check if a weavelet has already been spawned.
    if h, ok := d.handlers[component]; ok {
        // The weavelet has already been spawned.
        return h, nil
    }

    // Spawn a weavelet in a subprocess to host the component.
    info := &protos.WeaveletArgs{
        App:             "app",                     // the application name
        DeploymentId:    deploymentId,              // the deployment id
        Id:              uuid.New().String(),       // the weavelet id
        Mtls:            false,                     // don't enable mtls
        RunMain:         component == runtime.Main, // should the weavelet run main?
        InternalAddress: "localhost:0",             // internal address of the weavelet
    }
    config := &protos.AppConfig{
        Name:   "app",       // the application name
        Binary: flag.Arg(0), // the application binary
    }
    envelope, err := envelope.NewEnvelope(context.Background(), info, config, envelope.Options{})
    if err != nil {
        return nil, err
    }
    h := &handler{
        deployer: d,
        envelope: envelope,
        address:  envelope.WeaveletAddress(),
    }

    go func() {
        // Inform the weavelet of the component it should host.
        envelope.UpdateComponents([]string{component})
    }()

    go func() {
        // Handle messages from the weavelet.
        envelope.Serve(h)
    }()

    // Return the handler.
    d.handlers[component] = h
    return h, nil
}

Now, we implement the EnvelopeHandler methods, which handle the weavelet initiated communication to the deployer.

Components

First, we implement ActivateComponent. When some component T is needed, the weavelet calls the EnvelopeHandler.ActivateComponent method to activate T. ActivateComponent should start the component—potentially with multiple replicas—if it hasn't already been started.

Our handler calls deployer.spawn to spawn a new weavelet to host the component. The handler then calls UpdateRoutingInfo to inform the requesting weavelet of the newly spawned weavelet's address. This allows components on the requesting weavelet to perform RPCs with the component on the newly spawned weavelet.

Referring back to the figure above as an example, if component A on weavelet 1 activates component C, then the deployer spawns weavelets 2 and 3 (if they haven't been spawned already) and then tells weavelet 1 the addresses of weavelets 2 and 3.

A deployer should call UpdateRoutingInfo whenever there is a change to the routing information of a component for which a weavelet has called ActivateComponent. For example, if a deployer detects that a weavelet hosting component A has crashed, it should call UpdateRoutingInfo on all weavelets that have called ActivateComponent on A with new routing information that omits the address of the failed weavelet.

// Responsibility 1: Components.
func (h *handler) ActivateComponent(_ context.Context, req *protos.ActivateComponentRequest) (*protos.ActivateComponentReply, error) {
    // Spawn a weavelet to host the component, if one hasn't already been
    // spawned.
    spawned, err := h.deployer.spawn(req.Component)
    if err != nil {
        return nil, err
    }

    // Tell the weavelet the address of the requested component.
    h.envelope.UpdateRoutingInfo(&protos.RoutingInfo{
        Component: req.Component,
        Replicas:  []string{spawned.address},
    })

    return &protos.ActivateComponentReply{}, nil
}
weaver multi, like our deployer, spawns weavelets in subprocesses. weaver gke spawns weavelets in Kubernetes deployments.

Listeners

Next, we implement the listener methods. When a component requests a network listener, Envelopehandler.GetListenerAddress method is invoked. This method returns the address on which the component should listen. Our simple deployer always returns "localhost:0".

After a weavelet receives an address from GetListenerAddress, it creates a network listener on the address and invokes the ExportListener method with the concrete address that it's listening on. For example, after a weavelet receives "localhost:0" from our GetListenerAddress implementation, it listens on "localhost:0". This results in a dialable address, say "127.0.0.1:35879", which the weavelet then reports to the ExportListener handler. Our simple deployer merely prints out this address for users to contact directly.

// Responsibility 2: Listeners.
func (h *handler) GetListenerAddress(_ context.Context, req *protos.GetListenerAddressRequest) (*protos.GetListenerAddressReply, error) {
    return &protos.GetListenerAddressReply{Address: "localhost:0"}, nil
}

func (h *handler) ExportListener(_ context.Context, req *protos.ExportListenerRequest) (*protos.ExportListenerReply, error) {
    // This simplified deployer does not proxy network traffic. Listeners
    // should be contacted directly.
    fmt.Printf("Weavelet listening on %s\n", req.Address)
    return &protos.ExportListenerReply{}, nil
}
weaver multi's implementation of GetListenerAddress always returns "localhost:0". Its ExportListener runs a local HTTP proxy on the address specified in the LocalAddress field of the ListenerOptions passed to Listener. This proxy balances traffic across the addresses of the listeners reported to ExportListener. weaver gke implements listeners by configuring load balancers in Google Cloud.

Telemetry

Next, we implement the telemetry methods. All logs produced by a weavelet are received by the LogBatch method. Our deployer uses a pretty printer from Service Weaver's logging library to print the logs to stdout. Similarly, all traces produced by a weavelet are received by the HandleTraceSpans function. For simplicity, our deployer ignores traces.

// Responsibility 3: Telemetry.
func (h *handler) LogBatch(_ context.Context, batch *protos.LogEntryBatch) error {
    pp := logging.NewPrettyPrinter(colors.Enabled())
    for _, entry := range batch.Entries {
        fmt.Println(pp.Format(entry))
    }
    return nil
}

func (h *handler) HandleTraceSpans(context.Context, *protos.TraceSpans) error {
    // This simplified deployer drops traces on the floor.
    return nil
}
weaver multi writes logs and traces to files. weaver gke exports logs and traces to Cloud Logging and Cloud Trace.

Security

Because our deployer does not enable mTLS, we can leave GetSelfCertificate, VerifyClientCertificate, and VerifyServerCertificate unimplemented. They will never be called.

// Responsibility 4: Security.
func (*handler) GetSelfCertificate(context.Context, *protos.GetSelfCertificateRequest) (*protos.GetSelfCertificateReply, error) {
    // This deployer doesn't enable mTLS.
    panic("unused")
}

func (*handler) VerifyClientCertificate(context.Context, *protos.VerifyClientCertificateRequest) (*protos.VerifyClientCertificateReply, error) {
    // This deployer doesn't enable mTLS.
    panic("unused")
}

func (*handler) VerifyServerCertificate(context.Context, *protos.VerifyServerCertificateRequest) (*protos.VerifyServerCertificateReply, error) {
    // This deployer doesn't enable mTLS.
    panic("unused")
}

Main

Finally, we implement a main function for the deployer. We create a deployer, spawn the main component, and block.

func main() {
    flag.Parse()
    d := &deployer{handlers: map[string]*handler{}}
    d.spawn(runtime.Main)
    select {} // block forever
}

If we compile our deployer, we can pass it a Service Weaver binary to deploy.

$ go build -o deploy main.go       # compile the deployer
$ ./deploy <Service Weaver binary> # deploy an application

Advanced Deployer Features

The multiprocess deployer in the previous section was designed to be as simple as possible. Real-world deployers, on the other hand, require a number of more advanced features. Enumerating and explaining how to implement these features is beyond the scope of this blog post, but we'll summarize some advanced features here. You can review the implementations of our weaver multi and weaver gke deployers for reference.