Protocols10 min read27 июля 2026 г.

Load Testing gRPC, Step by Step

From a one-shot unary probe to a bidi stream with message-RTT percentiles: dynamic schemas without codegen, live HTTP/2 channels, streaming assertions, and CI gating with the perfscale native engine.

Автор: Perfscale Team

What you'll build

A load test that opens real HTTP/2 channels against your gRPC endpoint, makes unary calls from JSON templates, runs a bidirectional stream of unique messages from one template, and measures both the call latency and the application-level message round trip — scaled across concurrent virtual users, with pass/fail assertions you can gate CI on.

We'll go in three steps: a one-shot unary call from the CLI, a live channel with a bidi stream, and the same test on the platform with its gRPC metrics dashboard. Then the metrics model, assertions, and CI. Everything here is free and open source — the std/grpc* actions ship in the perfscale engine on every plan.

Prerequisites

  • The perfscale CLI (install instructions): npm install -g @perfscale/exe, or perfscale self-update to be current (gRPC needs v0.7.0+)

  • A gRPC endpoint to test. To follow along locally, the OSS repo ships an echo server (needs a Rust toolchain):

    cargo run -p perfscale-core --example grpc_echo_server
    

    It listens plaintext on 127.0.0.1:50051 with server reflection enabled and implements perfscale.test.v1.Echo with Unary, ServerStream, ClientStream, and Bidi methods (plus Fail and Large for edge cases).

  • Against a real (grpcs://, production) endpoint you need exactly one schema source: server reflection enabled on the server, or a descriptor set built once from your protos:

    protoc --descriptor_set_out=api.pb --include_imports api.proto
    base64 -i api.pb   # macOS; on Linux: base64 -w0 api.pb
    

    The base64 output goes into the step's descriptor_set: field. No codegen, no stubs — the engine maps your JSON payloads to protobuf at run time using the standard protobuf-JSON rules.


Step 1 — A first unary call

The simplest gRPC test is std/grpc@v1: connect, load the schema, make one unary call, close — one step, timed as one latency sample.

Create test.yaml:

steps:
  - name: unary smoke
    uses: std/grpc@v1
    with:
      url: grpc://127.0.0.1:50051
      reflection: true        # echo server has reflection on; prod may need descriptor_set
      method: "perfscale.test.v1.Echo/Unary"
      payload: { message: "ping-${seq}" }
    check:
      duration_ms_lt: 250

Native tests always run with a config. Create config.yaml:

vus: 1
duration: 10s

Run it:

perfscale run -f test.yaml -c config.yaml

Things worth knowing about this little file:

  • reflection: true tells the engine to pull the schema from the server's reflection service and cache it per URL for the rest of the run — nothing to generate, nothing to commit.
  • payload is plain JSON, mapped with protobuf-JSON rules (proto field names or their camelCase json_name, 64-bit ints as strings, enums as names). The ${seq} token expands per call, so every request carries a distinct message.
  • Methods are named "package.Service/Method". A typo fails with a did-you-mean suggestion, and a bad schema fails fast — before any network I/O.

Reading the output: per-request lines stream live, then the summary prints. A pure gRPC run is all grpc_* lines — gRPC steps never feed the http_req_* series:

  • grpc_req_duration gets one sample per unary call — your call-latency histogram.
  • grpc_msg_rtt equals the request duration on a unary call (it gets interesting on streams — next step).
  • grpc_msgs_sent / grpc_msgs_received count one message each way per call.
  • grpc_req_failed stays at 0 while the server answers OK (status 0, the default expect_status).

The run exits 0 even when checks fail — failed checks are load-test feedback, printed to stderr and the summary, not a CLI error.


Step 2 — A live channel and a bidi stream

One-shot steps re-connect and re-load the schema on every call. Real load holds a channel: std/grpc-connect@v1 opens a live HTTP/2 channel for the rest of the iteration, and every call and stream rides it — the connect and the schema load are paid once per iteration, not per call.

Replace test.yaml (this is the scenario that ships as examples/grpc.test.yaml in the OSS repo):

steps:
  - name: open channel
    uses: std/grpc-connect@v1
    with:
      url: grpc://127.0.0.1:50051
      reflection: true
    outputs: conn

  - name: unary echo
    uses: std/grpc-call@v1
    with:
      id: "${{ conn.id }}"
      method: "perfscale.test.v1.Echo/Unary"
      payload: { message: "ping-${seq}" }
    check:
      duration_ms_lt: 250

  - name: open bidi stream
    uses: std/grpc-stream-open@v1
    with:
      id: "${{ conn.id }}"
      method: "perfscale.test.v1.Echo/Bidi"
    outputs: stream

  - name: send events
    uses: std/grpc-stream-send@v1
    with:
      id: "${{ stream.id }}"
      payload: { message: "evt-${seq}" }
      repeat: 5
      interval_ms: 20

  - name: await echoes
    uses: std/grpc-stream-recv@v1
    with:
      id: "${{ stream.id }}"
      until_contains: "evt-5"
      timeout: 5000
    check:
      messages_count_gte: 5

  - name: close stream
    uses: std/grpc-stream-close@v1
    with: { id: "${{ stream.id }}" }

  - name: pace
    uses: std/sleep@v1
    with: { ms: 200 }

Bump the load in config.yaml:

vus: 5
duration: 30s

Run it again. The moving parts:

  • outputs: conn stores the connect step's result; later steps address the channel as ${{ conn.id }}. Channel and stream ids live only inside their VU's current iteration — whatever you leave open is dropped at iteration end (streams are cancelled), so call grpc-stream-close when you want a clean, status-checked shutdown.
  • One template, 5 unique messages per stream: ${seq} expands per send, one message every 20 ms.
  • until_contains: "evt-5" is the recv step's stopping rule: read until a message contains the last event. Alternatives: until_json (JSON-subset match) or plain count: N.
  • The same family covers the other two streaming shapes: server-streaming passes its single payload at open; client-streaming sends like bidi and gets its one response back from grpc-stream-close.

The summary from this run:

vus....................: 5 min=1 max=5
iterations..............: 140 4.67/s
grpc_msgs_received: 840 28.02/s
grpc_msgs_sent: 840 28.02/s
grpc_req_failed: 0 0.00/s
grpc_msg_rtt: avg=2.90ms p(50)=2.80ms p(90)=3.60ms p(95)=4.10ms p(99)=5.30ms min=1.90ms max=7.20ms count=280
grpc_req_duration: avg=2.95ms p(50)=2.85ms p(90)=3.70ms p(95)=4.20ms p(99)=5.40ms min=1.95ms max=7.40ms count=140

The counts tell the story: 140 iterations → 140 unary samples in grpc_req_duration, and 280 grpc_msg_rtt samples — one per unary call plus one per stream, where it records the time from a send to the first reply matching your until-rule. Message counters: 6 sent and 6 received per iteration (1 unary + 5 stream) = 840.


Step 3 — Run it on the platform

The same YAML runs on the platform unchanged — no k6 script, nothing to install on your side. In the console, create a native test and paste your test.yaml and config.yaml; environment-specific values (the target URL, auth tokens) go into env variables referenced as ${{ vars.* }}.

Creating a native gRPC test in the perfscale console — test editor with env variables

Pick the machines to generate load from — distributed workers apply the VUs in parallel, each holding its own channel per iteration — and start the run. The Runs page streams the logs live:

Run logs streaming while a gRPC load test executes on platform machines

The run is detected as gRPC automatically, and the Metrics page switches to gRPC tiles: send/receive rates, message counters, message-RTT percentiles, and call duration:

gRPC metrics tiles of a real run (test grpc-prod-check): Send/Receive rate, Messages sent/received, Message RTT p50/p95/max, Call duration p95

gRPC metrics

MetricWhat it means
grpc_req_durationUnary call latency histogram (std/grpc@v1 and std/grpc-call@v1 only). Streams deliberately don't feed it — their lifetimes span your steps.
grpc_msg_rttApplication-level message RTT. Equals the request duration on a unary call; on a stream recv it's the send→matching-reply time, recorded only when an until_* rule matched and a send preceded it on the same stream.
grpc_msgs_sent / grpc_msgs_receivedMessage throughput counters, per call and per stream step.
grpc_req_failedRPCs that did not meet expect_status. For streams, grpc-stream-close is what turns the final status into this counter.

Two consequences: a failed connect emits no metrics (no RPC was made), and gRPC steps never touch http_req_duration — the grpc_* series are the whole story.

Assertions

Unary calls assert on the status code with expect_status (default 0 = OK). Error-path tests read naturally — the echo server's Fail method returns INVALID_ARGUMENT (status 3), so this step passes when the call fails the right way:

  - name: error path
    uses: std/grpc-call@v1
    with:
      id: "${{ conn.id }}"
      method: "perfscale.test.v1.Echo/Fail"
      payload: { message: "boom" }
      expect_status: 3        # INVALID_ARGUMENT

Latency bounds go in a check (duration_ms_lt: 250, as in the steps above). Stream receive/close steps expose a messages list, asserted with at-least-one-matches semantics:

    check:
      messages_count_gte: 5                 # at least 5 messages arrived
      message_contains: "evt"               # some message contains the substring
      message_matches: { message: "evt-5" } # some message JSON-subset-matches

For deterministic exchanges, address one message by index: check: { on: got.messages.0, message_matches: { message: "welcome" } }.

CI/CD

The same two files gate a pipeline. On GitHub, the Perfscale/github-action step installs the pinned CLI, runs your test (file + config inputs), and packs the summary and metrics into an artifact. On GitLab, include: the templates from Perfscale/gitlab-ci. The full wiring — REST API triggers, webhooks back into CI — is in CI/CD integration.

One gRPC-specific gotcha: --summary-export parses only the http_req_* family, so a pure gRPC run exports summary: null. Gate on the grpc_* stdout lines (or add a companion HTTP step) when you need a hard CI threshold.

Next steps

Комментарии

Ответить в Bluesky