We're excited to talk about a new preview feature in Habitat that we feel will unlock some more interesting use cases that community members have been wanting for a while. In this post, we'll talk about composite packages and how they might help you.
What is a Composite Package?
As the name might suggest, a composite package is a special kind of Habitat package that includes other Habitat packages, specifically services. A service is something you would actually run in a Habitat Supervisor: something like core/redis, but not a library like, say, core/linux-headers. Composite packages allow you to group together services that should be run together on the same Supervisor, enabling you to take better advantage of modern deployment patterns with your Habitat services.
In the past, the intrepid Habitat user community has been finding all sorts of creative ways to use Habitat in a variety of ways. Since the sidecar pattern is becoming increasingly prevalent, particularly with microservice architectures and containerized deployments, users have tried to implement it in Habitat, but with limited success. Often, we would see users trying to start multiple services from the
run hook of another coordinating service. Approaches like this may work initially, but won't work in the long run, because the Habitat Supervisor won't be properly supervising additional services that are spawned this way. In fact, shelling out to the
hab binary within a hook is actually undefined behavior.
Composite packages allow you to express the relationship between different Habitat services in a way that lets the Habitat Supervisor properly manage each service. They allow you to continue to create small, focused services, while expressing relationships between them at a higher level.
Okay, but what is a Composite Package, Really?
When you boil it all down, a composite package is just another Habitat package, but one that is all metadata. It is also generated from a
plan.sh file, so your standard Habitat workflow still applies. We'll take a look at the salient features by working through an example. We'll keep things at a fairly high level, in order to focus on the composite-specific details. We'll assume a basic working knowledge of Habitat, but never fear! Our tutorials can bring you up to speed.
A Sample Composite Package
Our example scenario is simple: we have a basic NodeJS application that we would like to put behind an Nginx proxy. Both services are running on the same machine. They are clearly related services, but they are distinct enough to remain separate; they run separate processes, have separate development lifecycles, and so on. A key point for our discussion of composites is that they are deployed to the same machine. Many other scenarios can fit into this template: database and archiver (such as WAL-E for Postgres), service and service mesh proxy (such as Envoy), service and log aggregation agent, and numerous others.
(The scenario we explore here is in fact an evolution of the pattern that Chris Nunciato demonstrated in his Habitat blog post Packaging a Website with Nginx and Habitat. You should read that article when you're done here to compare and contrast approaches.)
You can access the code for all these packages in the habitat-composite-example repository.
Our NodeJS Application
For our application, we'll use the NodeJS sample application introduced by Habitat core maintainer Nell Shamrell-Harrington in her blog post on Node Scaffolding.
This is a simple application, but for our purposes it will work perfectly, with one minor tweak. The original application does not export the port the NodeJS server is listening on. Without this information, our proxy server won't know how to find our application server (and no, we are not going to hard code it!).
The NodeJS scaffolding that the application uses will take an optional
app.port configuration value to set the listening port. We will just add that to the application's
default.toml file, and add a
pkg_export entry into our
plan.sh. When it's all said and done, the files will look like this:
With that change in place, we can move on to our proxy server.
Our Nginx Proxy Server
For our proxy, we'll adapt the example Chris demonstrated in his post. His Nginx server served static content from another Habitat package, but we'll be serving proxied content from another Habitat service.
Here's what our
plan.sh looks like:
This is a standard Habitat plan file (we haven't gotten to the composite part yet!), but we'll point out some important aspects.
First, we have a dependency on the
core/nginx package. This is where the binaries we'll run will come from, but we'll supply our own
run hook and
nginx.conf configuration file (we'll cover those next!).
Second, we have a required bind named
http that is satisfied by an exported value of
port. This is how we will connect to our NodeJS application; it exports the port that it is running on, and then our proxy consumes that information to generate its configuration files.
Finally, we have overridden the
do_install callback functions since they are not required for this plan.
Next, let's look at this Nginx proxy's configuration file.
This is similar to the configuration file that Chris used in his blog post, but with some differences.
First, we've declared an upstream server named
backend; this represents our NodeJS server. It is defined in terms of our
http binding that we declared in our
plan.sh file. Because of how Habitat works, if we were to change the port our NodeJS application was being served on, our Nginx proxy would be notified, and would rewrite and refresh its configuration appropriately. (Incidentally, notice that the host is hardcoded to "localhost", driving home the fact that this is expected to be running in the same Supervisor as our NodeJS application.)
Second, we define our
server stanza to function as a proxy to our NodeJS backend. The
listen value is taken from our proxy application's own configuration, and is the port that the proxy will be serving requests on.
Finally, note that we have set a number of
*_temp_path variables. The current
core/nginx packages have an
nginx binary that was compiled with these paths rooted in
/hab/svc/nginx/var/, which is the correct path assuming that the binary will be running as an
nginx service. In our case, however, it'll be running as our
composite-example-api-proxy service, so we need to set these values to paths that our service can actually write to.
(In reality, the
core/nginx package dates from the early days of Habitat, before we knew what packaging patterns were good. Nginx should really never be running as an "nginx service" directly, but should instead be used as a dependency of your actual application, as illustrated here. While this is an interesting detail, it is not particularly germane to composites per se.)
Finally, let's take a look at the
run hook of our
This is exactly the same as the one that Chris used in his blog post. We simply call the
nginx binary from the
core/nginx package we depend on, but using our configuration file.
Our Composite Package (Finally!)
At last, we're ready to wrap all this up into a composite package. As mentioned earlier, composites are just another type of Habitat package, and are made from a
plan.sh file. This file has a few additional features that we've not seen before, though. Let's take a look at what it would take to wire our NodeJS application up to our Nginx proxy.
pkg_version fields should look familiar, and serve the same role they do in other Habitat packages, but
pkg_bind_map are all new.
pkg_type field is the simplest; a composite package is recognized as such by setting this field to the string
"composite". If you do not include this field, Habitat will treat it as though it were a "standalone" package (which is what every other Habitat package until now has been).
pkg_services array is where you enumerate all the services that are in your composite. Here, we provide the identifiers for our NodeJS application, and for the Nginx proxy we have created.
The final new field,
pkg_bind_map, is also the most exciting. We have already seen how we are connecting the
http binding of our Nginx proxy to the exported
port from our NodeJS application. However, there's no reason that you, the user, should always have to specify this binding on the command line when you start this composite up. It's basically an implementation detail of this particular arrangement of services. It might not be that big of a deal in this particular example, but as you add more services with multiple binds, it really starts to become a challenge. That's where
pkg_bind_map comes in. This lets you declare all the binding relationships between all the services in the composite. Here we are saying that our
cm/composite-example-api-proxy has a bind named
http that is satisfied by the
cm/sample-node-app service. If our proxy had more than one bind, then we could add more mappings, separated by spaces, like so:
A particularly fun part of composites is the fact that the build process is intelligent. If you list a package in your
pkg_services array that isn't actually a service (i.e., it doesn't have a run hook), it will trigger a build error. Additionally, you must also provide at least two services; after all, a composite with a single service isn't providing you anything that the service by itself can't. Finally, you cannot build a composite if it specifies bindings that are not possible. For example, our proxy's
http bind requires an exported value of
port to be satisfied (see the proxy's
plan.sh file above). If our NodeJS application exported a value of
http_port instead, the composite package's build would fail when it detects this mismatch. This is another example of Habitat's overall philosophy of surfacing errors to the user as quickly as possible. It's much better to discover these kinds of issues at build time than at deploy time.
Using the Composite Package
In many ways, using a composite package is not really different from using a standalone package. Let's see what happens when we try to load a composite package in a Habitat Supervisor.
As we can see, it loads each individual service from the composite for us.
If we look at our supervisor's output, we can also see these services starting up. The fact that the proxy can start up at all is a sign that the binds have been properly satisfied, and we didn't have to specify anything on the command line when we loaded the composite!
To test, we can also visit the website on
And we can see in the logs that the proxy and the backend are both participating in servicing the requests:
What Composites Aren't
Composite packages are a new feature of Habitat, and there may be a few rough edges. The current implementation is intended as a way to explore this problem space for Habitat, but it is not necessarily the final implementation. We are very curious to see how composite packages work (or don't!) for you and your particular use cases.
While you can install, start, stop, load, and unload composite packages, you should keep in mind that composite packages can behave differently from the Habitat packages you are used to. In particular, composite packages are not quite a formal runtime construct of Habitat. The individual services are still available and addressable just as though you'd started them manually. The composite-as-a-whole is not directly addressable as though it were a service in its own right. It cannot do anything like export selected values from its constituent services as "composite-level exports", or map "composite-level binds" onto constituent service binds. A composite package as a whole does not update itself as an individual service can. Indeed, the constituent services of a composite can continue to update independently of each other.
While each of these potential behaviors are compelling, we're are looking to develop the overall feature of composites iteratively with feedback from our user community. We believe that the feature set we are unveiling is a suitable "first move", and provides a basis from which to discuss further improvements.
Finally, composite package support is available for Linux services only at this point, but support for Windows services is planned.
We're excited to hear what you have to say about composite packages. What worked for you? What do you wish they did differently? How do you think you might use them in your own infrastructure? Let us know!