Skip to content

Commit e5ba892

Browse files
Run cluster example behind Envoy
Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a
1 parent 269b1c3 commit e5ba892

8 files changed

Lines changed: 191 additions & 50 deletions

File tree

.dockerignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.git
2+
.context
3+
.bundle
4+
.covered.db
5+
gems.locked
6+
pkg
7+
external
8+
**/*.ipc

examples/cluster/Dockerfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
ARG RUBY_VERSION=4.0
2+
FROM ruby:${RUBY_VERSION}
3+
4+
WORKDIR /code
5+
6+
ENV BUNDLE_GEMFILE=/code/examples/cluster/gems.rb
7+
8+
COPY . .
9+
10+
RUN bundle install
11+
12+
CMD ["bundle", "exec", "async-service", "examples/cluster/falcon.rb"]

examples/cluster/client.rb

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,28 @@
44
# Released under the MIT License.
55
# Copyright, 2026, by Samuel Williams.
66

7-
require "async/http/client"
8-
require "async/http/endpoint"
7+
require "net/http"
8+
require "uri"
99

10-
addresses_path = File.expand_path(ENV.fetch("ADDRESSES_PATH", "addresses.txt"), __dir__)
11-
addresses = File.readlines(addresses_path, chomp: true)
10+
uri = URI(ENV.fetch("ENVOY_URI", "http://127.0.0.1:10000"))
11+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 20
12+
workers = {}
1213

13-
abort "No cluster addresses found in #{addresses_path}." if addresses.empty?
14-
15-
Sync do
16-
addresses.each do |address|
17-
endpoint = Async::HTTP::Endpoint.parse("http://#{address}")
14+
until workers.size == 2 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
15+
begin
16+
response = Net::HTTP.get_response(uri)
1817

19-
Async::HTTP::Client.open(endpoint) do |client|
20-
response = client.get("/")
21-
22-
begin
23-
puts "#{address}: #{response.read}"
24-
ensure
25-
response.finish
26-
end
18+
if response.is_a?(Net::HTTPSuccess)
19+
worker_id = response["x-worker-id"]
20+
workers[worker_id] ||= response.body
2721
end
22+
rescue Errno::ECONNREFUSED, EOFError
23+
# Envoy may still be connecting to the xDS control plane.
2824
end
25+
26+
sleep(0.1) unless workers.size == 2
2927
end
28+
29+
abort "Envoy did not route requests to both workers." unless workers.size == 2
30+
31+
workers.each_value{|body| puts(body)}

examples/cluster/compose.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
services:
2+
falcon:
3+
image: cluster-falcon
4+
build:
5+
context: ../..
6+
dockerfile: examples/cluster/Dockerfile
7+
environment:
8+
CONSOLE_OUTPUT: XTerm
9+
ports:
10+
- "10000:10000"
11+
12+
envoy:
13+
image: envoyproxy/envoy:v1.32-latest
14+
command: ["envoy", "-c", "/etc/envoy/envoy.yaml", "--log-level", "info"]
15+
network_mode: "service:falcon"
16+
volumes:
17+
- ./envoy.yaml:/etc/envoy/envoy.yaml:ro
18+
depends_on:
19+
falcon:
20+
condition: service_started
21+
22+
client:
23+
image: cluster-falcon
24+
command: ["bundle", "exec", "ruby", "examples/cluster/client.rb"]
25+
environment:
26+
ENVOY_URI: http://127.0.0.1:10000
27+
network_mode: "service:falcon"
28+
depends_on:
29+
envoy:
30+
condition: service_started
31+
profiles:
32+
- client

examples/cluster/envoy.yaml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
node:
2+
id: falcon-cluster-example
3+
cluster: falcon-cluster-example
4+
5+
dynamic_resources:
6+
ads_config:
7+
api_type: GRPC
8+
transport_api_version: V3
9+
grpc_services:
10+
- envoy_grpc:
11+
cluster_name: xds_cluster
12+
13+
static_resources:
14+
listeners:
15+
- name: listener_http
16+
address:
17+
socket_address:
18+
address: 0.0.0.0
19+
port_value: 10000
20+
filter_chains:
21+
- filters:
22+
- name: envoy.filters.network.http_connection_manager
23+
typed_config:
24+
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
25+
stat_prefix: ingress_http
26+
route_config:
27+
name: local_route
28+
virtual_hosts:
29+
- name: falcon
30+
domains: ["*"]
31+
routes:
32+
- match:
33+
prefix: "/"
34+
route:
35+
cluster: cluster
36+
http_filters:
37+
- name: envoy.filters.http.router
38+
typed_config:
39+
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
40+
41+
clusters:
42+
- name: cluster
43+
connect_timeout: 1s
44+
type: EDS
45+
lb_policy: ROUND_ROBIN
46+
eds_cluster_config:
47+
service_name: cluster
48+
eds_config:
49+
ads: {}
50+
resource_api_version: V3
51+
52+
- name: xds_cluster
53+
connect_timeout: 1s
54+
type: STATIC
55+
load_assignment:
56+
cluster_name: xds_cluster
57+
endpoints:
58+
- lb_endpoints:
59+
- endpoint:
60+
address:
61+
socket_address:
62+
address: 127.0.0.1
63+
port_value: 18000
64+
typed_extension_protocol_options:
65+
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
66+
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
67+
explicit_http_config:
68+
http2_protocol_options: {}

examples/cluster/falcon.rb

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,13 @@
44
# Released under the MIT License.
55
# Copyright, 2026, by Samuel Williams.
66

7-
require "protocol/http/middleware"
8-
7+
require "async/service/supervisor"
8+
require "async/service/supervisor/envoy"
99
require "falcon/environment/cluster"
1010

11-
addresses_path = File.expand_path(ENV.fetch("ADDRESSES_PATH", "addresses.txt"), __dir__)
12-
File.write(addresses_path, "")
13-
14-
record_addresses = Module.new do
15-
define_method(:prepare_worker!) do |instance, listener:|
16-
super(instance, listener: listener)
17-
18-
File.open(addresses_path, "a") do |file|
19-
file.flock(File::LOCK_EX)
20-
listener.addresses.each do |address|
21-
file.puts(address.inspect_sockaddr) if address.ip?
22-
end
23-
end
24-
end
25-
end
26-
2711
service "cluster" do
2812
include Falcon::Environment::Cluster
29-
include record_addresses
13+
include Async::Service::Supervisor::Envoy::Supervised
3014

3115
count 2
3216

@@ -35,6 +19,33 @@ def url
3519
end
3620

3721
middleware do
38-
Protocol::HTTP::Middleware::HelloWorld
22+
rack_application = proc do |_env|
23+
worker_id = Process.pid.to_s
24+
body = "Hello from worker #{worker_id}!\n"
25+
26+
[
27+
200,
28+
{
29+
"content-type" => "text/plain",
30+
"content-length" => body.bytesize.to_s,
31+
"x-worker-id" => worker_id,
32+
},
33+
[body],
34+
]
35+
end
36+
37+
Falcon::Server.middleware(rack_application, cache: false)
38+
end
39+
end
40+
41+
service "supervisor" do
42+
include Async::Service::Supervisor::Environment
43+
44+
monitors do
45+
[
46+
Async::Service::Supervisor::Envoy::Monitor.new(
47+
bind: "http://127.0.0.1:18000",
48+
),
49+
]
3950
end
4051
end

examples/cluster/gems.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
source "https://rubygems.org"
7+
8+
gem "falcon", path: "../.."
9+
gem "async-service-supervisor-envoy", "~> 0.0.1"

examples/cluster/readme.md

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,29 @@
1-
# Cluster TCP Endpoints
1+
# Cluster with Envoy
22

3-
This example shows how to run Falcon cluster workers on independently bound TCP endpoints. Each worker binds to `localhost` with port `0`, allowing the operating system to assign an available port.
3+
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.
44

5-
After binding, Falcon describes each worker using a `Falcon::Service::Cluster::Listener`. The listener exposes its logical name, scheme, supported protocol names, bound endpoint, and all concrete socket addresses. This example records those addresses in `addresses.txt`; service discovery integrations can instead use `prepare_worker!(instance, listener:)` to register them directly.
5+
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.
66

77
## Usage
88

9-
Start the two-worker cluster:
9+
Build and start Falcon and Envoy:
1010

1111
```shell
12-
$ bundle exec async-service ./falcon.rb
12+
$ docker compose up --build --detach
1313
```
1414

15-
In another terminal, run the client:
15+
Run the client through Compose:
1616

1717
```shell
18-
$ bundle exec ruby ./client.rb
19-
[::]:53142: Hello World!
20-
[::]:53143: Hello World!
18+
$ docker compose run --rm client
19+
Hello from worker 12!
20+
Hello from worker 13!
2121
```
2222

23-
The exact address family and ports are platform-dependent.
23+
The client waits for Envoy and confirms that requests reach both workers.
2424

25-
Both commands use `./addresses.txt` by default. Set `ADDRESSES_PATH` on both commands to use a different file:
25+
Stop and remove the containers:
2626

2727
```shell
28-
$ ADDRESSES_PATH=/tmp/falcon-cluster-addresses bundle exec async-service ./falcon.rb
29-
$ ADDRESSES_PATH=/tmp/falcon-cluster-addresses bundle exec ruby ./client.rb
28+
$ docker compose down
3029
```

0 commit comments

Comments
 (0)