gRPC Service to Service on Cloud Run
Part 1
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:
- Protocol Buffer Compiler for generating protocol buffer and gRPC Go codes
- Google gcloud CLI and GCP account for deploying Cloud Run
- Docker for building container image
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%