Skip to content
Merged
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
5 changes: 3 additions & 2 deletions async-service-supervisor-envoy.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ Gem::Specification.new do |spec|
spec.required_ruby_version = ">= 3.3"

spec.add_dependency "async", "~> 2.38"
spec.add_dependency "async-grpc-xds", "~> 0.1"
spec.add_dependency "async-grpc-xds", "~> 0.2"
spec.add_dependency "async-http"
spec.add_dependency "async-service-supervisor", "~> 0.18"
spec.add_dependency "async-service-supervisor", "~> 0.20"
spec.add_dependency "process-metrics", "~> 0.12"
end
42 changes: 22 additions & 20 deletions control-plane/test/envoy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,43 +17,45 @@ def eventually(timeout: 20, interval: 0.5)

while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
begin
return yield
if result = yield
return result
end
rescue => error
sleep interval
end

sleep interval
end

raise error
raise error || "Condition was not met within #{timeout} seconds!"
end

it "routes requests through Envoy to supervised Falcon workers" do
uri = envoy_uri

backend_ids = eventually do
20.times.map do
response = Net::HTTP.get_response(uri)

expect(response.code.to_i).to be == 200
expect(response.body).to be =~ /Hello from backend-[ab]/

response["x-backend-id"]
end
responses = eventually do
responses = 20.times.map{Net::HTTP.get_response(uri)}
backend_ids = responses.filter_map{|response| response["x-backend-id"]}.uniq.sort

responses if backend_ids == ["backend-a", "backend-b"]
end

expect(backend_ids.compact.uniq.sort).to be == ["backend-a", "backend-b"]
responses.each do |response|
expect(response.code.to_i).to be == 200
expect(response.body).to be =~ /Hello from backend-[ab]/
end

expect(responses.filter_map{|response| response["x-backend-id"]}.uniq.sort).to be == ["backend-a", "backend-b"]
end

it "loads the xDS cluster from the supervisor monitor" do
uri = admin_uri + "/clusters?format=json"

cluster_status = eventually do
response = Net::HTTP.get_response(uri)

expect(response.code.to_i).to be == 200

clusters = JSON.parse(response.body)
clusters.fetch("cluster_statuses").find do |cluster|
cluster.fetch("name") == "app-http1"
if (response = Net::HTTP.get_response(uri)).code.to_i == 200
clusters = JSON.parse(response.body)
clusters.fetch("cluster_statuses").find do |cluster|
cluster.fetch("name") == "app-http1"
end
end
end

Expand Down
31 changes: 31 additions & 0 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,34 @@ Async::Service::Supervisor::Envoy::Monitor.new(
```

Disconnected workers are removed from EDS. Registered workers that fail the delegate health check remain in EDS with an unhealthy endpoint status.

## Load-aware Balancing

Enable out-of-band ORCA reporting to let Envoy weight independently bound workers using their current processor utilization and request throughput:

``` ruby
utilization_monitor = Async::Service::Supervisor::UtilizationMonitor.new(interval: 1)

[
utilization_monitor,
Async::Service::Supervisor::Envoy::Monitor.new(
bind: "http://0.0.0.0:18000",
orca: true,
utilization_monitor: utilization_monitor,
interval: 1
)
]
```

The supervisor utilization monitor manages each worker's shared-memory allocation and registration. The Envoy monitor samples it through `sample_by_worker`, combines each worker's `requests_total` counter with processor usage from `process-metrics`, and serves the resulting ORCA reports from the same HTTP/2 endpoint as ADS. It also configures each discovered cluster to use Envoy's client-side weighted-round-robin policy.

The first sample establishes a baseline. Subsequent reports contain normalized `cpu_utilization` and `rps_fractional` values for each worker. Reports are removed immediately when a worker disconnects.

Out-of-band ORCA requires:

- Envoy 1.39 or later.
- A fixed TCP port for the monitor's `bind` address.
- A supervisor utilization monitor registered alongside the Envoy monitor.
- Independently addressable TCP worker endpoints. Unix sockets and endpoints shared by several workers cannot provide distinct per-worker ORCA identities.

The monitor address and worker addresses must be reachable from Envoy. In a sidecar deployment, binding the monitor to a fixed port in the shared network namespace satisfies this requirement.
1 change: 1 addition & 0 deletions lib/async/service/supervisor/envoy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
require_relative "envoy/delegate"
require_relative "envoy/endpoint"
require_relative "envoy/monitor"
require_relative "envoy/orca_service"
require_relative "envoy/supervised"
161 changes: 150 additions & 11 deletions lib/async/service/supervisor/envoy/monitor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@

require "async/http/endpoint"
require "async/service/supervisor/monitor"
require "async/service/supervisor/utilization_monitor"
require "async/grpc/xds/client_side_weighted_round_robin"
require "async/grpc/xds/control_plane"
require "async/grpc/xds/server"
require "process/metrics"
require "xds/data/orca/v3/orca_load_report_pb"

require_relative "delegate"
require_relative "endpoint"
require_relative "endpoint_group"
require_relative "orca_service"

module Async
module Service
Expand All @@ -23,21 +28,45 @@ class Monitor < Async::Service::Supervisor::Monitor
# @parameter bind [String | Nil] The optional address for the xDS control plane server.
# @parameter delegate [Delegate] The delegate used to map supervisor state into Envoy endpoints.
# @parameter control_plane [Async::GRPC::XDS::ControlPlane] The xDS control plane to update.
# @parameter orca [Boolean] Whether to collect and serve per-worker ORCA load reports.
# @parameter processor [Process::Metrics::Processor | Nil] The optional process CPU sampler.
# @parameter utilization_monitor [Async::Service::Supervisor::UtilizationMonitor | Nil] The per-worker utilization monitor used for ORCA reporting.
# @parameter interval [Numeric] The endpoint reconciliation and ORCA reporting interval in seconds.
def initialize(
bind: nil,
delegate: Delegate.new,
control_plane: Async::GRPC::XDS::ControlPlane.new,
orca: false,
processor: nil,
utilization_monitor: nil,
interval: 1,
**options
)
super(**options)
super(interval: interval, **options)

@bind = bind
@delegate = delegate
@control_plane = control_plane
@interval = interval
@orca = orca
@controllers = {}
@published_clusters = {}
@server_task = nil
@mutex = Mutex.new

if @orca
raise ArgumentError, "ORCA reporting requires a TCP bind address!" unless @bind
raise ArgumentError, "ORCA reporting requires a utilization monitor!" unless utilization_monitor

@orca_port = server_endpoint.url.port
raise ArgumentError, "ORCA reporting requires a fixed TCP port!" unless @orca_port&.positive?

@processor = processor || Process::Metrics::Processor.new
@utilization_monitor = utilization_monitor
@request_totals = {}
@load_reports = {}
@authorities = {}
end
end

# @attribute [Async::GRPC::XDS::ControlPlane] The xDS control plane receiving cluster and endpoint updates.
Expand All @@ -52,6 +81,7 @@ def initialize(
def register(supervisor_controller)
@mutex.synchronize do
@controllers[supervisor_controller.id] = supervisor_controller
@authorities[worker_hostname(supervisor_controller)] = supervisor_controller.id if @orca
reconcile
end
end
Expand All @@ -62,6 +92,12 @@ def register(supervisor_controller)
def remove(supervisor_controller)
@mutex.synchronize do
@controllers.delete(supervisor_controller.id)
if @orca
hostname = worker_hostname(supervisor_controller)
@authorities.delete(hostname)
@load_reports.delete(hostname)
@request_totals.delete(supervisor_controller.id)
end
reconcile
end
end
Expand All @@ -74,8 +110,9 @@ def run(parent: Async::Task.current)

if @bind
@server_task = parent.async do
endpoint = Async::HTTP::Endpoint.parse(@bind, protocol: Async::HTTP::Protocol::HTTP2)
Async::GRPC::XDS::Server.new(@control_plane).run(endpoint)
server = Async::GRPC::XDS::Server.new(@control_plane)
server.dispatcher.register(ORCAService.new(self, minimum_interval: @interval)) if @orca
server.run(server_endpoint)
end
end

Expand All @@ -92,16 +129,44 @@ def as_json
end
end

# Determine whether an ORCA worker authority is currently registered.
# @parameter authority [String] The gRPC request authority.
# @returns [Boolean] Whether the authority identifies a live worker.
def worker?(authority)
return false unless @orca

@mutex.synchronize{@authorities.key?(authority)}
end

# Get the latest ORCA report for a worker authority.
# @parameter authority [String] The gRPC request authority.
# @returns [Xds::Data::Orca::V3::OrcaLoadReport | Nil] The latest valid report, if available.
def load_report(authority)
return unless @orca

@mutex.synchronize{@load_reports[authority]}
end

# Refresh endpoint health and publish updated EDS state.
# @returns [void]
def run_once
sample_load_reports if @orca

@mutex.synchronize do
reconcile
end
end

private

def server_endpoint
@server_endpoint ||= Async::HTTP::Endpoint.parse(@bind, protocol: Async::HTTP::Protocol::HTTP2)
end

def worker_hostname(supervisor_controller)
"worker-#{supervisor_controller.id}"
end

def build_record(supervisor_controller, endpoint)
cluster = @delegate.cluster(supervisor_controller, endpoint)
return unless cluster
Expand Down Expand Up @@ -148,14 +213,24 @@ def build_records_by_cluster

def build_clusters(records_by_cluster = build_records_by_cluster)
records_by_cluster.transform_values do |records|
groups = {}

records.each do |record|
group = groups[record[:endpoint]] ||= EndpointGroup.new(record[:endpoint])
group.add(record[:worker], healthy: record[:healthy])
if @orca
records.map do |record|
{
addresses: record[:endpoint].addresses,
healthy: record[:healthy],
hostname: worker_hostname(record[:worker]),
}
end
else
groups = {}

records.each do |record|
group = groups[record[:endpoint]] ||= EndpointGroup.new(record[:endpoint])
group.add(record[:worker], healthy: record[:healthy])
end

groups.each_value.map(&:as_json)
end

groups.each_value.map(&:as_json)
end
end

Expand All @@ -168,7 +243,71 @@ def cluster_configuration(records)
raise ArgumentError, "Envoy cluster contains no common protocols: #{protocols.inspect}" if common_protocols.empty?
raise ArgumentError, "HTTPS upstream endpoints are not yet supported!" if schemes.first == :https

{protocol: envoy_protocol(common_protocols)}
configuration = {protocol: envoy_protocol(common_protocols)}

if @orca
if records.any?{|record| record[:endpoint].addresses.any?{|address| address[:path]}}
raise ArgumentError, "Out-of-band ORCA reporting requires IP endpoints!"
end

configuration[:load_balancing_policy] = Async::GRPC::XDS::ClientSideWeightedRoundRobin.build(
@orca_port,
reporting_period: @interval
)
end

configuration
end

def sample_load_reports
controllers = @mutex.synchronize{@controllers.dup}
workers = @utilization_monitor.sample_by_worker
process_ids = controllers.each_value.filter_map(&:process_id)
processor_samples = @processor.sample(process_ids)
request_totals = {}

workers.each do |worker_id, worker|
requests_total = worker[:utilization][:requests_total]
if requests_total.is_a?(Numeric) && requests_total.finite?
request_totals[worker_id] = requests_total
end
end

@mutex.synchronize do
controllers.each do |worker_id, controller|
next unless @controllers[worker_id].equal?(controller)

hostname = worker_hostname(controller)
processor_sample = processor_samples[controller.process_id]
requests_total = request_totals[worker_id]
previous_requests_total = @request_totals[worker_id]

if processor_sample && requests_total && previous_requests_total && requests_total >= previous_requests_total
rps = (requests_total - previous_requests_total).fdiv(processor_sample.duration)
cpu = processor_sample.utilization

if rps.finite? && cpu.finite?
@load_reports[hostname] = Xds::Data::Orca::V3::OrcaLoadReport.new(
cpu_utilization: cpu,
rps_fractional: rps,
named_metrics: {"orca.heartbeat" => 0.0},
)
next
end
end

@load_reports.delete(hostname)
end

@request_totals = request_totals

Console.debug(self, "Sampled ORCA load reports.",
workers: workers.keys,
processes: processor_samples.keys,
requests: request_totals,
reports: @load_reports.keys,
)
end
end

def envoy_protocol(protocols)
Expand Down
Loading
Loading