gRPC with (spring, load balancing, TLS, health check) 예제
0. gRPC base
Git : https://github.com/Mussyan/grpc_handson
을 베이스로 아래 내용 중 '기존 ~ 코드'는 위 깃의 2, 3, 4번 챕터를 통해 만든 프로젝트를 뜻한다.
1. with spring
Reference : https://coding-start.tistory.com/352
기존 코드에 레퍼런스를 적절히 활용하였음.
build.gradle(서버, 클라이언트 공통)
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8'
}
}
plugins {
id 'org.springframework.boot' version '2.2.6.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
}
sourceCompatibility = 1.8
targetCompatibility = 1.8
sourceSets {
src {
main {
java {
srcDirs 'build/generated/source/proto/main/grpc'
srcDirs 'build/generated/source/proto/main/java'
}
}
}
}
jar {
// enabled = true
archivesBaseName = "grpc_handson_jar"
from {
configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
}
manifest {
attributes 'Main-Class' : "HelloServerRunner"
}
}
apply plugin: 'com.google.protobuf'
repositories {
mavenCentral()
}
dependencies {
/**
* gRPC
*/
compile group: 'io.grpc', name: 'grpc-netty-shaded', version: '1.35.0'
compile group: 'io.grpc', name: 'grpc-protobuf', version: '1.35.0'
compile group: 'io.grpc', name: 'grpc-stub', version: '1.35.0'
implementation 'javax.annotation:javax.annotation-api:1.3.2'
implementation "com.google.protobuf:protobuf-java-util:3.8.0"
compile group: 'com.google.protobuf', name: 'protobuf-java', version: '3.8.0'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation 'io.projectreactor:reactor-test'
testCompile group: 'junit', name: 'junit', version: '4.12'
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.8.0"
}
plugins {
grpc {
artifact = 'io.grpc:protoc-gen-grpc-java:1.35.0'
}
}
generateProtoTasks {
all()*.plugins {
grpc {}
}
}
}
proto 파일
syntax = "proto3";
option java_multiple_files = true;
option java_package = "com.leo.grpc.spring";
package com.leo.grpc.spring;
message HelloRequest {
string name = 1;
int32 age = 2;
string team = 3;
}
message HelloResponse {
string greeting = 1;
}
message DuplicateRequest {
repeated int32 nums = 1;
}
message DuplicateResponse {
bool isDuplicated = 1;
}
service HelloService {
rpc hello(HelloRequest) returns (HelloResponse);
rpc duplicate(DuplicateRequest) returns (DuplicateResponse);
}
3. Load balancing
https://github.com/grpc/grpc/blob/master/doc/load-balancing.md
문서 일부를 번역해보자면
호출 별 부하 분산
gRPC의 로드 밸런싱은 연결 단위가 아닌 호출 단위.
단일 클라이언트의 다중 요청에도 모든 서버에 로드 밸런싱을 함.
로드 밸런싱 방식
- 프록시 방식 로드 밸런싱
견고하고 신뢰할 수 있으며
리소스가 많이 들고 대기시간이 길며 요청이 많을 수록 비효율적
- 균형 인식 클라이언트
로드 밸런싱 로직을 클라이언트단에 구현.
복잡하고 클라이언트가 커짐
- 외부 로드 밸런싱 서비스
클라이언트는 로드 밸런싱 스타일(round_robin 등)만 구현하며
외부 로드 밸런싱 서비스를 활용함
grpc 코드 제너레이션을 통해 간단한 로드 밸런싱 메소드를 자체 제공.
복잡한 처리에 대한 자세한 구현이 필요할 경우 외부 load balancing(ALB, Kubernetes 등)을 활용할 것을 권장하는 듯 하다.
자체 지원하는 로드 밸런싱을 아래와 같이 구현해보았다.
클라이언트 코드 아래와 같이 수정
//기존 연결 방식
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 36200)
.usePlaintext()
.build();
//로드밸런싱 적용 연결 방식
NameResolverProvider nameResolverFactory = new MultiAddressNameResolverFactory(
new InetSocketAddress("localhost", 36200),
new InetSocketAddress("localhost", 36201)
);
NameResolverRegistry nameResolverRegistry = NameResolverRegistry.getDefaultRegistry();
nameResolverRegistry.register(nameResolverFactory);
ManagedChannel channel = ManagedChannelBuilder.forTarget("localhost")
.defaultLoadBalancingPolicy("round_robin")
.usePlaintext()
.build();
--------------------------------------------------------------------------------------
//MultiAddressNameResolverFactory.java
public class MultiAddressNameResolverFactory extends NameResolverProvider {
final List<EquivalentAddressGroup> addresses;
MultiAddressNameResolverFactory(SocketAddress... addresses) {
this.addresses = Arrays.stream(addresses)
.map(EquivalentAddressGroup::new)
.collect(Collectors.toList());
}
@Override
public NameResolver newNameResolver(URI targetUri, Args args) {
return new NameResolver() {
@Override
public String getServiceAuthority() {
return "fakeAuthority";
}
@Override
public void start(Listener2 listener) {
listener.onResult(ResolutionResult.newBuilder().setAddresses(addresses)
.setAttributes(Attributes.EMPTY).build());
}
@Override
public void shutdown() {
}
};
}
@Override
public String getDefaultScheme() {
return "multiaddress";
}
@Override
protected boolean isAvailable() {
return true;
}
@Override
protected int priority() {
return 0;
}
}
서버 2개를 각각 36200, 36201번 포트로 열고 하나씩 죽여가며 테스트 진행.
중간의 'defaultLoadBalancingPolicy'의 경우 'round_robin', 'pick_first' 두 가지 옵션이 있으며
pick_first : 처음 connection을 맺은 서버에 트래픽을 전부 요청하고 실패할 시에 다음 서버에 요청을 보낸다.
round_robin : 일단 모든 서버와 connection을 맺고 순차적으로 요청을 보낸다.
놀랍게도 더 부족해보이는 pick_first가 디폴트 값이다.
4. Throttling
https://github.com/grpc/grpc-java/issues/6426
https://groups.google.com/g/grpc-io/c/XCMIva8NDO8
위 두 링크를 요약해보자면,
서버 생성 시 옵션인 ServerBuilder.executor() 를 활용하여 동시 호출을 제한하거나
세마포어를 활용하는 등 흐름을 제어하고
쌓여있는 클라이언트 호출에 대해선 별도의 처리가 필요하다.
Netflix의 동시성 제한 Reference : https://github.com/Netflix/concurrency-limits
5. TLS
https://github.com/grpc/grpc-java/tree/master/examples/example-tls
https://github.com/techschool/pcbook-java
서버 코드 아래와 같이 수정
//기존 서버 코드
Server server = ServerBuilder.forPort(port)
.addService(new HelloServiceImpl())
.build();
//TLS 적용 서버 코드
Server server = NettyServerBuilder.forPort(port)
.addService(new HelloServiceImpl())
.sslContext(getSslContextBuilder().build())
.build();
------------------------------------------------------
//getSslContextBuilder method
public static SslContextBuilder getSslContextBuilder() {
String pathPrefix = "/Users/mzc01-dabins/grpc_handson_v2/2-gRPC_server_java/grpc_server/src/main/resources/keys/";
File certChainFilePath = new File(pathPrefix + "server1.pem");
File privateKeyFilePath = new File(pathPrefix + "server1.key");
File trustCertCollectionFilePath = new File(pathPrefix + "ca.pem");
SslContextBuilder sslClientContextBuilder = SslContextBuilder.forServer(certChainFilePath, privateKeyFilePath);
sslClientContextBuilder.trustManager(trustCertCollectionFilePath);
sslClientContextBuilder.clientAuth(ClientAuth.REQUIRE);
return GrpcSslContexts.configure(sslClientContextBuilder);
}
클라이언트 코드 아래와 같이 수정
//기존 채널 생성 코드
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 36200)
.usePlaintext()
.build();
//TLS 적용 채널 생성 코드
ManagedChannel channel = NettyChannelBuilder.forAddress("localhost", 36200)
.overrideAuthority("foo.test.google.fr")
.sslContext(buildSslContext())
.build();
------------------------------------------------------------------------------
//buildSslContext method
private static SslContext buildSslContext() throws SSLException {
String pathPrefix = "/Users/mzc01-dabins/grpc_handson_v2/3-gRPC_client_java/grpc_client/src/main/resources/keys/";
File clientCertChainFilePath = new File(pathPrefix + "client.pem");
File clientPrivateKeyFilePath = new File(pathPrefix + "client.key");
File trustCertCollectionFilePath = new File(pathPrefix + "ca.pem");
SslContextBuilder builder = GrpcSslContexts.forClient();
builder.trustManager(trustCertCollectionFilePath);
builder.keyManager(clientCertChainFilePath, clientPrivateKeyFilePath);
return builder.build();
}
위 예제를 위한 키 생성 step
작성중
$ openssl req -x509 -new -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem \
-config ca-openssl.cnf -days 3650 -extensions v3_req
$ openssl genrsa -out client.key.rsa 2048
$ openssl pkcs8 -topk8 -in client.key.rsa -out client.key -nocrypt
$ openssl req -new -key client.key -out client.csr
$ openssl x509 -req -CA ca.pem -CAkey ca.key -CAcreateserial -in client.csr \
-out client.pem -days 3650
$ openssl genrsa -out server0.key.rsa 2048
$ openssl pkcs8 -topk8 -in server0.key.rsa -out server0.key -nocrypt
$ openssl req -new -key server0.key -out server0.csr
$ openssl x509 -req -CA ca.pem -CAkey ca.key -CAcreateserial -in server0.csr \
-out server0.pem -days 3650
$ openssl genrsa -out server1.key.rsa 2048
$ openssl pkcs8 -topk8 -in server1.key.rsa -out server1.key -nocrypt
$ openssl req -new -key server1.key -out server1.csr -config server1-openssl.cnf
$ openssl x509 -req -CA ca.pem -CAkey ca.key -CAcreateserial -in server1.csr \
-out server1.pem -extensions req_ext -extfile server1-openssl.cnf -days 3650
정리하기
6. Health check
https://github.com/grpc/grpc/blob/master/doc/health-checking.md
다른 RPC 서비스와 동일하게 Health check용 서비스를 활용해볼 수 있다.
RPC 서비스로 구현하면 서비스 별 상태 검사, (다른 소프트웨어 스택 사용에 비해)낮은 기능 추가 부담, 다른 RPC 서비스들과 동등한 형식 등의 이점이 있다.
syntax = "proto3";
message HealthCheckRequest {
string service = 1;
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
SERVICE_UNKNOWN = 3; // Used only by the Watch method.
}
ServingStatus status = 1;
}
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
Check 메서드에 타임 리밋을 활용하여 서비스에 대한 헬스 체크를 진행해볼 수 있으며
Watch 메서드를 통해 스트리밍 통신에 대한 상태 확인을 진행해볼 수 있다.
클라이언트단에서 주기적으로 헬스체크를 실행하며 오랜 시간 커넥션이 유지되면 연결이 한번씩 끊기는 이슈가 있기에(헤더 크기에 관한 이유라고 하며 추가 리서치하기) retry 형식으로 구현하여야 함.
혹은 외부 서비스(Load balancer, Kubernetes, docker 등)를 통한 Health check 또한 가능하다.
ALB 활용 Reference : https://aws.amazon.com/ko/blogs/korea/new-application-load-balancer-support-for-end-to-end-http-2-and-grpc/
node.js : https://www.npmjs.com/package/grpc-health-check
7. Test with tool(BloomRPC)
gRPC는 REST API와 다르게 데이터를 직렬화하여 전송하기 때문에 보통 사용하는 Postman으로 테스트하기 어렵다.
brew install --cask bloomrpc
설치 후 'bloomRPC' 실행.
1번으로 표시한 버튼을 클릭하면 proto 파일을 불러올 수 있으며 불러오기에 성공하면 2번에 서비스 목록이 출력된다.
서비스 명을 클릭하면 사람이 보기에 익숙한 JSON 형태로 필요한 파라미터들이 Editor에 자동으로 추가된다.
3번 항목을 통해 1:1부터 Bi-direction streaming까지 통신 방식을 선택할 수 있으며
4번 항목을 통해 proto 명세의 내용을 확인할 수 있다.
주소 창에 gRPC 서버의 IP, 포트를 입력하고 필요한 경우 TLS 설정을 추가하여 가운데 실행 버튼을 클릭하면 결과를 확인해볼 수 있다.
proto 명세를 통해 서비스 목록을 한 눈에 알 수 있으며
각 서비스명을 클릭하여 파라미터 목록을 알려주므로
Postman보다 편리한 점이 있다.