Share via


gRPC 관련 성능 모범 사례

작성자: James Newton-King

gRPC는 고성능 서비스를 위해 설계되었습니다. 이 문서에서는 gRPC에서 가능한 최상의 성능을 얻는 방법을 설명합니다.

gRPC 채널 다시 사용

gRPC 호출 시 gRPC 채널을 재사용해야 합니다. 채널을 재사용하면 기존 HTTP/2 연결을 통해 호출이 멀티플렉싱될 수 있습니다.

gRPC 호출별로 새 채널을 생성하는 경우 완료하는 데 상당히 오랜 시간이 걸릴 수 있습니다. 호출할 때마다 새 HTTP/2 연결을 만들기 위해 클라이언트와 서버 간에 여러 번의 네트워크 왕복이 필요합니다.

  1. 소켓 열기
  2. TCP 연결 설정
  3. TLS 협상
  4. HTTP/2 연결 시작
  5. gRPC 호출하기

gRPC 호출 간에 채널을 공유하고 재사용할 수 있습니다.

  • gRPC 클라이언트는 채널을 사용하여 만듭니다. gRPC 클라이언트는 경량 개체이며 캐시하거나 재사용할 필요가 없습니다.
  • 다양한 형식의 클라이언트를 포함하여 여러 gRPC 클라이언트를 한 채널에서 만들 수 있습니다.
  • 채널 및 채널에서 만든 클라이언트를 여러 스레드에서 안전하게 사용할 수 있습니다.
  • 채널에서 만든 클라이언트는 여러 개의 동시 호출을 수행할 수 있습니다.

gRPC 클라이언트 팩터리는 중앙 집중식으로 채널을 구성하는 방법을 제공합니다. 자동으로 기본 채널을 다시 사용합니다. 자세한 내용은 .NET의 gRPC 클라이언트 팩터리 통합을 참조하세요.

연결 동시성

일반적으로 HTTP/2 연결에는 하나의 연결에서 한 번에 사용 가능한 최대 동시 스트림(활성 HTTP 요청) 수에 한도가 있습니다. 기본적으로 대부분의 서버는 이 한도를 100개의 동시 스트림으로 설정합니다.

gRPC 채널은 단일 HTTP/2 연결을 사용하고, 해당 연결에서 동시 호출이 멀티플렉싱됩니다. 활성 호출 수가 연결 스트림 한도에 도달하면 추가 호출은 클라이언트에서 큐에 대기합니다. 큐에 대기 중인 호출은 활성 호출이 완료될 때까지 대기한 후 전송됩니다. 부하가 높은 애플리케이션이나 장기 스트리밍 gRPC 호출에서는 이 한도로 인해 큐에 대기 중인 호출이 야기하는 성능 문제가 발생할 수 있습니다.

.NET 5에는 SocketsHttpHandler.EnableMultipleHttp2Connections 속성이 도입되었습니다. true로 설정하면 동시 스트림 한도에 도달하는 경우 채널에서 추가 HTTP/2 연결이 만들어집니다. GrpcChannel이 만들어지면 내부 SocketsHttpHandler가 추가 HTTP/2 연결을 만들도록 자동으로 구성됩니다. 앱이 고유한 처리기를 구성하는 경우에는 EnableMultipleHttp2Connectionstrue로 설정하는 것이 좋습니다.

var channel = GrpcChannel.ForAddress("https://localhost", new GrpcChannelOptions
{
    HttpHandler = new SocketsHttpHandler
    {
        EnableMultipleHttp2Connections = true,

        // ...configure other handler settings
    }
});

gRPC 호출 을 만드는 .NET Framework 앱은 사용하도록 WinHttpHandler구성해야 합니다. .NET Framework 앱은 WinHttpHandler.EnableMultipleHttp2Connections 속성을 설정하여 true 추가 연결을 만들 수 있습니다.

.NET Core 3.1 앱을 위한 몇 가지 해결 방법이 있습니다.

  • 부하가 높은 앱 영역에 대해 별도의 gRPC 채널을 만듭니다. 예를 들어 Logger gRPC 서비스의 부하가 높을 수 있습니다. 별도의 채널을 사용하여 앱에서 LoggerClient를 만듭니다.
  • gRPC 채널의 풀을 사용합니다(예: gRPC 채널 목록 만들기). Random은 gRPC 채널이 필요할 때마다 목록에서 채널을 선택하는 데 사용됩니다. Random을 사용하면 호출이 여러 연결에서 무작위로 분산됩니다.

Important

서버에 대한 최대 동시 스트림 한도를 늘리는 것은 이 문제를 해결하는 또 다른 방법입니다. 이는 Kestrel에서 MaxStreamsPerConnection으로 구성됩니다.

최대 동시 스트림 한도를 늘리지 않는 것이 좋습니다. 단일 HTTP/2 연결에서 스트림이 너무 많으면 새로운 성능 문제가 발생합니다.

  • 연결에 쓰려고 시도하는 스트림 간에 스레드 경합이 발생합니다.
  • 연결 패킷 손실로 인해 TCP 계층에서 모든 호출이 차단됩니다.

클라이언트 앱의 ServerGarbageCollection

.NET 가비지 수집기는 워크스테이션 GC(가비지 수집) 및 서버 가비지 수집의 두 가지 모드가 있습니다. 각 모드는 서로 다른 워크로드에 맞게 조정됩니다. ASP.NET Core 앱은 기본적으로 서버 GC를 사용합니다.

동시 앱은 일반적으로 서버 GC에서 더 나은 성능을 발휘합니다. gRPC 클라이언트 앱이 동시에 많은 수의 gRPC 호출을 보내고 받는 경우 서버 GC를 사용하도록 앱을 업데이트할 때 성능 이점이 있을 수 있습니다.

서버 GC를 사용하도록 설정하려면 앱의 프로젝트 파일에 <ServerGarbageCollection>을 설정합니다.

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

가비지 수집에 대한 자세한 내용은 워크스테이션 및 서버 가비지 수집을 참조하세요.

참고 항목

ASP.NET Core 앱은 기본적으로 서버 GC를 사용합니다. gRPC 클라이언트 콘솔 앱과 같이 서버가 아닌 gRPC 클라이언트 앱에서만 <ServerGarbageCollection>을 사용하도록 설정하는 것이 유용합니다.

부하 분산

일부 부하 분산 장치는 gRPC에서 효과적으로 작동하지 않습니다. L4(전송) 부하 분산 장치는 엔드포인트 간에 TCP 연결을 분산하여 연결 수준에서 작동합니다. 이 접근 방식은 HTTP/1.1을 사용하여 수행된 API 호출의 부하를 분산하는 데 적합합니다. HTTP/1.1을 사용하여 수행된 동시 호출은 여러 연결에서 전송되므로 엔드포인트에서 호출의 부하를 분산할 수 있습니다.

L4 부하 분산 장치는 연결 수준에서 작동하므로 gRPC에서 제대로 작동하지 않습니다. gRPC는 단일 TCP 연결에서 여러 호출을 멀티플렉싱하는 HTTP/2를 사용합니다. 해당 연결을 통한 모든 gRPC 호출은 하나의 엔드포인트로 이동합니다.

gRPC의 부하를 효과적으로 분산하는 두 가지 옵션이 있습니다.

  • 클라이언트 쪽 부하 분산
  • L7(애플리케이션) 프록시 부하 분산

참고 항목

엔드포인트 간에 gRPC 호출의 부하만 분산할 수 있습니다. 스트리밍 gRPC 호출이 설정되면 스트림을 통해 전송되는 모든 메시지가 하나의 엔드포인트로 이동합니다.

클라이언트 쪽 부하 분산

클라이언트 쪽 부하 분산을 사용하면 클라이언트가 엔드포인트를 인식합니다. 각 gRPC 호출을 위해 클라이언트는 호출을 보낼 다른 엔드포인트를 선택합니다. 클라이언트 쪽 부하 분산은 대기 시간이 중요한 경우에 적합합니다. 클라이언트와 서비스 간에 프록시가 없으므로 호출이 서비스로 직접 전송됩니다. 클라이언트 쪽 부하 분산의 단점은 각 클라이언트가 사용해야 하는 사용 가능한 엔드포인트를 추적해야 한다는 것입니다.

할당 준비 클라이언트 부하 분산은 부하 분산 상태가 중앙 위치에 저장되는 기술입니다. 클라이언트는 부하 분산을 결정할 때 사용할 정보를 중앙 위치에서 주기적으로 쿼리합니다.

자세한 내용은 gRPC 클라이언트 쪽 부하 분산을 참조하세요.

프록시 부하 분산

L7(애플리케이션) 프록시는 L4(전송) 프록시보다 더 높은 수준에서 작동합니다. L7 프록시는 HTTP/2를 이해합니다. 프록시는 하나의 HTTP/2 연결에서 멀티플렉싱된 gRPC 호출을 수신하고 여러 백 엔드 엔드포인트에 분산합니다. 프록시를 사용하는 것은 클라이언트 쪽 부하 분산보다 간단하지만 gRPC 호출에 추가 대기 시간을 추가합니다.

많은 L7 프록시를 사용할 수 있습니다. 몇 가지 옵션은 다음과 같습니다.

프로세스 간 통신

클라이언트와 서비스 간의 gRPC 호출은 일반적으로 TCP 소켓을 통해 전송됩니다. TCP는 네트워크를 통해 통신하는 데 유용하지만, 클라이언트와 서비스가 동일한 머신에 있는 경우에는 IPC(프로세스 간 통신)가 더 효율적입니다.

동일한 머신에 있는 프로세스 간 gRPC 호출에는 Unix 도메인 소켓이나 명명된 파이프와 같은 전송을 사용하는 것이 좋습니다. 자세한 내용은 gRPC와 프로세스 간 통신을 참조하세요.

연결 유지 ping

연결 유지 ping을 사용하여 비활성 기간 동안 HTTP/2 연결을 유지할 수 있습니다. 앱이 작업을 계속할 때 기존 HTTP/2 연결을 곧바로 사용할 수 있으면 연결을 다시 설정하느라 지연이 발생하지 않고 초기 gRPC 호출을 신속하게 수행할 수 있습니다.

연결 유지 ping은 SocketsHttpHandler에서 구성됩니다.

var handler = new SocketsHttpHandler
{
    PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
    KeepAlivePingDelay = TimeSpan.FromSeconds(60),
    KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
    EnableMultipleHttp2Connections = true
};

var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
    HttpHandler = handler
});

앞의 코드는 비활성 기간 동안 60초마다 연결 유지 ping을 서버에 보내는 채널을 구성합니다. ping을 사용하면 서버와 사용 중인 모든 프록시가 비활성 상태로 인해 연결을 닫지 않습니다.

참고 항목

활성 ping을 유지하면 연결을 활성 상태로 유지하는 데만 도움이 됩니다. 연결에 대한 장기 실행 gRPC 호출은 비활성 서버 또는 중간 프록시에 의해 종료될 수 있습니다.

흐름 제어

HTTP/2 흐름 제어는 앱이 데이터로 과부하되는 것을 방지하는 기능입니다. 흐름 제어를 사용하는 경우:

  • 각 HTTP/2 연결 및 요청에는 사용 가능한 버퍼 창이 있습니다. 버퍼 창은 앱이 한 번에 받을 수 있는 데이터의 양입니다.
  • 버퍼 창이 채워지면 흐름 제어가 활성화됩니다. 활성화되면 전송 앱이 더 많은 데이터 전송을 일시 중지합니다.
  • 수신 앱이 데이터를 처리하면 버퍼 창의 공간을 사용할 수 있습니다. 전송 앱이 데이터 보내기를 다시 시작합니다.

흐름 제어는 큰 메시지를 수신할 때 성능에 부정적인 영향을 줄 수 있습니다. 버퍼 창이 들어오는 메시지 페이로드보다 작거나 클라이언트와 서버 간에 대기 시간이 있는 경우 시작/중지 버스트에서 데이터를 보낼 수 있습니다.

흐름 제어 성능 문제는 버퍼 창 크기를 늘려 해결할 수 있습니다. Kestrel에서 이는 앱 시작 시 InitialConnectionWindowSizeInitialStreamWindowSize로 구성됩니다.

builder.WebHost.ConfigureKestrel(options =>
{
    var http2 = options.Limits.Http2;
    http2.InitialConnectionWindowSize = 1024 * 1024 * 2; // 2 MB
    http2.InitialStreamWindowSize = 1024 * 1024; // 1 MB
});

권장 사항:

  • gRPC 서비스가 Kestrel의 기본 스트림 창 크기인 96KB보다 큰 메시지를 수신하는 경우가 많으면 연결 및 스트림 창 크기를 늘리는 것이 좋습니다.
  • 연결 창 크기는 항상 스트림 창 크기와 크거나 같아야 합니다. 스트림은 연결의 일부이며 보낸 사람은 둘 다로 제한됩니다.

흐름 제어의 작동 방식에 대한 자세한 내용은 HTTP/2 Flow Control(블로그 게시물)을 참조하세요.

Important

Kestrel의 창 크기를 늘리면 앱을 대신하여 Kestrel이 더 많은 데이터를 버퍼링할 수 있으므로 메모리 사용량이 증가할 수 있습니다. 불필요하게 큰 창 크기를 구성하지 마세요.

스트리밍

gRPC 양방향 스트리밍을 사용하면 고성능 시나리오에서 단항 gRPC 호출을 바꿀 수 있습니다. 양방향 스트림이 시작된 후에는 메시지를 스트리밍하는 것이 여러 개의 단항 gRPC 호출을 통해 메시지를 보내는 것보다 빠릅니다. 스트리밍된 메시지는 기존 HTTP/2 요청에서 데이터로 전송되며, 단항 호출별로 새 HTTP/2 요청을 만드는 오버헤드가 제거됩니다.

예제 서비스:

public override async Task SayHello(IAsyncStreamReader<HelloRequest> requestStream,
    IServerStreamWriter<HelloReply> responseStream, ServerCallContext context)
{
    await foreach (var request in requestStream.ReadAllAsync())
    {
        var helloReply = new HelloReply { Message = "Hello " + request.Name };

        await responseStream.WriteAsync(helloReply);
    }
}

클라이언트 예:

var client = new Greet.GreeterClient(channel);
using var call = client.SayHello();

Console.WriteLine("Type a name then press enter.");
while (true)
{
    var text = Console.ReadLine();

    // Send and receive messages over the stream
    await call.RequestStream.WriteAsync(new HelloRequest { Name = text });
    await call.ResponseStream.MoveNext();

    Console.WriteLine($"Greeting: {call.ResponseStream.Current.Message}");
}

성능상의 이유로 단항 호출을 양방향 스트리밍으로 대체하는 것은 고급 기법이며 많은 상황에서 적합하지 않습니다.

스트리밍 호출을 사용하는 것은 다음과 같은 경우에 적합합니다.

  1. 높은 처리량이나 짧은 대기 시간이 필요한 경우
  2. gRPC 및 HTTP/2가 성능 병목 상태로 식별된 경우
  3. 클라이언트의 작업자가 gRPC 서비스를 사용하여 일반 메시지를 보내거나 받는 경우

단항 대신 스트리밍 호출을 사용할 때 추가 복잡성과 제한 사항에 유의하세요.

  1. 서비스 또는 연결 오류로 인해 스트림이 중단될 수 있습니다. 오류가 발생하는 경우 스트림을 다시 시작하기 위한 논리가 필요합니다.
  2. RequestStream.WriteAsync는 다중 스레딩에 안전하게 사용할 수 없습니다. 한 번에 하나의 메시지만 스트림에 쓸 수 있습니다. 여러 스레드의 메시지를 단일 스트림을 통해 보내려면 Channel<T> 같은 생산자/소비자 큐가 메시지를 마샬링해야 합니다.
  3. gRPC 스트리밍 방법은 한 유형의 메시지를 수신하고 한 유형의 메시지 유형을 보내는 것으로 제한됩니다. 예를 들어 rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage)RequestMessage를 수신하고 ResponseMessage를 보냅니다. Protobuf가 Anyoneof를 사용하여 알 수 없는 메시지나 조건부 메시지를 지원하면 이러한 제한을 해결할 수 있습니다.

이진 페이로드

이진 페이로드는 Protobuf에서 bytes 스칼라 값 형식으로 지원됩니다. C#에서 생성된 속성은 ByteString을 속성 형식으로 사용합니다.

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

Protobuf는 오버헤드를 최소화 하면서 큰 이진 페이로드를 효율적으로 직렬화하는 이진 형식입니다. JSON과 같은 텍스트 기반 형식은 base64로의 인코딩이 필요하고 메시지 크기에 33%를 추가해야 합니다.

대량 ByteString 페이로드를 사용하는 경우 아래에서 설명하는 불필요한 복사 및 할당을 방지하기 위한 몇 가지 모범 사례가 있습니다.

이진 페이로드 보내기

ByteString 인스턴스는 보통 ByteString.CopyFrom(byte[] data)를 사용하여 만듭니다. 이 메서드는 새 ByteString 및 새 byte[]를 할당합니다. 데이터가 새 바이트 배열에 복사됩니다.

UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes)을 사용하여 ByteString 인스턴스를 만들면 추가 할당과 복사를 방지할 수 있습니다.

var data = await File.ReadAllBytesAsync(path);

var payload = new PayloadResponse();
payload.Data = UnsafeByteOperations.UnsafeWrap(data);

바이트는 UnsafeByteOperations.UnsafeWrap으로 복사되지 않으므로 ByteString이 사용되는 동안 수정해서는 안 됩니다.

UnsafeByteOperations.UnsafeWrap에는 Google.Protobuf 버전 3.15.0 이상이 필요합니다.

이진 페이로드 읽기

ByteString.MemoryByteString.Span 속성을 사용하여 ByteString 인스턴스에서 데이터를 효율적으로 읽을 수 있습니다.

var byteString = UnsafeByteOperations.UnsafeWrap(new byte[] { 0, 1, 2 });
var data = byteString.Span;

for (var i = 0; i < data.Length; i++)
{
    Console.WriteLine(data[i]);
}

이러한 속성을 사용하면 코드에서 할당 또는 복사 없이 ByteString의 데이터를 직접 읽을 수 있습니다.

대부분의 .NET API에는 ReadOnlyMemory<byte>byte[] 오버 로드가 있으므로 ByteString.Memory 은 기본 데이터를 사용하기 위해 권장되는 방법입니다. 그러나 앱에서 데이터를 바이트 배열로 가져와야 하는 경우도 있습니다. 바이트 배열이 필요한 경우 MemoryMarshal.TryGetArray 메서드를 사용하여 데이터의 새 복사본을 할당하지 않고 ByteString에서 배열을 가져올 수 있습니다.

var byteString = GetByteString();

ByteArrayContent content;
if (MemoryMarshal.TryGetArray(byteString.Memory, out var segment))
{
    // Success. Use the ByteString's underlying array.
    content = new ByteArrayContent(segment.Array, segment.Offset, segment.Count);
}
else
{
    // TryGetArray didn't succeed. Fall back to creating a copy of the data with ToByteArray.
    content = new ByteArrayContent(byteString.ToByteArray());
}

var httpRequest = new HttpRequestMessage();
httpRequest.Content = content;

앞의 코드가 하는 역할은 다음과 같습니다.

  • ByteString.Memory에서 MemoryMarshal.TryGetArray를 사용해 배열을 가져오려고 시도합니다.
  • 성공적으로 검색된 경우 ArraySegment<byte>를 사용합니다. 세그먼트에는 배열, 오프셋, 개수에 대한 참조가 있습니다.
  • 그렇지 않으면 ByteString.ToByteArray()를 사용하여 새 배열 할당으로 대체(Fallback)합니다.

gRPC 서비스 및 큰 이진 페이로드

gRPC 및 Protobuf는 큰 이진 페이로드를 보내고 받을 수 있습니다. 이진 Protobuf는 이진 페이로드를 직렬화할 때 텍스트 기반 JSON보다 효율적이지만 큰 이진 페이로드를 사용할 때 유의해야 하는 중요한 성능 특성은 여전히 있습니다.

gRPC는 메시지 기반 RPC 프레임워크로, 다음을 의미합니다.

  • gRPC에서 메시지를 보내기 전에 전체 메시지를 메모리에 로드합니다.
  • 메시지가 수신되면 전체 메시지가 메모리로 역직렬화됩니다.

이진 페이로드는 바이트 배열로 할당됩니다. 예를 들어 10MB 이진 페이로드는 10MB 바이트 배열을 할당합니다. 큰 이진 페이로드가 포함된 메시지는 대형 개체 힙에 바이트 배열을 할당할 수 있습니다. 대용량 할당은 서버 성능 및 확장성에 영향을 줍니다.

큰 이진 페이로드를 사용하는 고성능 애플리케이션을 만들기 위한 조언: