6 min read

Build your Own Serverless: Part 1

In this blog post, we will discuss building a simple serverless platform using Go, a popular language for system programming, and Docker, a tool designed to simplify creating, deploying, and running applications using containers.
Build your Own Serverless: Part 1
Photo by Tai Bui / Unsplash

In this blog post, we will discuss building a simple serverless platform using Go, a popular language for system programming, and Docker, a tool designed to simplify creating, deploying, and running applications using containers.

Our goal is to develop a Go-based reverse proxy that launches a Docker container when it receives a request and routes traffic to it. If the container already exists, it will simply direct traffic to the existing container. Our platform will also support multiple containers and direct traffic to the appropriate container based on the hostname.

If you want to skip over this section, you can find links to the other parts below:

  • Part 2: Admin Service & Modularity
  • Part 3: Sqlite, docker go client, & graceful shutdown.
  • Part 4: version, traffic distribution, env variables, & garbage collection of idle containers.

Prerequisites

Before diving into coding, ensure you have the following:

  • Basic knowledge of the Go programming language and Docker.
  • Go (version 1.19 or higher) installed on your machine.
  • Docker installed and running on your machine.

First, we need to determine how to route traffic. Let's make some assumptions to simplify writing and understanding the code:

  • We will call this thingy cLess
  • We will have a reverse proxy that receives a request and checks if the appropriate container for that request is running and ready. If it is, the proxy will direct traffic to it. If not, it will start the container and proxy the request to it.
  • Mapping of requests to containers will be done through the first part of the hostname. For example, if a request comes from golang.cless.cloud, the proxy will check if the golang container is up and running. If so, it will direct traffic to it. The first part of the hostname can be replaced by any name, like a service name.
  • We will use /etc/hosts, to direct request from domain names to our serverless app. details will come later.
  • Also, we will assume that all container will expose port 8080.
  • Also, cLess will only run one machine.

Necessary Toil (/etc/hosts):

Let's get that routing locally out of the way, first thing you need is to modify your /etc/hosts, using sudo vi /etc/hosts, and append the text below to it.

127.0.0.1       golang.cless.cloud
127.0.0.1       python.cless.cloud
127.0.0.1       java.cless.cloud
127.0.0.1       nodejs.cless.cloud
127.0.0.1       rust.cless.cloud

We have all these mapping above just as a fun way to direct request from hostnames to server built by that specific language.

Docker Images:

In order to build a bunch of docker images and to test them locally, you can clone the repo, and either invoke ./build_images.sh, or selectively with ./build.sh inside each folder.

Also, the repo contains the code under part-1, in case you don't feel like writing the code and you just want to run it, and play with it.

Go Proxy & cLess:

Inside the cLess repo, we will find all the code needed to run this in one file main.go .

Let's dump the code in here then explain the code function by function.

package main

import (
	"fmt"
	"math/rand"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os/exec"
	"strings"
	"sync"
	"time"
)

type RunningService struct {
	ContainerID  string // docker container ID
	AssignedPort int    // port assigned to the container
	Ready        bool   // whether the container is ready to serve requests
}

const defaultPort = 8080 // let's assume that all the images expose port 8080

// We will use a map and a mutex to store and manage our docker containers
var mutex = &sync.Mutex{}

var containers = make(map[string]RunningService) // map of hostname to running services
var portToContainerID = make(map[int]string)

func main() {
	http.HandleFunc("/", handler)
	http.ListenAndServe(":80", nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
	containerName := getContainerNameFromHost(r.Host)
	mutex.Lock()
	_, exists := containers[containerName]
	mutex.Unlock()

	if !exists {
		mutex.Lock()
		rSvc, err := startContainer(containerName)
		if err != nil {
			fmt.Printf("Failed to start container: %s\n", err)
			w.Write([]byte("Failed to start container"))
			return
		}
		containers[containerName] = RunningService{
			ContainerID:  rSvc.ContainerID,
			AssignedPort: rSvc.AssignedPort,
		}
		mutex.Unlock()
		if !isContainerReady(*rSvc) {
			w.Write([]byte("Container not ready after 30 seconds"))
		} else {
			mutex.Lock()
			rSvc := containers[containerName]
			rSvc.Ready = true
			mutex.Unlock()
		}
	} else {
		mutex.Lock()
		rSvc := containers[containerName]
		mutex.Unlock()
		if !rSvc.Ready {
			w.Write([]byte("Container not ready yet"))
			return
		}
	}

	proxy := httputil.NewSingleHostReverseProxy(&url.URL{
		Scheme: "http",
		Host:   fmt.Sprintf("localhost:%d", containers[containerName].AssignedPort),
	})
	proxy.ServeHTTP(w, r)
}

func startContainer(containerName string) (*RunningService, error) {
	port := getUnusedPort(containerName)
	cmd := exec.Command("docker", "run", "-d", "-p", fmt.Sprintf("%d:%d", port, defaultPort), containerName)
	containerID, err := cmd.Output()
	if err != nil {
		fmt.Printf("Failed to start container: %s\n", err)
		return nil, err
	}
	portToContainerID[port] = string(containerID)
	rSvc := RunningService{
		ContainerID:  string(containerID),
		AssignedPort: port,
		Ready:        false,
	}

	return &rSvc, nil
}

func getContainerNameFromHost(host string) string {
	parts := strings.Split(host, ".")
	return parts[0] + "-docker"
}

func getUnusedPort(containerName string) int {
	// get random port between 8000 and 9000
	// check if port is in use
	port := rand.Intn(1000) + 8000
	_, exists := portToContainerID[port]
	if exists {
		return getUnusedPort(containerName)
	}
	return port
}

func isContainerReady(rSvc RunningService) bool {
	start := time.Now()
	for i := 0; i < 29; i++ {
		fmt.Println("Waiting for container to start...")
		resp, err := http.Get(fmt.Sprintf("http://localhost:%d", rSvc.AssignedPort))
		if err != nil {
			fmt.Println(err.Error())
		}
		if resp != nil && resp.StatusCode == 200 {
			fmt.Println("Container ready!")
			fmt.Printf("Container started in %s\n", time.Since(start))
			return true
		}
		fmt.Println("Container not ready yet...")
		time.Sleep(1 * time.Second)
	}
	return false
}

  • RunningService: struct to container information about a running service.
  • isContainerReady: Once a container is started we need a way to find if it is ready first, before we direct traffic to it.
  • getUnusedPort: we need an unused port within the machine to assign that port to the running container, and that utility function does the trick.
  • getContainerNameFromHost: get container name from the hostname that we get from the request.
  • startContainer: start a docker container, and return a RunningService if successful, and an error in case we can't start the container.
  • handler: direct traffic to the running service if exists; otherwise, it run a new service and proxy traffic to it.
  • main: listen on port 80 for request, and route all traffic to handler.

Test Run:

Run the go code using:

go run main.go

Then curl or browse http://rust.cless.cloud/

You should see logs like:

go run main.go
Waiting for container to start...
Container ready!
Container started in 3.258542ms

Conclusion:

And voila! You have just built a naive serverless platform using Go and Docker. There's a lot more to a production-grade serverless platform, such as scaling, networking, monitoring, and security, but this should give you a good start.

It's great that you made it through the entire blog post. The next sections should be interesting as well:

  • Part 2: Admin Service & Modularity
  • Part 3: Sqlite, docker go client, & graceful shutdown.

In the next part of this series, we'll look at how we can improve our platform by adding other features. Stay tuned! and don't miss on our next posts!