gRPC Service to Service on Cloud Run

Part 1

Edi Wiraya
Google Cloud - Community
8 min readJun 22, 2023

--

Service to service calls are common in the microservice world, some prefer REST others opt for gRPC. Both have advantages and disadvantages, but we won’t cover the comparison in this article. The focus is on gRPC implementation between services, how to deploy on Google Cloud Run, leverage Google Cloud authentication feature, and private networking considerations.

This is a series of articles with 3 parts. Part 1 is a simple scenario, a good starting point to learn gRPC, get your hands dirty to code in Go (and Node JS), and deploy to Cloud Run. Securing the service to service call with authentication using IAM authorization is the second part, and the last one is private networking to keep the network traffic internally in your VPC.

Link to Part 2: gRPC service to service on Cloud Run with Authentication
Link to Part 3: gRPC service to service on Cloud Run and private networking

Wait! I’m not interested in the story, just show me the code. https://github.com/edyw/cloudrun-grpc

git clone https://github.com/edyw/cloudrun-grpc.git

Part 1: gRPC service to service, open to Internet, SSL enabled, http/2, no authentication

Prerequisite:

Client calls Service A using http GET, Service A passes the request to Service B via gRPC. This is a simple implementation with no authentication, and both services are open to the Internet. The aim is to have a working service to service gRPC deployed to Cloud Run. Let’s go deeper to the next level.

Service A is implemented as go-api service, this is a Cloud Run service name as well as the directory used in the source code. go-api is a http server with GET /contact/:phone, it uses :phone parameter to construct gRPC request to Service B. Service B is implemented as go-contact service, it exposes data as defined in contact.proto and has remote procedure call GetContact.

We will go through these steps:
Step 1.1 Create contact.proto & generate Go Protocol Buffer and gRPC files
Step 1.2 Develop go-contact (Service B) service
Step 1.3 Develop go-api (Service A) service
Step 1.4 Optional: Local test
Step 1.5 Configure and deploy to Cloud Run
Step 1.6 Test Cloud Run deployment

Step 1.1

Create contact.proto file and generate Go Protocol Buffer and gRPC files

/proto/contact.proto

syntax = "proto3";

option go_package = "cloudrun-grpc/go-contact/proto/contactpb";

package contact;

service Contact {
rpc GetContact (ContactRequest) returns (ContactReply) {}
}

message ContactRequest {
string phoneNumber = 1;
}

message ContactReply {
string name = 1;
}

A message ContactRequest has phone number as input, and returning messageContactReply with a name as output. GetContact is a remote procedure defined as part of Contact service.

We use the Protocol Buffer Compiler to generate Go pb and grpc codes to a new directory /go-contact/proto. /go-contact is the directory we will use later to develop the go-contact.

Execute this at /proto directory:

protoc \
--go_out=../go-contact/proto --go_opt=paths=source_relative \
--go-grpc_out=../go-contact/proto --go-grpc_opt=paths=source_relative \
contact.proto

You should have 2 files generated:

/go-contact/proto/contact_grpc.pb.go
/go-contact/proto/contact.pb.go

Step 1.2

Develop go-contact (Service B) service

Create a go.mod file:

/go.mod

module cloudrun-grpc

go 1.20

require (
github.com/gin-gonic/gin v1.9.1
google.golang.org/api v0.125.0
google.golang.org/grpc v1.55.0
google.golang.org/protobuf v1.30.0
)

Download dependencies:

go mod download
go mod tidy

Let’s write go-contact gRPC service

/go-contact/main.go

package main

import (
contactpb "cloudrun-grpc/go-contact/proto"
"context"
"fmt"
"log"
"net"

"google.golang.org/grpc"
)

var data = map[string]string{
"555-110022": "Mike F",
"555-220033": "Rob U",
"555-330044": "Tanya C",
}

type ContactServer struct {
contactpb.UnimplementedContactServer
}

func (s *ContactServer) GetContact(ctx context.Context, req *contactpb.ContactRequest) (*contactpb.ContactReply, error) {
return &contactpb.ContactReply{
Name: data[req.GetPhoneNumber()],
}, nil
}

func main() {
l, err := net.Listen("tcp", fmt.Sprintf(":%d", 8080))
if err != nil {
log.Fatalf("%v", err)
}
s := grpc.NewServer()
contactpb.RegisterContactServer(s, &ContactServer{})
log.Printf("ContactServer tcp/%v", l.Addr())
if err := s.Serve(l); err != nil {
log.Fatalf("%v", err)
}
}

GetContact returns ContactReply using a map[string]string to lookup the phone number as key, and return the name.

Step 1.3

Develop go-api (Service A) service

/go-api/main.go

package main

import (
contactpb "cloudrun-grpc/go-contact/proto"
"context"
"crypto/tls"
"crypto/x509"
"net/http"

"log"

"github.com/gin-gonic/gin"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)

// TODO: After Cloud Run Service B deployed, replace this with its host name
const contactServerHost = "replace-this-with-service-B-cloud-run-host"

type ContactServer struct {
contactpb.UnimplementedContactServer
}

func main() {
r := gin.Default()
r.GET("/contact/:phone", getContactHandler)
r.Run(":8081")
}

func getContactHandler(c *gin.Context) {
contactReply, err := getContact(c.Param("phone"), context.Background())
if err != nil {
log.Printf("%v", err)
c.JSON(http.StatusInternalServerError, err)
} else {
log.Printf("%v", contactReply)
c.JSON(http.StatusOK, contactReply)
}
}

func getContact(phone string, ctx context.Context) (*contactpb.ContactReply, error) {
addr := contactServerHost + ":443"

systemRoots, err := x509.SystemCertPool()
if err != nil {
return nil, err
}
cred := credentials.NewTLS(&tls.Config{
RootCAs: systemRoots,
})

// Initialize client connections outside handler in your implementation
g, err := grpc.Dial(addr, grpc.WithAuthority(contactServerHost), grpc.WithTransportCredentials(cred))
if err != nil {
return nil, err
}
defer g.Close()

cc := contactpb.NewContactClient(g)

contactRequest := &contactpb.ContactRequest{
PhoneNumber: phone,
}
contactReply, err := cc.GetContact(ctx, contactRequest)
if err != nil {
return nil, err
}
return contactReply, nil
}

main function is a http server to serve route /contact/:phone with handler function getContactHandler. Parameter :phone is passed to getContact along with the context.

Since TLS is by default enabled in Cloud Run, we need to create TLS configuration. If you come to this article to seek a quick copy paste SSL/TLS part, this is the snippet section.

   ...
systemRoots, err := x509.SystemCertPool()
if err != nil {
...
}
cred := credentials.NewTLS(&tls.Config{
RootCAs: systemRoots,
})

g, err := grpc.Dial(addr, grpc.WithAuthority(contactServerHost), grpc.WithTransportCredentials(cred))
...

Next we import go-contact/proto as contactpb and create the client using grpc client connection. Use the same contactpb to construct ContactRequest with the phone number to execute the remote procedure call.

Important: This is not a production grade code, nor best practice in coding. You should move out the client connection out and manage it elegantly across the service.

Step 1.4

Optional: Local test

Run both services on your terminal. Temporarily change contactServerHost="localhost:8080" and addr := contactServerHost + “:8080” for go-api.

go-contact listens to port 8080, and go-api listens to port 8081, you change change them as needed.

To test locally:

curl localhost:8081/contact/555-110022

If everything is going well, you should get the output: Mike F%

Step 1.5

Configure and deploy to Cloud Run

We need Dockerfile for both go-contact and go-api

/go-contact/Dockerfile

FROM golang:1.20 as build

WORKDIR /app
COPY . ./
RUN go mod download
WORKDIR /app/go-contact

RUN CGO_ENABLED=0 GOARCH=amd64 go build -mod=readonly -v -o /app/server

FROM gcr.io/distroless/static:nonroot as runtime

COPY --chown=nonroot:nonroot --from=build /app/server /server

ENTRYPOINT ["/server", "-alsologtostderr"]

/go-api/Dockerfile

FROM golang:1.20 as build

WORKDIR /app
COPY . ./
WORKDIR /app/go-api
RUN go mod download

RUN CGO_ENABLED=0 GOARCH=amd64 go build -mod=readonly -v -o /app/server

FROM gcr.io/distroless/static:nonroot as runtime

COPY --chown=nonroot:nonroot --from=build /app/server /server
ENTRYPOINT ["/server", "-alsologtostderr"]

To set up your project using gcloud, replace ${PROJECT_ID} with your GCP Project ID:

gcloud projects create ${PROJECT_ID}
gcloud config set project ${PROJECT_ID}

Enable Cloud Run, Artifact Registry services:

gcloud services enable run.googleapis.com
gcloud services enable artifactregistry.googleapis.com

Cloud Run deployment requires image from Google Artifact Registry. We use Docker to build the image locally, push to Artifact Registry and use gcloud to deploy to Cloud Run using the Artifact Registry repository.

Create Artifact Registry repository:

gcloud artifacts repositories create ${REPO} \
--repository-format=docker \
--location=${REGION}

To deploy go-contact, execute the following commands from main directory.

docker build . -f ./go-contact/Dockerfile --tag ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-contact

docker push ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-contact

gcloud run deploy go-contact --image=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-contact \
--region=${REGION} \
--project=${PROJECT_ID} \
--allow-unauthenticated \
--use-http2

Set the environment variables:
* PROJECT_ID: Google Cloud Project ID
* REGION: Region where you want to deploy Cloud Run. eg. asia-southeast1
* REPO: Google Artifact Registry Repository name

Important: For production deployment, create a Service Account for each Cloud Run service, and follow the least privileges principles.

Once you have successfully deployed go-contact service, use the Cloud Run Service URL to replace contactServerHost=.. value in go-api /go-api/main.go. Set the addr port back to 8081 if you change it during the local test.

To deploy go-api, go to main directory and run:

docker build . -f ./go-api/Dockerfile --tag ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-api

docker push ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-api

gcloud run deploy go-api --image=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-api \
--region=${REGION} \
--project=${PROJECT_ID} \
--allow-unauthenticated \
--port=8081

You should have Cloud Run Service URL when it is successfully deployed.

Step 1.6

Test Cloud Run deployment

curl ${CLOUD_RUN_SERVICE_URL}/contact/555-110022

The output: Mike F%

Congratulations! You just deployed gRPC service to service on Cloud Run.

Up next:
Part 2 to explore how to secure your service with authentication.
Or you can continue with Part 1 Bonus to create Node JS version of Service A.

Part 1 Bonus: Node JS to call go-contact

Prerequisite:

  • Protocol Buffer Compiler for Node JS: npm install -g grpc-tools

Let’s tweak Part 1 a little bit, we add another Cloud Run service called node-api (Service A1), it has the same feature as go-api (Service A) but this written in JavaScript.

Step B.1

Setup Node JS

Create new directory /node-api and generate package.json file using npm init. Download dependencies by running npm install at /node-api directory:

npm install @grpc/grpc-js express google-auth-library google-protobuf

Step B.2

Generate Node JS Protocol Buffer and gRPC files

Use the same /proto/contact.proto file to generate pb and grpc files for node. Run this from /proto directory:

grpc_tools_node_protoc \
--js_out=import_style=commonjs,binary:../node-api/proto \
--grpc_out=grpc_js:../node-api/proto \
contact.proto

You should have these 2 files generated:
/node-api/proto/contact_grpc_pb.js
/node-api/proto/contact_pb.js

Step B.3

Develop node-api (Service A1) service

/node-api/index.js

const express = require('express')
const { getContactHandler } = require('./contact-handler')

const app = express()
app.get('/contact/:phone', getContactHandler)
app.listen('8082', () => {
console.log('node-api listening..')
})

/node-api/contact-handler.js

const grpc = require('@grpc/grpc-js')
const messages = require('./proto/contact_pb')
const services = require('./proto/contact_grpc_pb')

//TODO: Replace with go-contact Cloud Run Service URL
const contactServerHost = 'replace-with-go-contact-cloud-run-service-url'

const getContactHandler = async (req, res) => {
// Initialize client connections outside handler in your implementation
const client = new services.ContactClient(contactServerHost + ':443', grpc.credentials.createSsl())
const grpcReq = new messages.ContactRequest()
grpcReq.setPhonenumber(req.params.phone)

client.getContact(grpcReq, (err, grpcReply) => {
console.log('grpcReply: ' + JSON.stringify(grpcReply, ' ', 2))
res.send(grpcReply.getName())
})
}

module.exports = {
getContactHandler
}

Make sure to replace contactServerHost with go-contact Cloud Run Service URL

/node-api/Dockerfile

FROM node:18-alpine

WORKDIR /app
COPY ./node-api/package*.json ./
RUN npm install
COPY ./node-api/. .

ENTRYPOINT ["node", "index.js"]

Step B.4

Deploy and test node-api service

From main directory execute:

docker build . -f ./node-api/Dockerfile --tag ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/node-api

docker push ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/node-api

gcloud run deploy node-api --image=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/node-api \
--region=${REGION} \
--project=${PROJECT_ID} \
--allow-unauthenticated \
--port=8082

Test the service using node-api Cloud Run Service URL

curl ${CLOUD_RUN_SERVICE_URL}/contact/555-110022

The same as go-api, this node-api service should return: Mike F%

--

--