Skip to content

hostagent: fsync on every log record can stall grpc port forwarding under connection churn #5410

Description

@chrisanderton

Description

When a workload creates a large number of short-lived TCP connections through a forwarded port, publishing the next port can sometimes stall. In our case, using Lima's default gRPC port forwarder, the new container was already running and listening inside the VM, but its port did not become reachable from the host for 9 to 11 seconds.

We first saw this through Colima while running an integration test suite. About one in five container starts failed its readiness check because the host-side port did not become available in time.

Startup times were very consistent: around 750 ms normally, but became 9 to 11 seconds when the problem occurred.

Environment

  • Lima 2.2.0
  • macOS 14.8.5
  • Apple Silicon
  • vz
  • APFS
  • gRPC port forwarder, Lima's default
  • Found through Colima 0.10.3, which uses Lima's hostagent unchanged

Workload

Our integration test harness repeatedly:

  1. starts a container and publishes a port;
  2. sends HTTP traffic through that port;
  3. removes the container;
  4. immediately starts another container with a published port; and
  5. waits for the new host-side port to become reachable.

Each cycle creates hundreds to a few thousand short-lived TCP connections.

Debug

I went old school, adding various log lines into various points to try and figure out where the time was going. This included many false leads that I cannot remember.

Tracing the host-side event handling showed that the event callback could take 10 to 11 seconds to return. While it was blocked, the host was not processing later guest-agent events.

Most of that time was spent inside two logrus.Debugf calls.

Logging behaviour

cmd/limactl/hostagent.go wraps the hostagent's stdout and stderr in a syncWriter:

Each successful write is followed by Sync().

On our host we benchmarked and there is a significant performance penalty to this approach - with sync vs. without:

Method Rate Per record
write + fsync per record 242 lines/s 4.13 ms
write only 709,143 lines/s 0.0014 ms

We also measured the live hostagent log drain rate at 183 to 234 lines/s across six separate samples.

The reproduction above generated 3,214 log records. At around 200 records per second, that is enough to explain a delay of roughly the size we were seeing.

Note the next part is AI-assisted - I got to the cause of the delay, but trying to explain the interactions got tricky so I needed help. I'll get on to options at the end - but jumping ahead: removing the sync after every write is the patch I made locally and the stall disappeared (also tested the coalesce option).

Cause

The stall comes from the interaction between logging and the gRPC port forwarder.

syncWriter calls Sync() after every hostagent log write.

At the same time, logrus holds its process-wide output lock while writing each record. This means each log record holds that lock while the fsync completes.

The gRPC port forwarder writes several debug records when each TCP tunnel is closed. A burst of hundreds of short-lived connections can therefore create thousands of log records.

Other goroutines that need to log have to wait behind that backlog.

One of those is the guest-agent event callback. GuestAgentClient.Events runs that callback inline on the event receive loop.

If the callback blocks while logging, the host stops reading further guest-agent events. The event for the next forwarded port has already been sent by the guest, but it is not processed until the logging backlog clears.

That is where the 9 to 11 second delay comes from.

Why it mainly affects the gRPC forwarder

The per-record fsync applies to all hostagent logging, but the gRPC forwarder produces much more log traffic during connection churn.

Forwarder Debug records per TCP connection Scales with
gRPC 4 (pkg/portfwd/client.go:26,174,181,187) connection count
SSH 0, only per-port Infof (pkg/hostagent/port.go:129,147,156) port count

With the SSH forwarder, the connection data is carried by the SSH ControlMaster rather than through limactl, so there are no hostagent log records for each TCP connection teardown.

In an otherwise identical workload, the SSH hostagent log contained 25 ssh -O forward commands and no tunnel teardown records. The gRPC run produced about 45,000 tunnel teardown records.

This is why the problem first looked specific to the gRPC forwarder.

The underlying logging issue is not limited to gRPC. The gRPC path simply produces enough records to expose it during normal connection churn.

Log level

There is another factor that increases the amount of logging.

initLogrus in cmd/limactl/hostagent.go forces the hostagent to Debug level, or Trace when limactl itself is at Debug.

There does not appear to be a supported way to reduce the hostagent log level below Debug (note the logic is if debug.. else..: so this seems like an issue too as it's not possible to be less verbose?).

This means users of the gRPC forwarder cannot avoid the per-connection debug records through configuration.

Suggested fix

Instead of calling Sync() after every log record, flush periodically.

We tested a 100 ms coalescing interval.

In the same microbenchmark, throughput increased to:

553,599 lines/s

There were four flushes across 200,000 log records.

Running the same reproduction against that build changed the port publication time from:

10514 ms

to:

252 ms

The hostagent log still grew by the same 3,214 records.

No logging was removed. The records were simply written without an fsync after each one.

Removing Sync() completely is about 22% faster in the microbenchmark, but the difference is small at normal log volumes.

A short coalescing interval keeps the original behaviour of regularly flushing the log while avoiding a disk sync for every record.

Origin

The per-write sync was introduced in:

722b7d1b (hostagent: sync every (io.Writer).Write)

It was added in July 2021 and first released in v0.6.0.

At the time, the hostagent produced relatively few log records during the lifetime of an instance.

The cost became more noticeable later when the port forwarder started producing records for individual connection teardowns.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions