How to deploy a Go app on AWS Fargate – part 1

Wouter Hagens, CC BY-SA 3.0

I had this problem. Well, I still have it: one of my clients has a very old app that we are replacing because upgrading it is plain impossible. So we are strangling it as fast as possible.

This legacy app is not exposing any API to the outside world, and we don’t want it to: we could not guarantee a sufficient level of security for clients’ data, since we would need to write the libraries ourselves or use old ones.

We scrapped other “normal” solutions because it would require a lot of work for our DevOps, already bloated with more urgent stuff.

So I decided it was time to finally put my new Go skills out of tutorial hell and on the road.

The architecture

As a prerequisite, you need Docker installed on your machine. No, you don’t need to install Go. I’m assuming you are on a Unix-like machine.

The idea is to have a “spot” Go app that reads from a read-only DB replica (in production on AWS RDS) and exposes a simple REST API, behind an auth layer.

The app will be deployed via AWS Fargate.

The Docker

Let’s splash in the Dockerfile:

FROM golang:alpine as builder
RUN apk update && apk add --no-cache git
WORKDIR /app
COPY go.mod ./
RUN go mod download 
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd

FROM alpine
WORKDIR /root/
COPY --from=builder /app/main .
COPY --from=builder /app/.env .       
EXPOSE 8080
CMD ["./main"]

Pretty standard stuff: we pull Golang on Alpine to build our image. We update and install git, create the /app folder, copy the go.mod file in it, install the dependencies, copy the rest of the files and finally build the binaries.

In the second stage we pull Alpine, move to the /root directory, copy the binaries and the .env file, open the 8080 port and start the Go app.

Since there is another service involved (the database), we are going to need a docker-compose.yml file to be used in development. And here it is:

version: '3'
services:
    app:
        image: golang
        container_name: go-fargate-demo-api
        ports: 
            - 8080:8080 
        volumes:
            - .:/app
        working_dir: /app
        command: go run ./cmd/main.go
        depends_on:
            - go-app-mysql          
        networks:
            - go-app

    go-app-mysql:
        image: mysql:8
        container_name: go-fargate-demo-db
        environment:
            - MYSQL_USER=${DB_USER}
            - MYSQL_PASSWORD=${DB_PASSWORD}
            - MYSQL_DATABASE=${DB_NAME}
            - MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
        command:
            - "--default-authentication-plugin=mysql_native_password"
            - "--log-bin-trust-function-creators=1"
        ports:
            - 3306:3306
        volumes:
            - db:/var/lib/mysql
        networks:
            - go-app

volumes:
    db:             

networks:
    go-app:
        driver: bridge

Finally, we need an .env file:

DB_HOST=go-app-mysql
DB_DRIVER=mysql
DB_USER=youruser
DB_PASSWORD=yourpassword
DB_NAME=yourdbname
DB_PORT=3306

The Go app

Now we can start building our app. I’ll initialize the go.mod (see above) with:

docker-compose run app go mod init PUT_YOUR_PATH_HERE # in my case: github.com/g-dem/go-fargate-demo

Of course now we need a main package, so let’s create a main.go file inside the cmd folder.

We are going to have just to endpoint: POST /authenticate and GET /companies, which will expose our data. Let’s start with the latter.

We are going to use github.com/julienschmidt/httprouter to handle the requests, so we need to:

docker-compose run app go get github.com/julienschmidt/httprouter

And then the main.go file:

package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/julienschmidt/httprouter"
)

func main() {
    router := httprouter.New()

    router.GET("/companies", hello)

    log.Fatal(http.ListenAndServe(":8080", router))
}

func hello(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
    fmt.Fprintf(w, "Hello, World!\n")
}

After a docker-compose up the browser will cheer:

The storage

Adding the data to the database is outside the scope of this tutorial, but you can access the database container and the MySQL shell by typing in the terminal:

docker-compose exec go-app-mysql mysql -uroot -pyourpassword yourdbname

This way you’ll be able to run the queries needed to CREATE a companies table and INSERT stuff in it.

Of course we need a way to connect to the database from our Go app. Let’s create a pkg/database folder and add a database.go file to it.

package database

import (
    "database/sql"
    "fmt"
    "log"
    "os"

    "github.com/joho/godotenv"
)

func ConnectToDB() *sql.DB {
    err := godotenv.Load()
    if err != nil {
        log.Fatal("Error loading .env file")
    }
    dbUser := os.Getenv("DB_USER")
    dbPass := os.Getenv("DB_PASSWORD")
    dbName := os.Getenv("DB_NAME")
    dbHost := os.Getenv("DB_HOST")
    db, err := sql.Open("mysql", fmt.Sprintf("%s:%s", dbUser, dbPass)+"@tcp("+dbHost+":3306)/"+dbName+"?parseTime=true")

    if err != nil {
        log.Fatal(err)
    }

    return db
}

We read the DB credentials from the .env file (we need to go get github.com/joho/godotenv ) and connect to the database (we need the driver, so go get github.com/go-sql-driver/mysql).

In this sample the Company model will only have an id and a name. Let’s create a pkg/listing folder and add a companies.go file.

package listing

type Company struct {
    Id                 uint64     `json:"id"`
    Name               string     `json:"name"`
}

In the same listing package we create a service.go file.

package listing

import "github.com/g-dem/go-fargate-demo/pkg/database"

func GetAllCompanies() []Company {
    db := database.ConnectToDB()
    defer db.Close()

    results, err := db.Query(`SELECT id, name FROM companies`)

    if err != nil {
        panic(err.Error())
    }

    defer results.Close()

    var cs []Company

    for results.Next() {
        var c Company

        err = results.Scan(
            &c.Id,
            &c.Name)
        if err != nil {
            panic(err.Error())
        }

        cs = append(cs, c)
    }

    return cs
}

In this file we connect to the database and query all the companies in the table. We add the rows to a slice of Company, and return it.

Next we need a handler to actually handle the response. We need a pkg/handler/handler.go file:

package handler

import (
    "encoding/json"
    "net/http"

    "github.com/julienschmidt/httprouter"
)

func Writer(data interface{}) func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(data)
    }
}

The Writer function accept an interface as input and return a function that sets the headers and encodes the input data.

We use a empty interface as parameter because in an actual application we probably want to return different types of struct. In this case it’s a list of companies, but it can be just one company, or a list of products, users and other stuff.

Now we need to change our main.go to implement the handler:

package main

import (
    "log"
    "net/http"

    "github.com/g-dem/go-fargate-demo/pkg/handler"
    "github.com/g-dem/go-fargate-demo/pkg/listing"
    _ "github.com/go-sql-driver/mysql"
    "github.com/julienschmidt/httprouter"
)

func main() {
    router := httprouter.New()

    router.GET("/companies", getCompanies())

    log.Fatal(http.ListenAndServe(":8080", router))
}

func getCompanies() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
    return handler.Writer(listing.GetAllCompanies())
}

And here is our JSON with the list of companies:

Next up: authentication.