Building a gRPC Service in Go
At IBM's storage division, the key management infrastructure was a collection of REST services that had grown organically over several years. When I was brought in to redesign parts of it, one of the decisions we made early was to use gRPC for the new internal service interfaces. This post covers what I learned building those services.
Why gRPC Over REST for Internal Services
REST over HTTP/JSON is the right default for APIs that cross organizational boundaries or serve external clients. For internal service-to-service communication inside a platform, gRPC has real advantages.
The contract is enforced by the compiler. Your .proto file defines the service interface — message shapes, field types, which RPCs exist. Both the server and client generate code from the same proto. If the server changes a field type, the client won't compile. With JSON/REST, you discover these mismatches at runtime, usually in production.
Performance is genuinely better. Protocol Buffers encode to binary, which is smaller and faster to serialize than JSON. HTTP/2 multiplexing means multiple in-flight RPCs over a single connection. For a key management service handling thousands of wrap/unwrap operations per second, this matters.
Streaming is a first-class concept. You can define server-streaming, client-streaming, and bidirectional streaming RPCs in the proto. Implementing streaming over REST is always an afterthought.
Defining the Service
Everything starts with the .proto file. Write this first — it's the contract that everything else is generated from.
syntax = "proto3";
package keymanager;
option go_package = "github.com/ibm-storage/keymanager/proto/keymanager";
// KeyService handles cryptographic key operations.
service KeyService {
rpc WrapKey(WrapKeyRequest) returns (WrapKeyResponse);
rpc UnwrapKey(UnwrapKeyRequest) returns (UnwrapKeyResponse);
}
message WrapKeyRequest {
string key_id = 1;
bytes plaintext = 2;
string algorithm = 3;
}
message WrapKeyResponse {
bytes ciphertext = 1;
string key_version = 2;
}
message UnwrapKeyRequest {
string key_id = 1;
bytes ciphertext = 2;
string key_version = 3;
}
message UnwrapKeyResponse {
bytes plaintext = 1;
}
Generating Go Code
Install the protoc compiler and the Go plugins:
# Install protoc (macOS)
brew install protobuf
# Install the Go plugins
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Generate:
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-grpc_out=. \
--go-grpc_opt=paths=source_relative \
proto/keymanager/keymanager.proto
This produces two files: keymanager.pb.go (the message types) and keymanager_grpc.pb.go (the server and client interfaces). You implement the server interface and use the client interface. Don't edit these files — they're regenerated whenever the proto changes.
The generated server interface looks like:
type KeyServiceServer interface {
WrapKey(context.Context, *WrapKeyRequest) (*WrapKeyResponse, error)
UnwrapKey(context.Context, *UnwrapKeyRequest) (*UnwrapKeyResponse, error)
mustEmbedUnimplementedKeyServiceServer()
}
Implementing the Server
package main
import (
"context"
"fmt"
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/ibm-storage/keymanager/proto/keymanager"
)
type keyServiceServer struct {
pb.UnimplementedKeyServiceServer
// your real dependencies go here: HSM client, DB connection, etc.
}
func (s *keyServiceServer) WrapKey(ctx context.Context, req *pb.WrapKeyRequest) (*pb.WrapKeyResponse, error) {
if req.KeyId == "" {
return nil, status.Error(codes.InvalidArgument, "key_id is required")
}
// Call your actual key wrapping logic here.
ciphertext, version, err := wrapWithHSM(ctx, req.KeyId, req.Plaintext, req.Algorithm)
if err != nil {
// Log the internal error but return a generic message externally.
log.Printf("HSM wrap error for key %s: %v", req.KeyId, err)
return nil, status.Error(codes.Internal, "key wrap operation failed")
}
return &pb.WrapKeyResponse{
Ciphertext: ciphertext,
KeyVersion: version,
}, nil
}
func (s *keyServiceServer) UnwrapKey(ctx context.Context, req *pb.UnwrapKeyRequest) (*pb.UnwrapKeyResponse, error) {
if req.KeyId == "" {
return nil, status.Error(codes.InvalidArgument, "key_id is required")
}
plaintext, err := unwrapWithHSM(ctx, req.KeyId, req.Ciphertext, req.KeyVersion)
if err != nil {
return nil, status.Errorf(codes.NotFound, "key %s version %s not found", req.KeyId, req.KeyVersion)
}
return &pb.UnwrapKeyResponse{Plaintext: plaintext}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
pb.RegisterKeyServiceServer(grpcServer, &keyServiceServer{})
log.Printf("KeyService listening on :50051")
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
The UnimplementedKeyServiceServer embed is how the generated code handles forward compatibility. If you add a new RPC to the proto and regenerate, any server that embeds Unimplemented... will compile and return codes.Unimplemented for the new method until you implement it. Without this, adding an RPC to the proto would be a breaking change for every existing server.
Client Setup
package main
import (
"context"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/ibm-storage/keymanager/proto/keymanager"
)
func main() {
// grpc.WithInsecure() disables TLS. Use this only in development.
// In production, use grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)).
conn, err := grpc.Dial("keymanager:50051", grpc.WithInsecure())
if err != nil {
log.Fatalf("failed to connect: %v", err)
}
defer conn.Close()
client := pb.NewKeyServiceClient(conn)
// Every RPC call needs a deadline. No exceptions.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.WrapKey(ctx, &pb.WrapKeyRequest{
KeyId: "projects/ibm-storage/keys/data-encryption-key",
Plaintext: []byte("secret data"),
Algorithm: "AES_256_GCM",
})
if err != nil {
st, ok := status.FromError(err)
if ok {
log.Fatalf("RPC failed: code=%s message=%s", st.Code(), st.Message())
}
log.Fatalf("non-gRPC error: %v", err)
}
log.Printf("wrapped key version: %s, ciphertext len: %d", resp.KeyVersion, len(resp.Ciphertext))
}
Error Handling
gRPC errors carry a status code and a message. The status codes map to HTTP status codes loosely but aren't identical. The ones I used most:
codes.InvalidArgument— bad input, equivalent to HTTP 400codes.NotFound— the resource doesn't exist, HTTP 404codes.PermissionDenied— authenticated but not authorized, HTTP 403codes.Unauthenticated— no valid credentials, HTTP 401codes.Internal— something went wrong on the server that the client can't do anything about, HTTP 500codes.Unavailable— the service is temporarily unavailable, retryable, HTTP 503codes.DeadlineExceeded— the deadline passed before the operation completed
On the server, return errors with status.Error(codes.X, "message") or status.Errorf(codes.X, "format %s", arg). On the client, parse them with status.FromError(err):
if err != nil {
if st, ok := status.FromError(err); ok {
switch st.Code() {
case codes.NotFound:
// handle not found specifically
case codes.DeadlineExceeded:
// maybe retry
default:
log.Printf("unexpected gRPC error: %v", st)
}
}
}
Deadlines
Every gRPC call needs a deadline. I can't emphasize this enough. Without a deadline, a call to an unhealthy server can block forever, which means your goroutine blocks forever, which means under load you exhaust your goroutine pool and your service stops responding. gRPC will respect the deadline propagated through context — if the deadline passes, the call returns codes.DeadlineExceeded immediately.
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
Five seconds is a reasonable default for synchronous key operations. For streaming RPCs or operations with known variable latency, tune it. But always set one.
The Proto-First Workflow
Write the .proto file first, always. The proto is the contract. Once you've defined it, both the server team and the client team can work independently — the server team implements the service, the client team writes against the generated client stubs. Without the proto as the shared artifact, you end up with the server and client discovering mismatches at integration time.
Check the generated code into your repo. Yes, it's generated, but checking it in means consumers don't need the proto toolchain to build — they just import the package. Put the generation command in a Makefile target so regenerating is one command when the proto changes.
The gRPC tooling for Go is mature and the development experience is genuinely good once you've set it up. The discipline of defining the proto first changes how you think about service design in a positive way — it forces you to think about the contract before the implementation.