Steven's Thoughts

Containers:Microservices :: Atoms:Universes

Containers are to Microservices what Atoms are to Universes. What in the world does that mean, you ask?

Universes are composed of many Atoms. Likewise, Microservices are composed of many Containers.

We containerized our video-streaming endpoint last week. This week we will decouple video-streaming from its underlying storage mechanism by adding a video-storage microservice, which will be backed by an S3-compatible storage system.

But first, let's use our earlier video-streaming Docker container from last week to learn docker compose.

Round 1: docker compose. (source)

Last week we learned to create a Dockerfile that allows us to create and easily deploy our video-streaming microservice anywhere.

We used the Docker CLI with several flags to specify the required environment variable (-e) and mapped port 4000 on our local computer to port 80 (-p 4000:80) in our container. Remembering the relevant flags and the required container creation order, especially when connecting containers and their associated dependencies is difficult if not impossible.

Thankfully, docker compose comes to our rescue. docker-compose.yaml (or docker-compose.yml) allows us to express dependencies between containers so we can define the configuration once and forget it. The declarative nature of the docker-compose.yml file simplifies both deployment and testing. (Testing will be covered in a few weeks.)

# Version isn't required as of Docker 3.9, but old habits die hard.
version: "3"
services:
  # Our service name is video-streaming.
  video-streaming:
    # It creates an image named video-streaming...
    image: video-streaming
    # ...from the Dockerfile in the ./video-streaming subdirectory.
    build:
      context: ./video-streaming
      dockerfile: Dockerfile
    # The container's image name is also video-streaming,
    # although it doesn't have to be.
    container_name: video-streaming
    # Here we map our host port 4000 to the container port 80.
    ports:
      - "4000:80"
    # Here we specify the environment variables our microservice
    # depends on.  We only need PORT for now.
    environment:
      - PORT=80
    # Do we want to restart the container if it fails?
    # Not during development.
    restart: "no"

With our docker-compose.yaml file defined, and our microservice from last week moved to the ./video-streaming subdirectory, we can now start our first microservice. (A single Atom Universe!)

$ docker compose up

We verify our microservice is running with the following:

$ docker compose ps

And we can shut it down with:

$ docker compose down

Personally, I like having the dc alias (for Linux and Macs) as follows:

alias dc='docker compose'

This makes it easy to quickly dc up, dc ps, and dc down as needed.

Round 2: S3, MinIO, and the video-storage microservice. (source)

Atoms need to interact with other Atoms to make the Universe fun, so let's add our second Atom: video-storage.

Our first container from last week has one major flaw: the sample video was mounted directly from the local file system into the video-streaming Docker image. This restriction would force our files to be copied to every machine running the container, which wouldn't be very efficient.

Another option, the one we'll use, is to host our files in the magical cloud and make them accessible via HTTP. Amazon's S3 service fits this description, allowing us to place files into Buckets and retrieve them via HTTP, so we'll use it.

But, we think, do we really want to pay for S3 while we're still in the learning phase? Not really. Life happens and we may get distracted, leaving us paying a bill for something that's not providing us value.

This is where MinIO enters the picture. MinIO is an S3-compatible file system that runs in a local Docker container; MinIO lets you learn at your own pace for zero cost.

MinIO Basics

Docker offers the simplest and most straight-forward way to launch MinIO, so let's append it to our earlier docker-compose.yaml file.

  # MinIO is our service name.
  minio:
    # We use the bitnami image for easier configuration.
    image: docker.io/bitnami/minio:latest
    container_name: minio
    # MinIO can be used with secret keys or a username
    # and password.  The former will be covered in future
    # weeks, so let's stick with username/password for now.
    environment:
      - MINIO_ROOT_USER=steven
      - MINIO_ROOT_PASSWORD=changeme
      # MINIO_DEFAULT_BUCKETS creates non-existant buckets for us.
      - MINIO_DEFAULT_BUCKETS=videos
    # Don't restart on error so we know something went wrong.
    restart: "no"
    ports:
      # Port 9000 is MinIO's default service port.
      - "9000:9000"
      # Port 9001 is for MinIO's web interface.
      # We login with the MINIO_ROOT_USER and
      # MINIO_ROOT_PASSWORD defined above.
      - "9001:9001"
    volumes:
      # MinIO stores data in /bitmap/minio/data, at least
      # in this image.  We'll overlay our ./data directory
      # there so every Bucket change persists locally.
      - ./data:/bitnami/minio/data

Starting the services with dc up --build (after dc down --volumes if you launched the first one) enables MinIO's web interface at http://localhost:9001/.

This round requires the sample video file to be placed in the videos Bucket we created earlier, so:

  1. Start the microservice with dc up --build.
  2. Login to the web interface with the values you chose for MINIO_ROOT_USER and MINIO_ROOT_PASSWORD.
  3. Click Object Browser on the left under User.
  4. Click the videos folder.
  5. Click Upload, then Upload File.
  6. Select the sample video to upload it.

Congrats on making it this far! Next: the video-storage microservice!

video-storage

Our video-storage microservice relies on six environment variables to function, so we're going to make a helper func that will either retrieve those variables or kill the program with an error telling us which variable needs to be set.

Then we'll connect to S3=/=MinIO and create an HTTP endpoint that can stream the file to the recipient (browser or another microservice) upon request.

See below:

package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"log/slog"
	"net/http"
	"os"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

// If the requested environment variable is missing, fail with
// the given error message.
func failOnMissingEnvironmentVariable(variableName, failureMessage string) string {
	value, found := os.LookupEnv(variableName)
	if !found {
		log.Fatal(failureMessage)
	}

	return value
}

func main() {
	// Retrieve the required environment variables.
	port := failOnMissingEnvironmentVariable(`PORT`, `Please specify the port number for the HTTP server with the environment variable PORT.`)
	minioStorageHost := failOnMissingEnvironmentVariable(`MINIO_STORAGE_HOST`, `Please specify the name for the storage host in the variable MINIO_STORAGE_HOST.`)
	minioStoragePort := failOnMissingEnvironmentVariable(`MINIO_STORAGE_PORT`, `Please specify the port number for the storage host with the environment variable MINIO_STORAGE_PORT.`)
	bucketName := failOnMissingEnvironmentVariable("BUCKET", `Please specify the S3 bucket name in the environment variable BUCKET.`)
	minioUser := failOnMissingEnvironmentVariable(`MINIO_ROOT_USER`, `Please specify the S3 user name in the environment variable MINIO_ROOT_USER.`)
	minioPassword := failOnMissingEnvironmentVariable(`MINIO_ROOT_PASSWORD`, `Please specify the S3 password in the environment variable MINIO_ROOT_PASSWORD.`)

	// Define our S3/MinIO endpoint.
	minioEndpoint := fmt.Sprintf(`%s:%s`, minioStorageHost, minioStoragePort)
	// Default for now.
	awsRegion := "us-east-1"

	// A CredentialsProvider is required to login to S3/MinIO.
	credProvider := aws.NewCredentialsCache(
		credentials.NewStaticCredentialsProvider(
			minioUser,
			minioPassword,
			""))

	// BaseEndpoint and UsePathStyle are the most important variables here.
	// BaseEndpoint is your localhost:port.
	// UsePathStyle lets you access files like they're in
	// a filesystem.
	//
	// s3.New returns a client that can retrieve and store
	// files in S3.  For now, we only care about retrieval.
	s3svc := s3.New(s3.Options{
		Credentials:  credProvider,
		Region:       awsRegion,
		BaseEndpoint: &minioEndpoint,
		UsePathStyle: true,
	})

	mux := http.NewServeMux()
	mux.HandleFunc("GET /video", func(w http.ResponseWriter, r *http.Request) {
		// Extract the path value.
		path := r.FormValue(`path`)
		input := &s3.GetObjectInput{
			Bucket: aws.String(bucketName),
			Key:    aws.String(path),
		}
		// Retrieve the object from S3...
		result, err := s3svc.GetObject(context.Background(), input)
		// ...or not.
		if err != nil {
			w.WriteHeader(http.StatusNotFound)
			return
		}
		defer result.Body.Close()

		io.Copy(w, result.Body)
	})

	slog.Info(`video-storage online`, `port`, port, `bucketName`, bucketName, `host`, minioEndpoint)
	http.ListenAndServe(fmt.Sprint(":", port), mux)
}

With the video-storage microservice defined, let's now add it to our docker-compose.yml file.

  video-storage:
    build:
      context: ./video-storage
      dockerfile: Dockerfile
    environment:
      - PORT=80
      - BUCKET=videos
      - MINIO_ROOT_USER=steven
      - MINIO_ROOT_PASSWORD=changeme
      - MINIO_STORAGE_HOST=http://minio
      - MINIO_STORAGE_PORT=9000
    ports:
      - "4001:80"
    restart: "no"

Now we're ALMOST complete.

The video-streaming microservice from Round 1 still stores the sample .mp4 in its own image. Let's now remove that responsibility from video-streaming and delegate it to where it belongs: video-storage!

package main

import (
	"fmt"
	"io"
	"log"
	"log/slog"
	"net/http"
	"os"
)

func failOnMissingEnvironmentVariable(variableName, failureMessage string) string {
	value, found := os.LookupEnv(variableName)
	if !found {
		log.Fatal(failureMessage)
	}

	return value
}

func main() {
	port := failOnMissingEnvironmentVariable(`PORT`, `Please specify the port number for the HTTP server with the environment variable PORT.`)
	// The host is minio.
	videoStorageHost := failOnMissingEnvironmentVariable(`VIDEO_STORAGE_HOST`, `Please specify the video storage host name with the environment variable VIDEO_STORAGE_HOST.`)
	// The REST port is 9000.
	videoStoragePort := failOnMissingEnvironmentVariable(`VIDEO_STORAGE_PORT`, `Please specify the video storage port number with the environment variable VIDEO_STORAGE_PORT.`)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /video", func(w http.ResponseWriter, r *http.Request) {
		videoPath := "/video?path=SampleVideo_1280x720_1mb.mp4"
		videoStorageURL := fmt.Sprintf(`%s:%s%s`, videoStorageHost, videoStoragePort, videoPath)
		req, _ := http.NewRequest(http.MethodGet, videoStorageURL, nil)
		req.Header = r.Header

		// Retrieve videoStorageURL.
		resp, err := http.DefaultClient.Do(req)
		// This error check alone won't check for 404s, unfortunately.
		if err != nil {
			w.WriteHeader(http.StatusFailedDependency)
			log.Printf("couldn't retrieve %s from %s", videoPath, videoStorageHost)
			return
		}
		defer resp.Body.Close()

		w.Header().Add(contentType, "video/mp4")
		io.Copy(w, resp.Body)
	})

	slog.Info(`video-storage online`)
	http.ListenAndServe(fmt.Sprint(":", port), mux)
}

One dc down --volumes and dc up --build later and you're finally ready to see your results. (Atoms two and three, MinIO and video-storage, make our Universe much cleaner.)

We can even test our video-storage microservice individually by, based on the port mappings in our docker-compose.yml file, visiting localhost:4001.

Next? Even more excitement! We're about to add a fourth Atom to our Universe: a database to map those filenames to YouTube-like query strings.

Round 3: MongoDB for the win! (source)

Which of the following URL paths do you prefer?

1. /video?path=SampleVideo_1280x720_1mb.mp4
2. /video?id=5d9e690ad76fe06a3d7ae416

You are in good company if you chose number 2! So that's what we'll do.

We will need a database collection mapping the id parameter we'll retrieve in video-streaming to the path parameter expected in the video-storage microservice, so let's add MongoDB to our docker-compose.yml:

  db:
    image: mongo:7
    container_name: db
    ports:
      - "4000:27017"
    restart: always
    depends_on:
      - minio
    volumes:
      - ./db-fixtures:/fixtures

Next we need to update video-streaming to retrieve the filename from MongoDB before we can ask video-storage to send us some of those sweet, sweet bytes its storing.

Fortunately, the changes required to video-streaming here are largely cosmetic. We simply retrieve the videoPath from the MongoDB database instead of hard-coding it, then we request it from the video-storage microservice along and forward the request headers.

Compare the http.HandlerFunc from Round 2…

    mux.HandleFunc("GET /video", func(w http.ResponseWriter, r *http.Request) {
		videoPath := "/video?path=SampleVideo_1280x720_1mb.mp4"
		videoStorageURL := fmt.Sprintf(`%s:%s%s`, videoStorageHost, videoStoragePort, videoPath)
		req, _ := http.NewRequest(http.MethodGet, videoStorageURL, nil)
		req.Header = r.Header

		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			w.WriteHeader(http.StatusFailedDependency)
			log.Printf("couldn't retrieve %s from %s", videoPath, videoStorageHost)
			return
		}
		defer resp.Body.Close()

		w.Header().Add(contentType, "video/mp4")
		io.Copy(w, resp.Body)
	})

…with the one from this Round:

	mux.HandleFunc("GET /video", func(w http.ResponseWriter, r *http.Request) {
		videoPath := getPathFromObjectID(w, r.FormValue(`id`), collection)
		if videoPath == nil {
			slog.Info(`video-storage`, `id`, `not found`)
			w.WriteHeader(http.StatusNotFound)
			return
		}

		videoStorageURL := fmt.Sprintf("%s:%s/video?id=%s", videoStorageHost, videoStoragePort, *videoPath)
		req, _ := http.NewRequest(http.MethodGet, videoStorageURL, nil)
		req.Header = r.Header

		// Retrieve videoStorageURL.
		resp, err := http.DefaultClient.Do(req)
		// This error check alone won't check for 404s, so we need to
		// ensure we fail on any non-OK response.
		if err != nil || resp.StatusCode != http.StatusOK {
			slog.Info(`video-storage`, `retrieval error`, err, `url`, videoStorageURL, `addr`, resp.Request.RemoteAddr)
			w.WriteHeader(http.StatusFailedDependency)
			return
		}
		defer resp.Body.Close()

		w.Header().Add(contentType, "video/mp4")
		io.Copy(w, resp.Body)
	})

The two handler functions are identical except for the nil-check and the call to the getPathFromObjectID helper function, which executes the MongoDB query when the id field isn't empty.

There's seven additional lines of MongoDB client initialization code as well, but the two are functionally indistinguishable.

Errata

This week's code was complicated by my old-fashioned development approach and solved by Ashley Davis's wisdom from the Bootstrapping Microservices book: run the service outside of docker-compose when developing and use live-reload.

I wasted a lot of time Sunday and Monday tweaking the code, adding logging here and there, then waiting for dc down --volumes && dc up --build to run, then loading the sample data into MongoDB with mongoimport --database videos --collection videos --file ./fixtures/videos.json, and finally testing the endpoints for both video-streaming and video-storage to no avail.

I woke up yesterday morning with the following thoughts:

  1. video-storage only depends on the mongo microservice.
  2. mongo exposes its port 9000 locally, so I can connect to it locally.
  3. emacs has a Postman equivalent called restclient that allows developers to test endpoints directly from inside emacs.
  4. Load a tmux session with 3 panes: the first running air in the video-storage microservice (with tons of logging), the second holding the actual Go code to edit in emacs, and the third running restclient in another emacs window.

With the help of air, I was able to quickly iterate on the code, let the service reload independently, and run the HTTP tests in emacs.

PORT=5000 MINIO_ROOT_USER=steven MINIO_ROOT_PASSWORD=changeme MINIO_STORAGE_HOST=http://localhost MINIO_STORAGE_PORT=9000 BUCKET=videos air

After an hour of iterating and testing, I realized the slog messages lacked the actual error message. Adding those fields showed my code wasn't wrong, my configuration was.

MINIO_STORAGE_HOST was set to mongodb://minio instead of http://minio.

Boy did I feel stupid. :)

Addendum

docker-compose and Kubernetes always remind me of the Captain Planet theme song. By your containers combined!

By your containers composed! maybe?

#microservices #docker #s3 #minio #mongodb