Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.git
.context
.bundle
.covered.db
gems.locked
pkg
external
**/*.ipc
12 changes: 12 additions & 0 deletions examples/cluster/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ARG RUBY_VERSION=4.0
FROM ruby:${RUBY_VERSION}

WORKDIR /code

ENV BUNDLE_GEMFILE=/code/examples/cluster/gems.rb

COPY . .

RUN bundle install

CMD ["bundle", "exec", "async-service", "examples/cluster/falcon.rb"]
31 changes: 31 additions & 0 deletions examples/cluster/client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "net/http"
require "uri"

uri = URI(ENV.fetch("ENVOY_URI", "http://127.0.0.1:10000"))
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 20
workers = {}

until workers.size == 2 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
begin
response = Net::HTTP.get_response(uri)

if response.is_a?(Net::HTTPSuccess)
worker_id = response["x-worker-id"]
workers[worker_id] ||= response.body
end
rescue Errno::ECONNREFUSED, EOFError
# Envoy may still be connecting to the xDS control plane.
end

sleep(0.1) unless workers.size == 2
end

abort "Envoy did not route requests to both workers." unless workers.size == 2

workers.each_value{|body| puts(body)}
32 changes: 32 additions & 0 deletions examples/cluster/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
services:
falcon:
image: cluster-falcon
build:
context: ../..
dockerfile: examples/cluster/Dockerfile
environment:
CONSOLE_OUTPUT: XTerm
ports:
- "10000:10000"

envoy:
image: envoyproxy/envoy:v1.32-latest
command: ["envoy", "-c", "/etc/envoy/envoy.yaml", "--log-level", "info"]
network_mode: "service:falcon"
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml:ro
depends_on:
falcon:
condition: service_started

client:
image: cluster-falcon
command: ["bundle", "exec", "ruby", "examples/cluster/client.rb"]
environment:
ENVOY_URI: http://127.0.0.1:10000
network_mode: "service:falcon"
depends_on:
envoy:
condition: service_started
profiles:
- client
68 changes: 68 additions & 0 deletions examples/cluster/envoy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
node:
id: falcon-cluster-example
cluster: falcon-cluster-example

dynamic_resources:
ads_config:
api_type: GRPC
transport_api_version: V3
grpc_services:
- envoy_grpc:
cluster_name: xds_cluster

static_resources:
listeners:
- name: listener_http
address:
socket_address:
address: 0.0.0.0
port_value: 10000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: falcon
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: cluster
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

clusters:
- name: cluster
connect_timeout: 1s
type: EDS
lb_policy: ROUND_ROBIN
eds_cluster_config:
service_name: cluster
eds_config:
ads: {}
resource_api_version: V3

- name: xds_cluster
connect_timeout: 1s
type: STATIC
load_assignment:
cluster_name: xds_cluster
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 18000
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
51 changes: 51 additions & 0 deletions examples/cluster/falcon.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env async-service
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "async/service/supervisor"
require "async/service/supervisor/envoy"
require "falcon/environment/cluster"

service "cluster" do
include Falcon::Environment::Cluster
include Async::Service::Supervisor::Envoy::Supervised

count 2

def url
"http://localhost:0"
end

middleware do
rack_application = proc do |_env|
worker_id = Process.pid.to_s
body = "Hello from worker #{worker_id}!\n"

[
200,
{
"content-type" => "text/plain",
"content-length" => body.bytesize.to_s,
"x-worker-id" => worker_id,
},
[body],
]
end

Falcon::Server.middleware(rack_application, cache: false)
end
end

service "supervisor" do
include Async::Service::Supervisor::Environment

monitors do
[
Async::Service::Supervisor::Envoy::Monitor.new(
bind: "http://127.0.0.1:18000",
),
]
end
end
9 changes: 9 additions & 0 deletions examples/cluster/gems.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

source "https://rubygems.org"

gem "falcon", path: "../.."
gem "async-service-supervisor-envoy", "~> 0.0.1"
29 changes: 29 additions & 0 deletions examples/cluster/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Cluster with Envoy

This example runs a two-worker Falcon cluster behind Envoy. Each worker binds to `localhost` with port `0`, allowing the operating system to assign an available port. Falcon publishes the concrete worker addresses to Envoy through the supervisor's xDS control plane.

Docker Compose runs Falcon and Envoy in the same network namespace. This allows the workers to remain bound to loopback addresses while Envoy connects to their dynamically assigned ports. Envoy exposes a fixed HTTP listener on port 10000 and distributes requests across the workers.

## Usage

Build and start Falcon and Envoy:

```shell
$ docker compose up --build --detach
```

Run the client through Compose:

```shell
$ docker compose run --rm client
Hello from worker 12!
Hello from worker 13!
```

The client waits for Envoy and confirms that requests reach both workers.

Stop and remove the containers:

```shell
$ docker compose down
```
2 changes: 1 addition & 1 deletion falcon.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Gem::Specification.new do |spec|

spec.add_dependency "async"
spec.add_dependency "async-container", "~> 0.20"
spec.add_dependency "async-http", "~> 0.75"
spec.add_dependency "async-http", "~> 0.97"
spec.add_dependency "async-http-cache", "~> 0.4"
spec.add_dependency "async-service", "~> 0.19"
spec.add_dependency "async-utilization", "~> 0.3"
Expand Down
2 changes: 1 addition & 1 deletion gems.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

# gem "fiber-profiler"

gem "async-service-supervisor"
gem "async-service-supervisor", "~> 0.18"

group :maintenance, optional: true do
gem "bake-modernize"
Expand Down
35 changes: 35 additions & 0 deletions lib/falcon/environment/cluster.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require_relative "server"
require_relative "../service/cluster"

module Falcon
module Environment
# Provides an environment for hosting a cluster of Falcon server workers, where each worker binds its own endpoint.
module Cluster
include Server

# The service class to use for the cluster.
# @returns [Class]
def service_class
Service::Cluster
end

# The host that this server will receive connections for.
def url
"http://[::]:0"
end

# Prepare a cluster worker after its endpoint has been bound.
#
# @parameter instance [Object] The container instance.
# @parameter listener [Service::Cluster::Listener] The worker's bound listener.
def prepare_worker!(instance, listener:)
prepare!(instance)
end
end
end
end
Loading
Loading