By guillaume blaquiere.Dec 2, 2021
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.
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 values, you 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 file+="index.html" } 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>.cloudfunctions.net/webserver/hello
https://us-central1-<ProjectId>.cloudfunctions.net/webserver/subroute/login# Static content
https://us-central1-<ProjectId>.cloudfunctions.net/webserver/static/
https://us-central1-<ProjectId>.cloudfunctions.net/webserver/static/index.html
https://us-central1-<ProjectId>.cloudfunctions.net/webserver/static/subdir/login.html
Replace the <ProjectId>
by your own project ID
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.
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.
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.
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.
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.
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.
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.