Overview
Lighthouse is a library for looking up arbitrary network resources such as databases and service components; it is a building block that can be quickly integrated with a variety of applications for a variety of service discovery use-cases. The figure below details the high-level workflow for applications that use lighthouse:
The workflow depicted in figure 1.0 shows the Lighthouse workflow when calling another remote service (other typical use cases are depicted in the User Guide:
A: When the application is running, it makes one or more invocations of the Lighthouse API, which will fetch data from the backing Consul storage system. This data (shown later in the protocol section) informs the application how to turn a logical name (e.g.
my-cassandra
) into a Consulservice
name.B: In this example, traffic is routed to the local Envoy side-car, which maintains TLS connections to outbound services, implements rate limiting and circuit breaking. Other non-envoy lighthouse workflows are discussed in the user guide.
C: Envoy is dynamically aware of the locations of network dependencies for this particular container, and so when receiving a request from the application will automatically conduct load balancing to the remote IP:PORT destinations.
With this frame, we can see that Lighthouse is actually a very thin layer that translates human-friendly names into names resolvable in Consul’s service catalog, which the runtime routing infrastructure will later use to find the runtime locations of addresses that service that particular network system.
QuickStart
Getting started with Lighthouse is straight forward. Update your build file to include the dependency, and then modify your project code pertaining to your use case.
Project Setup
Various transports are available for Lighthouse, but it is highly recommended that most users leverage the blaze
transport, like so:
libraryDependencies += "io.verizon.lighthouse" %% "blaze" % "3.0.0-SNAPSHOT"
This will pull in the Consul-backed lighthouse.
Creating the Client
To create the default client, one needs to wire together the http4s client and the Lighthouse client. The idea here is that the transport client (http4s in this example) can then be tuned for the workload the implementing application will be conducting. Lighthouse comes with some reasonable defaults, but having the ability to tune if required is often an important aspect.
import org.http4s.client.Client
import lighthouse._, blazeclient.{ClientConfig, defaultHttp4sClient}
final case class ApplicationConfig(
http4sClient: Client,
lighthouse: Lighthouse[LighthouseTask])
def initializeConfig(): ApplicationConfig = {
val http4sClient = defaultHttp4sClient(ClientConfig.default())
val lighthouse = Lighthouse.defaultClient(http4sClient)
ApplicationConfig(http4sClient, lighthouse)
}
This example assumes your application is structured in a functional manner, where you have a typed configuration class and are passing that configuration around in your application (e.g. with Reader
monad or similar). Even if you do not have this kind of structure, you can use Lighthouse, but the initialization would look slightly different.
Service Resource
The primary intended use-case for Lighthouse is to call over service systems. To that end, Lighthouse prioritizes that and provides a convenient set of functional idioms to make doing that easy. Here are some examples of how you might make service calls using Lighthouse and http4s.
import lighthouse._
import lighthouse.LighthouseOp.serviceCall
import org.http4s.Method
import org.http4s.client.Client
import scalaz.concurrent.Task
object Accounts {
import JsonSupport._ // defined below
final case class ApplicationConfig(http4sClient: Client, lighthouse: Lighthouse[LighthouseTask])
final case class AccountContext(app: ApplicationConfig, userCtx: LighthouseContext)
final case class User(id: String, name: String, age: Int)
final case class NewUser(name: String, age: Int)
def urlEncode(s: String): String =
java.net.URLEncoder.encode(s, "UTF-8")
// The type of service that is being called.
// For the Nelson infrastructure, this must match up with what's declared in
// its .nelson.yml.
val accountService: ServiceType = ServiceType("accounts-http")
// a fairly simple GET call that simply returns a String
def fetchUserName(userId: String, ctx: AccountContext): Task[String] = {
val op = serviceCall(accountService, Method.GET,
_ / "users" / urlEncode(userId) / "name")
for {
req <- op.runWith(ctx.app.lighthouse).run(ctx.userCtx)
name <- ctx.app.http4sClient.expect[String](req)
} yield name
}
// a call that POSTs a JSON body and decodes a response JSON body
def createUser(newUser: NewUser, ctx: AccountContext): Task[User] = {
val op = serviceCall(accountService, Method.POST,
_ / "users")
for {
req <- op.runWith(ctx.app.lighthouse).run(ctx.userCtx)
withBody <- req.withBody(newUser)
user <- ctx.app.http4sClient.expect[User](withBody)
} yield user
}
object JsonSupport {
import argonaut._
import org.http4s.{EntityDecoder, EntityEncoder}
import org.http4s.argonaut.{jsonOf, jsonEncoderOf}
implicit val userCodec: CodecJson[User] =
CodecJson.casecodec3(User.apply, User.unapply)("id", "name", "age")
implicit val userDecoder: EntityDecoder[User] = jsonOf[User]
implicit val newUserCodec: CodecJson[NewUser] =
CodecJson.casecodec2(NewUser.apply, NewUser.unapply)("name", "age")
implicit val newUserEncoder: EntityEncoder[NewUser] = jsonEncoderOf[NewUser]
}
}
This is a fairly complete example, which is why it is fairly long. This includes all the JSON encoders and everything, but as can be seen, the Lighthouse section is short.
Arbitrary Resources
Lighthouse can be used to look up addresses of a network system (such members of a database cluster). If you don’t know the name of the resource you need (cassandra-ads
in the example below), then you can simply run nelson units list -ns dev
to see what units are available to depend on within the dev
namespace.
import scalaz.NonEmptyList
import scalaz.concurrent.Task
object Example {
import lighthouse._, LighthouseOp.lookupInstances
val cassandraResource = NetworkResource(ServiceType("cassandra-ads"), PortName.Default)
/**
* Find an instances in the Cassandra cluster.
*
* The returned `Endpoint` has the host name, port number, protocol, etc.
*/
def cassandra(lighthouse: Lighthouse[LighthouseTask]): Task[NonEmptyList[Endpoint]] =
lookupInstances(cassandraResource)
.runWith(lighthouse)
.run(LighthouseContext.System)
}
In this example the cassandra
function will return a list of all the known Cassandra Endpoint
’s for the cassandra-ads
system. If we wanted to initialize a Cassandra driver with this set of endpoints, we can simply extract the authority information like so:
/**
* The URI (in string form) with the host and port 10.10.123.241:5436
*/
def endpointAuthority(endpoint: Endpoint): String =
endpoint.authority.renderString
These functions can be map
ed over the list of Endpoint
to return a list of addresses, such as is typically used to seed a database driver for a database like Cassandra, or a message queue like Kafka. If for some reason you needed the addresses complete with the protocol, then you can use a function like:
/**
* The URI (in string form) for an endpoint, complete with protocol, host and port.
*/
def endpointUriString(endpoint: Endpoint): String =
endpoint.httpUri.renderString
In the event you want to discover the URL for a single instance of the service, but not have lighthouse call it, one could do that using the following idiom:
import org.http4s.Uri
import scalaz.concurrent.Task
object Example {
import lighthouse._, LighthouseOp.lookupService
def foo(lighthouse: Lighthouse[LighthouseTask]): Task[Uri] =
lookupService(ServiceType("foo-http"))
.runWith(lighthouse)
.run(LighthouseContext.System)
}
This will return a Uri
that is already preformatted to route to the local routing infrastructure (see the overview for information on that), but it puts no constraints on what software you use to call the URI. This is typically not advised, but is useful in certain edge cases such as interacting with legacy or third-party libraries.
User Guide
Before diving into this section, be sure to have read the quickstart section, which is what most users are probably looking for. This section primarily covers the specific Lighthouse operations and how applications using Lighthouse can enable themselves for testing with Lighthouse protocol fixtures.
Operations
Whilst Lighthouse has a limited set of operations in its algebra, these operations should form the building blocks of service discovery in a variety of use cases: irrespective if you’re discovering a database, messaging system, another service, or some other internal components… if its a network system, Lighthouse should have you covered.
Operation | Description |
serviceCall |
When making remote calls to another network system, Lighthouse will take the configured client and prepare a Request that is ready for execution. This model allows users to configure their outbound request in whichever manner makes sense for them, whilst not burdening the caller with the underlying details of discovery, routing or performance. |
lookupInstances |
Get a list of all the IP:PORT combinations for the requested network system. Using lookupInstances will return every address that is registered to handle this particular application. |
lookupInstance |
Essentially the same as lookupInstances , except that only a single address is returned. Ordered is non-deterministic, so consumers should not always expect to receive the same address at the head of the sequence. |
lookupService |
Returns a URI to call, instead of actually preparing a request or any kind of prepared client. This is a relatively primitive operation and should not be used by the majority of users. |
Testing
Lighthouse is implemented as a Free algebra. If you’re not familiar, the high-level ramification of this means that the use of Lighthouse operations and the execution of those operations are separate, and the how (the interpreter) of those operations can be swapped out without affecting your calling code. This rather useful feature means that one can swap out the production interpreter with one that is more appropriate for a testing environment. Lighthouse provides a test interpreter based on simple maps.
import scalaz.concurrent.Task
import org.http4s.Uri
import lighthouse._
val inventory = ServiceType("inventory")
val cassandra = NetworkResource(ServiceType("ads-cassandra"), PortName.Default)
val testLighthouse = Lighthouse.testClient(
serviceMap = Map(inventory -> Uri.uri("http://127.0.0.1:4477/prod/inventory")),
networkResourceMap = Map(cassandra -> List(Endpoint(Uri.RegName("cassandra.service.dc1.example.com"), Port(9042))))
)
This test lighthouse client is capable of the same operations as the default lighthouse client:
scala> import lighthouse.LighthouseOp._
import lighthouse.LighthouseOp._
scala> lookupService(inventory).runWith(testLighthouse).run(LighthouseContext.System).run
res2: org.http4s.Uri = http://127.0.0.1:4477/prod/inventory
scala> httpsEndpoint(inventory).runWith(testLighthouse).run(LighthouseContext.System).run
res3: org.http4s.Request = Request(method=GET, uri=http://127.0.0.1:4477/prod/inventory, headers=Headers()
scala> lookupInstances(cassandra).runWith(testLighthouse).run(LighthouseContext.System).run
res4: scalaz.NonEmptyList[lighthouse.Endpoint] = NonEmptyList(Endpoint(cassandra.service.dc1.example.com,9042))
scala> lookupInstance(cassandra).runWith(testLighthouse).run(LighthouseContext.System).run
res5: lighthouse.Endpoint = Endpoint(cassandra.service.dc1.example.com,9042)
Developers
Extending this Scala implementation of Lighthouse or implementing Lighthouse support in another language is relatively straight forward. This section contains the information one would need to do to modify or extend Lighthouse. This section is not required to simply use an existing implementation of Lighthouse.
Languages
Lighthouse already has implementations in several languages:
If you require something that is not currently available, please consider contributing or talking with the the Nelson team.
Protocol
The Lighthouse protocol is a simplistic JSON document that - for a given Consul service name - has corresponding data held in the Consul Key-Value storage. For example, the following data would be held in the Consul KV store at lighthouse/discovery/v1/howdy-http--1-0-388--aeiq8irl
:
{
"namespaces": [
{
"routes": [
{
"port": "default",
"targets": [
{
"weight": 100,
"protocol": "http",
"port": 9000,
"stack": "howdy-http--1-0-388--aeiq8irl"
}
],
"service": "howdy-http"
}
],
"name": "dev"
}
],
"domain": "your.consul-tld.com",
"defaultNamespace": "dev"
}
Contributing
Contributing to Lighthouse is simple! If there is something you think needs to be fixed, either open an issue on GitHub or, better yet, just send a pull request with a patch. Whilst a fair amount of effort has been put into making Lighthouse easy-to-use, sometimes it helps to know more of the internal details, and the author encourages the reader to view the source code to understand the internals.
Credits
Lighthouse was designed and created by the following good people:
In addition, the following people are honourably mentioned for their contributions, advice and early adoption of Lighthouse:
- Eduardo Jimenez
- Andrew Morhland
- Greg Flanagan
- Vincent Marquez