12 Fractured Apps

Docker and 12 factor apps are a killer combo and offer a peek into the future of application design and deployment.

Many of the applications that are being packaged for Docker are broken in subtle ways. So subtle people would not call them broken, it’s more like a hairline fracture — it works but hurts like hell when you use them.

  • application-v2–prod-01022015
  • application-v2-dev-02272015

Once you go all in on Docker and refuse to use tools that don’t bear the Docker logo you paint yourself into a corner and start abusing Docker.

Example Application

  • Load configuration settings from a JSON encoded config file
  • Access a working data directory
  • Establish a connection to an external mysql database
package main

import (
"database/sql"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net"
"os"

_ "github.com/go-sql-driver/mysql"
)

var (
config Config
db *sql.DB
)

type Config struct {
DataDir string `json:"datadir"`

// Database settings.
Host string `json:"host"`
Port string `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Database string `json:"database"`
}

func main() {
log.Println("Starting application...")
// Load configuration settings.
data, err := ioutil.ReadFile("/etc/config.json")
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(data, &config); err != nil {
log.Fatal(err)
}

// Use working directory.
_, err = os.Stat(config.DataDir)
if err != nil {
log.Fatal(err)
}
// Connect to database.
hostPort := net.JoinHostPort(config.Host, config.Port)
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?timeout=30s",
config.Username, config.Password, hostPort, config.Database)

db, err = sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}

if err := db.Ping(); err != nil {
log.Fatal(err)
}
}
$ GOOS=linux go build -o app .
FROM scratch
MAINTAINER Kelsey Hightower <kelsey.hightower@gmail.com>
COPY app /app
ENTRYPOINT ["/app"]
$ docker build -t app:v1 .
$ docker run --rm app:v1
2015/12/13 04:00:34 Starting application...
2015/12/13 04:00:34 open /etc/config.json: no such file or directory
$ docker run --rm \
-v /etc/config.json:/etc/config.json \
app:v1
2015/12/13 07:36:27 Starting application...
2015/12/13 07:36:27 stat /var/lib/data: no such file or directory
$ docker run --rm \
-v /etc/config.json:/etc/config.json \
-v /var/lib/data:/var/lib/data \
app:v1
2015/12/13 07:44:18 Starting application...
2015/12/13 07:44:48 dial tcp 203.0.113.10:3306: i/o timeout

I can hear the silent cheers from hipster “sysadmins” sipping on a cup of Docker Kool-Aid eagerly waiting to suggest using a custom Docker entrypoint to solve our bootstrapping problems.

Custom Docker entrypoints to the rescue

  • Generate the required /etc/config.json configuration file
  • Create the required /var/lib/data directory
  • Test the database connection and block until it’s available
#!/bin/sh
set -e
datadir=${APP_DATADIR:="/var/lib/data"}
host=${APP_HOST:="127.0.0.1"}
port=${APP_PORT:="3306"}
username=${APP_USERNAME:=""}
password=${APP_PASSWORD:=""}
database=${APP_DATABASE:=""}
cat <<EOF > /etc/config.json
{
"datadir": "${datadir}",
"host": "${host}",
"port": "${port}",
"username": "${username}",
"password": "${password}",
"database": "${database}"
}
EOF
mkdir -p ${APP_DATADIR}exec "/app"
FROM alpine:3.1
MAINTAINER Kelsey Hightower <kelsey.hightower@gmail.com>
COPY app /app
COPY docker-entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
$ docker build -t app:v2 .
$ docker run --rm \
-e "APP_DATADIR=/var/lib/data" \
-e "APP_HOST=203.0.113.10" \
-e "APP_PORT=3306" \
-e "APP_USERNAME=user" \
-e "APP_PASSWORD=password" \
-e "APP_DATABASE=test" \
app:v2
2015/12/13 04:44:29 Starting application...
FROM alpine:3.1
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
app v2 1b47f1fbc7dd 2 hours ago 10.99 MB
app v1 42273e8664d5 2 hours ago 5.952 MB

Programming to the rescue

Config files should be optional

// Load configuration settings.
data, err := ioutil.ReadFile("/etc/config.json")
// Fallback to default values.
switch {
case os.IsNotExist(err):
log.Println("Config file missing using defaults")
config = Config{
DataDir: "/var/lib/data",
Host: "127.0.0.1",
Port: "3306",
Database: "test",
}
case err == nil:
if err := json.Unmarshal(data, &config); err != nil {
log.Fatal(err)
}
default:
log.Println(err)
}

Using env vars for config

log.Println("Overriding configuration from env vars.")if os.Getenv("APP_DATADIR") != "" {
config.DataDir = os.Getenv("APP_DATADIR")
}
if os.Getenv("APP_HOST") != "" {
config.Host = os.Getenv("APP_HOST")
}
if os.Getenv("APP_PORT") != "" {
config.Port = os.Getenv("APP_PORT")
}
if os.Getenv("APP_USERNAME") != "" {
config.Username = os.Getenv("APP_USERNAME")
}
if os.Getenv("APP_PASSWORD") != "" {
config.Password = os.Getenv("APP_PASSWORD")
}
if os.Getenv("APP_DATABASE") != "" {
config.Database = os.Getenv("APP_DATABASE")
}

Manage the application working directories

// Use working directory.
_, err = os.Stat(config.DataDir)
if os.IsNotExist(err) {
log.Println("Creating missing data directory", config.DataDir)
err = os.MkdirAll(config.DataDir, 0755)
}
if err != nil {
log.Fatal(err)
}

Eliminate the need to deploy services in a specific order

$ docker run --rm \
-e "APP_DATADIR=/var/lib/data" \
-e "APP_HOST=203.0.113.10" \
-e "APP_PORT=3306" \
-e "APP_USERNAME=user" \
-e "APP_PASSWORD=password" \
-e "APP_DATABASE=test" \
app:v3
2015/12/13 05:36:10 Starting application...
2015/12/13 05:36:10 Config file missing using defaults
2015/12/13 05:36:10 Overriding configuration from env vars.
2015/12/13 05:36:10 Creating missing data directory /var/lib/data
2015/12/13 05:36:10 Connecting to database at 203.0.113.10:3306
2015/12/13 05:36:40 dial tcp 203.0.113.10:3306: i/o timeout
2015/12/13 05:37:11 dial tcp 203.0.113.10:3306: i/o timeout
$ gcloud sql instances patch mysql \
--authorized-networks "203.0.113.20/32"
2015/12/13 05:37:43 dial tcp 203.0.113.10:3306: i/o timeout
2015/12/13 05:37:46 Application started successfully.
// Connect to database.
hostPort := net.JoinHostPort(config.Host, config.Port)
log.Println("Connecting to database at", hostPort)dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?timeout=30s",
config.Username, config.Password, hostPort, config.Database)
db, err = sql.Open("mysql", dsn)
if err != nil {
log.Println(err)
}
var dbError error
maxAttempts := 20
for attempts := 1; attempts <= maxAttempts; attempts++ {
dbError = db.Ping()
if dbError == nil {
break
}
log.Println(dbError)
time.Sleep(time.Duration(attempts) * time.Second)
}
if dbError != nil {
log.Fatal(dbError)
}
log.Println("Application started successfully.")

Summary

--

--

--

Sysadmin who can code.

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Kelsey Hightower

Kelsey Hightower

Sysadmin who can code.

More from Medium

Self-hosting Unleash with Kubernetes

Create a signed local Debian repository on GitLab — Part 1

Deploying services in DigitalOcean Kubernetes: Part 1