Configuring Envoy to proxy Google Cloud Run v2
- 6 minutes read - 1125 wordsI’m building an emulator for Cloud Run. As I considered the solution, I assumed (more later) that I could implement Google’s gRPC interface for Cloud Run and use Envoy to proxy HTTP/REST requests to the gRPC service using Envoy’s gRPC-JSON transcoder.
Google calls this process Transcoding HTTP/JSON to gRPC which I think it a better description.
Google’s Cloud Run v2
(v1
is no longer published to the googleapis
repo) service.proto
includes the following Services
definition for CreateService
:
package google.cloud.run.v2;
service Services {
option (google.api.default_host) = "run.googleapis.com";
option (google.api.oauth_scopes) =
"https://www.googleapis.com/auth/cloud-platform";
// Creates a new Service in a given project and location.
rpc CreateService(CreateServiceRequest)
returns (google.longrunning.Operation) {
option (google.api.http) = {
post: "/v2/{parent=projects/*/locations/*}/services"
body: "service"
};
option (google.api.routing) = {
routing_parameters {
field: "parent"
path_template: "projects/*/locations/{location=*}"
}
};
option (google.api.method_signature) = "parent,service,service_id";
option (google.longrunning.operation_info) = {
response_type: "Service"
metadata_type: "Service"
};
}
}
Notes:
- The package is
google.cloud.run.v2
run.googleapis.com
is defined as the default hostgoogle.longrunning.Operation
is the return typegoogle.api.http
definespost: "/v2/{parent=projects/*/locations/*}/services"
with a body ofservice
If we were to query Google’s APIs Explorer for Cloud Run Admin API, we would see:
- There are two versions of the API (discovery documents:
v1
;v2
) v2
Methodprojects.locations.services.create
HTTP request
POST https://run.googleapis.com/v2/{parent}/services
And:
Parameters | Description |
---|---|
parent (string) |
Required. The location and project in which this service should be created. Format: projects/{project}/locations/{location} , where {project} can be Project ID or Number. |
And:
The request body contains an instance of Service
.
What’s happening here is that the gRPC service definition is the source of truth and it defines the HTTP/REST method that’s published through Discovery and to Google’s APIs Explorer.
I have an implementation of the gRPC service but, FauxRPC is a clever tool that will generate (among other things) a mock gRPC server implementation from a Protobuf definition (descriptor).
Envoy requires a Protobuf descriptor for the (Cloud Run v2) service too, so let’s generate one:
GOOGLEAPIS="${HOME}/Projects/googleapis"
protoc \
--proto_path=${GOOGLEAPIS} \
--include_imports \
--descriptor_set_out=cloud_run_v2_services.binpb \
${GOOGLEAPIS}/google/cloud/run/v2/service.proto
NOTE In my emulator, the
google.longrunning.Operation
returned byCreateService
(andDeleteService
) encapsulates arbitrary messages types using Google’s Well-known TypeAny
and specifically the typegoogle.protobuf.StringValue
. For this reason, in the emulator, theprotoc
command additional referencesgoogle/protobuf/wrappers.proto
(whereAny
andStringValue
are defined).
Now that we have a descriptor, we can run FauxRPC
:
fauxrpc run \
--schema=${PWD}/cloud_run_v2_services.binpb
NOTE
fauxrpc run --schema
requires filenames ending (binpb
,json
,yaml
)
FauxRPC (dev (none) @ unknown; go1.23.6) - 3 services loaded, 0 stubs loaded
Listening on http://127.0.0.1:6660
OpenAPI documentation: http://127.0.0.1:6660/fauxrpc/openapi.html
Example Commands:
$ buf curl --http2-prior-knowledge http://127.0.0.1:6660 --list-methods
$ buf curl --http2-prior-knowledge http://127.0.0.1:6660/[METHOD_NAME]
2025/04/29 11:20:36 INFO Server started.
NOTE
WARN unable to parse template pattern
are removed from the above.
FauxRPC generates documentation and you can browse e.g. CreateService
[] 2025/04/29 - 11:00:00 | 200 | 69.788µs | 127.0.0.1 | GET "/fauxrpc/openapi.html"
[] 2025/04/29 - 11:00:00 | 200 | 193.867µs | 127.0.0.1 | GET "/fauxrpc/openapi.yaml"
[] 2025/04/29 - 11:00:00 | 200 | 239.702µs | 127.0.0.1 | GET "/fauxrpc/openapi.yaml"
NOTE The
curl
command generated by the documentation forCreateService
does not work (see FauxRPC documentation incorrect conversion of google.protobuf.Duration #23) replace"timeout": "/regex/"
with"timeout": "15s"
.
FaxuRPC accepts gRPC:
ENDPOINT="localhost:6660"
PROJECT="foo"
LOCATION="bar"
SERVICE="test"
PARENT="projects/${PROJECT}/locations/${LOCATION}"
NAME="${PARENT}/service/${SERVICE}"
IMAGE="gcr.io/kuar-demo/kuard-amd64:blue"
DATA="{
\"parent\": \"${PARENT}\",
\"service\": {
\"name\": \"${NAME}\",
\"labels\": {},
\"annotations\": {},
\"template\": {
\"labels\": {},
\"annotations\": {},
\"containers\": [
{
\"name\": \"${SERVICE}\",
\"image\": \"${IMAGE}\",
\"ports\": [
{
\"containerPort\": 8080
}
]
}
]
}
}
}"
grpcurl \
-plaintext \
-d "${DATA}" \
${ENDPOINT} google.cloud.run.v2.Services/CreateService
Yields:
[] 2025/04/29 - 11:00:00 | 200 | 1.878438ms | 127.0.0.1 | POST "/google.cloud.run.v2.Services/CreateService"
[] 2025/04/29 - 11:00:00 | 200 | 1.913159ms | 127.0.0.1 | POST "/google.cloud.run.v2.Services/CreateService"
[] 2025/04/29 - 11:00:00 | 200 | 25.55226ms | 127.0.0.1 | POST "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo"
NOTE Using
google.cloud.run.v2.Services/CreateService
confirming gRPC
FauxRPC also accepts REST/JSON:
ENDPOINT="http://localhost:6660"
PROJECT="foo"
LOCATION="bar"
SERVICE="test"
PARENT="projects/${PROJECT}/locations/${LOCATION}"
NAME="${PARENT}/service/${SERVICE}"
IMAGE="gcr.io/kuar-demo/kuard-amd64:blue"
DATA="{
\"name\":\"${NAME}\",
\"labels\":{},
\"annotations\":{},
\"template\":{
\"labels\":{},
\"annotations\":{},
\"containers\":[
{
\"name\":\"${SERVICE}\",
\"image\":\"${IMAGE}\",
\"ports\":[
{
\"name\":\"http1\",
\"container_port\":8080
}
]
}
]
}
}"
curl \
--ipv4 \
--request POST \
--header "accept: application/json" \
${ENDPOINT}/v2/${PARENT} \
-d "${DATA}"
Yields:
[] 2025/04/29 - 11:00:00 | 200 | 4.230106ms | 127.0.0.1 | POST "/v2/projects/foo/locations/bar/services"
[] 2025/04/29 - 11:00:00 | 200 | 4.307768ms | 127.0.0.1 | POST "/v2/projects/foo/locations/bar/services"
NOTE Using
/v2/projects/{project}/locations/{location}/services
confirming the REST/JSON transcoding
Now that we have a working gRPC implementation of the Cloud Run v2 service, we can configure and test Envoy with the gRPC-JSON transcoder to use it:
envoy.yaml
:
admin:
address:
socket_address:
address: 0.0.0.0
port_value: 9901
static_resources:
listeners:
- name: listener1
address:
socket_address:
address: 0.0.0.0
port_value: 8080
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: grpc_json
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match:
prefix: "/v2/"
route:
cluster: fauxrpc
timeout: 60s
http_filters:
- name: envoy.filters.http.grpc_json_transcoder
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder
proto_descriptor: "/cloud_run_v2_services.pb"
services:
- google.cloud.run.v2.Services
match_incoming_request_route: true
convert_grpc_status: true
print_options:
add_whitespace: true
always_print_primitive_fields: true
always_print_enums_as_ints: true
preserve_proto_field_names: false
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: fauxrpc
type: LOGICAL_DNS
lb_policy: ROUND_ROBIN
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: {}
load_assignment:
cluster_name: fauxrpc
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: "127.0.0.1"
port_value: 6660
And then run Envoy:
IMAGE="docker.io/envoyproxy/envoy:distroless-dev-d34ad17441b515757dcd449d1590f960a6c62b3a"
podman run \
--interactive --tty --rm \
--name=envoy \
--net=host \
--volume=${PWD}/envoy.yaml:/etc/envoy/envoy.yaml \
--volume=${PWD}/cloud_run_v2_services.pb:/cloud_run_v2_services.pb \
${IMAGE}
Notes:
--net=host
so that it can access the host’s FauxRPC service- Mount the host’s
${PWD}/envoy.yaml
in the correct place in the Envoy container - Mount the service descriptor in the location referenced by
envoy.yaml
(proto_descriptor
)
And then you can rerun the curl
(second) test using Envoy’s listener1
(localhost:8080
) address:
ENDPOINT="http://localhost:8080"
Yields:
[] 2025/04/29 - 14:03:25 | 200 | 4.710924ms | 127.0.0.1 | POST "/google.cloud.run.v2.Services/CreateService"
[] 2025/04/29 - 14:03:25 | 200 | 4.80647ms | 127.0.0.1 | POST "/google.cloud.run.v2.Services/CreateService"
NOTE Envoy is providing the HTTP/REST transcoding and invoking the gRPC
google.cloud.run.v2.Services/CreateService
on FauxRPC.
If you use Tailscale, you can use tailscale serve
to expose the FauxRPC endpoint with TLS and then configure Envoy to proxy to the secure endpoint:
tailscale serve \
--https=443 \
localhost:6660
And Envoy:
envoy.yaml
:
admin:
address:
socket_address:
address: 0.0.0.0
port_value: 9901
static_resources:
listeners:
- name: listener1
address:
socket_address:
address: 0.0.0.0
port_value: 8080
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: grpc_json
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match:
prefix: "/v2/"
route:
cluster: secure
timeout: 60s
http_filters:
- name: envoy.filters.http.grpc_json_transcoder
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder
proto_descriptor: "/cloud_run_v2_services.pb"
services:
- google.cloud.run.v2.Services
match_incoming_request_route: true
convert_grpc_status: true
print_options:
add_whitespace: true
always_print_primitive_fields: true
always_print_enums_as_ints: true
preserve_proto_field_names: false
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: secure
type: STRICT_DNS
lb_policy: ROUND_ROBIN
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: {}
connect_timeout: 10s
load_assignment:
cluster_name: secure
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: "{HOST}"
port_value: 443
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
common_tls_context: {}
sni: "{HOST}"
NOTE Replace both occurrences of
{HOST}
with the hostname output bytailscale serve
as “Available within your tailnet”
And, of course, repeat the previous test.