Datadog Gold Partner logo

Hack: Use Cloud Functions as a webserver with Golang

By guillaume blaquiere.Dec 2, 2021

Article-Hack- Use Cloud Functions as a webserver with Golang-1

Serverless is a new paradigm that changes the development habits. The “no-server-management” mode is great, but it comes with counterparts. In FaaS (Functions as a Service), only a function is exposed, only one entry-point for a single purpose workload.

It’s the design of Cloud Functions. But, sometimes, you want (or need) to do more. I already write an article on a Python solution but that required external dependency to build the hack. This time, with Golang, it’s possible to natively serve multiple paths by using native features.

Let’s jump in the details

Route the incoming requests

By design, the Cloud Functions is single purpose and serve only one entry-point.

The first step, and the main purpose of that hack, is to get the request on that unique entry-point and to route it to another piece of code according to the URL (sub)path.
To achieve that, we can use the Go native server multiplexer and to route the request according to the path.

var mux = newMux()

func Webserver(w http.ResponseWriter, r *http.Request) {
   mux.ServeHTTP(w, r)

func newMux() *http.ServeMux {
   mux := http.NewServeMux()
   mux.HandleFunc("/static/", serveStatic)
   mux.HandleFunc("/hello", hello)
   mux.HandleFunc("/subroute/login", login)
   return mux

There is no external dependency, all the features are built in the Golang core libraries.

Serve dynamics endpoints

Now, the router is able to route the requests to HandleFunc, we can execute dedicated code to process dynamically the request.
Here 2 examples:

func hello(w http.ResponseWriter, r *http.Request) {
   fmt.Fprint(w,"Hello World!")

func login(w http.ResponseWriter, r *http.Request) {
   fmt.Fprint(w,"Login from /subroute/login")
Serve static resources

We can also serve static resources! But it’s much more tricky.

First of all, Golang is a powerful compiled language: only the required files and dependencies are compiled and added into a unique binary. You haven’t additional files or directory after the compilation, neither system or environment dependencies, nor Golang installation required to run the binary.

However, because the static files aren’t compiled with the Golang code and they aren’t embedded in the Golang binary code. They are discarded.

How to serve static files and directories?

The idea is to have the files somewhere, outside the Go binary and to serve them. The trick here is to know how the container packaging of Cloud Functions is.

When you deploy your Cloud Functions, you submit your source code that is sent to Cloud Build to build the container. During the container build process, the source files are added into a directory inside the container in a dedicated directory:
/workspace/src/<Go package name>

In Golang, to programmatically know the package name at runtime, and to avoid hard-coded valuesyou can use reflection on a dummy Empty type

type Empty struct{}

var functionSourceCodeDir = "/workspace/src/" + reflect.TypeOf(Empty{}).PkgPath()

Now that you have the root path of your source files, you have to serve them, like that

func serveStatic(w http.ResponseWriter, r *http.Request) {
   file := r.URL.Path
   if strings.HasSuffix(file,"/") {
      // Set the default page
   http.ServeFile(w, r, path.Clean(functionSourceCodeDir+file))

Note that this packaging structure can change at any time and without any notice!

Webserver on top of Cloud Functions

All the pieces are here, you only have to test if it works as expected. You can find the source code in my GitHub repository and follow that steps:

  • Deploy your function with that command
# Runtime v1
gcloud beta functions deploy --runtime=v1 \
--region=us-central1 --allow-unauthenticated \
--runtime=go113 --trigger-http --entry-point=Webserver webserver# Runtime v2
gcloud beta functions deploy --runtime=v2 \
--region=us-central1 --allow-unauthenticated \
--runtime=go113 --trigger-http --entry-point=Webserver webserver

Change the region, security and functions name as you wish.

  • Test these URL paths:
# Dynamic content
https://us-central1-<ProjectId> Static content

Replace the <ProjectId> by your own project ID

That works as expected, but does that hack a good idea?

V1 runtime: The concurrency issue

The Cloud Functions legacy runtime, named v1, is designed to serve only 1 request at a time per instance.
That means, if you have 4 concurrent requests, 4 instances will spawn and will process the requests.

Article-Hack- Use Cloud Functions as a webserver with Golang-2

This design constraint isn’t neutral because:

  • You will have 4 times the cold starts, i.e. the time that takes the instance to start and to initialize your Cloud Functions code
  • You will pay 4 times the processing cost, i.e. you will have 4 instances, 1 per request, therefore 4 times the processing time & cost.

The “4” hasn’t been randomly chosen. It’s the standard number of concurrent connections that a browser creates to download resources, like static resources.

There is also a hard limit of 1000 concurrent Cloud Functions instances in parallel, and you could quickly reach the limit.

You can find more details on Cloud Run & Cloud Functions comparison in one of my quite old articles.

V2 runtime: The power of Cloud Run

The Cloud functions next generation runtime, named V2, is powered by Cloud Run.

With that great improvement, you can leverage the Cloud Run concurrency feature and handle up to 80 concurrent requests on the same instance by default, and up to 1000 by configuration.

Article-Hack- Use Cloud Functions as a webserver with Golang-3

This time, when you received 4 (or more) concurrent requests, the v2 runtime is able to process them on the same instance and therefore to avoid the previous cons of the V1 runtime.

A common caveat: the developer experience

In term of developer experience, the Cloud Functions runtimes, v1 or v2, have the same issue: the local development experience.

Indeed, you haven’t a thing to run on your workstation, you only have a piece of code, a function.

So, how to run your function? How to test it?

It’s especially important to make tests and small iterations when you build complex functions. It’s not efficient to always wait about 2 minutes to deploy your code on Cloud Functions service and test/validate/debug it.
The Functions Frameworks tend to solve that more or less nicely according to the language, but it’s not always ideal.

You need to build something additional, a small/dirty thing to start your code function locally, something able to get HTTP request

A webserver for instance!

I already talked about that in my first article.
At the end, you have a webserver that calls your function. Ready to deploy and perfectly compliant with App Engine or Cloud Run.
Here it’s only to run your function code. If you have environment dependent values, like the source file directory in Cloud Functions container, it’s harder to build something equivalent locally.

So, why a hacked Cloud Functions?

Use the right tool for the right job

The V2 runtime, based on Cloud Run, is a nice alternative and without some cons of the V1 runtime.

However, this solution is still an hack!

Products have their own strengths and are designed accordingly, for being more portable, more scalable, more efficient (in terms of performances, costs, or developer experience/productivity)!
In that case, the V2 runtime, served on top of Cloud Run, adds some additional constraints.

Why use Cloud Functions instead of Cloud Run?

With Cloud Run, the local testing is easier, the portability out of the box, and you stay the master of your runtime environnement inside the container. You avoid any container packaging change that can break this hack, like the static resources.

With Cloud Run, you can unleash your possibilities and be more agile! The performances and advantages will be the same as Cloud Functions V2 and this hack, but without the cons!

The original article published on Medium.

Related Posts