Heroku & Docker

Seit gut einem Jahr ist der Support für Docker nun auf Heroku aktiv. Zeit für uns einen Blick auf diesen Support zu werfen und wie er die Entwicklergemeinde rund um Heroku unterstützen kann.

Bevor wir tiefer in die Verbindung zwischen Heroku und Docker einsteigen (ja, es wird technisch!), kurz einige Worte zu Docker:

Was ist Docker?

Docker ist eine auf Containern basierte Virtualisierungstechnik. Der wesentliche Unterschied zwischen Containern und virtuellen Maschinen ist die Bereitstellung von Systemressourcen für die virtualisierte Umgebung.

Virtuelle Maschinen, bereitgestellt durch Hypervisoren wie VMWare, VirtualBox, XenServer oder Hyper-V, haben Zugriff auf emulierten Systemressourcen wie CPU, RAM und Festplattenplatz. D.h. der Hypervisor verwaltet die Zuteilung der Systemresourcen und „gaukelt“ der virtuellen Maschine ein vollständiges System vor. Für den Betrieb einer virtuellen Maschine müssen daher Systemresourcen festgelegt werden.

Eine Container-basierte Virtualierung emuliert die vorhandenen Systemresourcen nicht, sondern verwendet bestimmte Kernel-Funktionen wie cgroups und namespaces um die virtuelle Umgebung als isolierte Prozesse bereitzustellen.

Eine explizite Zuweisung von Systemressourcen zum Betrieb ist nicht notwendig, kann aber durchgeführt werden.

Folgende Grafik soll dies veranschaulichen:

VM vs Container - © Docker Inc.

Durch die Vereinfachung der Integration mit dem Hostsystem (System, dass die Resources für die Virtualisierung bereitstellt) können Images der virtuellen Systeme einfacher gepackt und auf andere Systeme übertragen werden.

Der Container liefert alle notwendigen Bibliotheken und andere Abhängigkeiten um lauffähig zu sein.

Häufig ist die Nutzung von Containervirtualisierung ein erster Schritt Richtung Cloud.

Docker Image

Ein Docker Image wird mittels eines Dockerfile definiert. Zu Beginn eines Images steht häufig ein Basisimage, das mittels speziellen Docker Befehlen für die vorgesehene Nutzung angepasst wird.

Die Dockerbefehle reichen von ENV zum Setzen von Umgebungsvariablen über RUN zur Ausführung von Shellbefehlen bis ENTRYPOINT zur Ausführung von Programmen innerhalb des Images -> Docker Command Documentation.

Ein Beispiel eines Dockerfile:

FROM alpine:3.4
MAINTAINER logicline GmbH <support@logicline.de>

# install nginx with package manager
RUN apk -U upgrade && \
    apk add nginx && \
    rm -rf /var/cache/apk/*

# copy nginx non-root config
COPY nginx.non-root.conf /etc/nginx/nginx.conf

# we don't need to set the user for nginx, because we configured the nginx to drop to non-root user for child processes
# only the master process runs as root
#USER nginx

CMD ["nginx", "-g", "daemon off;"]

Dieses Image enthält alle Informationen um einen Container mit dem Nginx Webserver zu bauen und zu starten.

Ihr werdet euch jetzt fragen „Wie bauen, ich dachte das läuft direkt so?“ Bisher ist das Dockerfile nur eine Beschreibung für ein Image. Der Build generiert aus dem Dockerfile ein lauffähiges Image, das als Container betrieben werden kann.

Folgende Ausgabe erzeugt Docker, wenn ihr das Image baut:

meykel@devbox:~/dockerstuff/alpine-nginx$ docker build -t mgruel/alpinx:3.4 .
Sending build context to Docker daemon 97.28 kB
Step 1 : FROM alpine:3.4
 ---> f70c828098f5
Step 2 : MAINTAINER logicline GmbH <support@logicline.de>
 ---> Running in c32140bddf67
 ---> 748c6b614efc
Removing intermediate container c32140bddf67
Step 3 : RUN apk -U upgrade &&     apk add nginx &&     rm -rf /var/cache/apk/*
 ---> Running in 601da2981d09
fetch http://dl-cdn.alpinelinux.org/alpine/v3.4/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.4/community/x86_64/APKINDEX.tar.gz
(1/6) Upgrading musl (1.1.14-r9 -> 1.1.14-r10)
(2/6) Upgrading busybox (1.24.2-r8 -> 1.24.2-r9)
Executing busybox-1.24.2-r9.post-upgrade
(3/6) Upgrading alpine-baselayout (3.0.2-r0 -> 3.0.3-r0)
Executing alpine-baselayout-3.0.3-r0.pre-upgrade
Executing alpine-baselayout-3.0.3-r0.post-upgrade
(4/6) Upgrading libcrypto1.0 (1.0.2h-r0 -> 1.0.2h-r1)
(5/6) Upgrading libssl1.0 (1.0.2h-r0 -> 1.0.2h-r1)
(6/6) Upgrading musl-utils (1.1.14-r9 -> 1.1.14-r10)
Executing busybox-1.24.2-r9.trigger
OK: 5 MiB in 11 packages
(1/3) Installing nginx-common (1.10.1-r1)
Executing nginx-common-1.10.1-r1.pre-install
(2/3) Installing pcre (8.38-r1)
(3/3) Installing nginx (1.10.1-r1)
Executing busybox-1.24.2-r9.trigger
OK: 6 MiB in 14 packages
 ---> 1c69bdb2be08
Removing intermediate container 601da2981d09
Step 4 : COPY nginx.non-root.conf /etc/nginx/nginx.conf
 ---> 58e37304db0f
Removing intermediate container 3c5c65726811
Step 5 : WORKDIR /usr/share/nginx/html
 ---> Running in 55f6f9951aec
 ---> 2d9150ba7dd3
Removing intermediate container 55f6f9951aec
Step 6 : CMD nginx -g daemon off;
 ---> Running in 4c9d30783d82
 ---> 2b3b76e1f944
Removing intermediate container 4c9d30783d82
Successfully built 2b3b76e1f944

Nachdem Docker das Image fertiggestellt hat, könnt ihr einen Container damit starten. Bevor wir aber einen Container starten, möchte ich euch kurz nochmal die verschiedenen Layer des Images vorführen. Wir verwenden hierzu einen Docker Befehl anstatt der Seite imagelayers.io, da die Seite derzeit noch ein Problem mit aktuellen Images hat.

meykel@devbox:~/dockerstuff/alpine-nginx$ docker history mgruel/alpinx:latest
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
aa69147edc4f        20 hours ago        /bin/sh -c #(nop) CMD ["nginx" "-g" "daemon o   0 B                 
fea45b309df0        20 hours ago        /bin/sh -c #(nop) USER [nginx]                  0 B                 
bc26c831f378        20 hours ago        /bin/sh -c #(nop) WORKDIR /usr/share/nginx/ht   0 B                 
6e22b5e1daa7        22 hours ago        /bin/sh -c #(nop) COPY file:27c03c1eae751032a   607 B               
d0ec170e5c2d        22 hours ago        /bin/sh -c apk -U upgrade &&     apk add ngin   5.719 MB            
d773e16ae303        22 hours ago        /bin/sh -c #(nop) MAINTAINER logicline GmbH <   0 B                 
f70c828098f5        7 weeks ago         /bin/sh -c #(nop) ADD file:701fd33a2f463fd4bd   4.795 MB

Hier sind die Layer in umgekehrter Reihenfolge gelistet, d.h. das älteste Layer ist als letztes aufgeführt. Diese Information kann euch helfen, eure Images zu vereinfachen und zu verschlanken.

Weiter gehts mit dem Starten des Containers auf Basis erstellten Images:

mgruel@devbox:~/dockerstuff/alpine-nginx$ docker run -it --rm -p 8080:8080 mgruel/alpinx:3.4

Hier passiert noch nicht so viel. Nach dem Start ist der Nginx über den Port 8080 durch einen Browser eurer Wahl erreichbar.

Wir haben an dieser Stelle ein fertiges Basisimage mit Nginx erstellt. Dieses Image kann nun verwendet werden um weitere anwendungsspezifische Images zu erstellen.

Docker Image für Heroku

Wir erstellen nun ein Image für Heroku, dass die Ausführung von Nodejs Anwendungen erlaubt. Dieses Image werden wir zusammen aufbauen und am Ende bei Heroku bereitstellen.

Das Image wird wieder auf dem Alpine Basisimage beruhen. Dazu eine Prise Nodejs aus dem Paketmanager.

Der Sourcecode zum Beispiel ist unter https://github.com/logiclinegmbh/docker-heroku-sample verfügbar.

Das Dockerfile zum Image:

FROM alpine:3.4
MAINTAINER logicline GmbH <support@logicline.de>

# install nginx with package manager [1]
RUN apk -U upgrade && \
    apk add nodejs && \
    rm -rf /var/cache/apk/*

# create use for security purposes [2]
RUN adduser -D nodejs

# ENV for later use [3]
ENV APPDIR="/app/user"

# WORKDIR for application [3]
RUN mkdir -p $APPDIR
RUN chown -R nodejs:nodejs $APPDIR
WORKDIR $APPDIR

# set user to nodejs for security purposes [4]
USER nodejs

# configure the global directory to point to the APPDIR [5]
RUN npm config set prefix '$APPDIR/.npm-global'

# copy application to WORKDIR [6]
COPY package.json .
COPY ./src ./src

# install required packages [7]
RUN npm install

# running node [8]
ENTRYPOINT ["node"]
CMD ["./src/index.js"]
  1. Wir installieren Nodejs aus dem Paketmanager.
  2. Wir erstellen einen speziellen User für unsere Laufzeitumgebung
  3. Wir definieren ein Basisverzeichnis, legen es an und
    geben dem erstellten User die Besitzerrechte am Basisverzeichnis.
  4. Wir setzen den Docker User auf den erstellten User.
  5. Nun überschreiben wir das globale Paketverzeichnis von NPM
    (damit global installierte Pakete im Basisverzeichnis bleiben)
  6. und kopieren den existierenden Sourcecode in das Basisverzeichnis.
  7. Hier installieren wir die notwendigen Pakete für unsere Applikation
  8. Zu letzt erfolgt die Ausführung unserer Anwendung mittels Nodejs

Der Build wird wie gewohnt mittels docker build -t mgruel/nodejs-static-serve:3.4 . gestartet. Im folgenden findet Ihr die Logausgabe dazu:

meykel@devbox:~/dockerstuff/nodejs-static-serve$ docker build -t mgruel/nodejs-static-serve:3.4 .
Sending build context to Docker daemon 1.275 MB
Step 1 : FROM alpine:3.4
 ---> 4e38e38c8ce0
Step 2 : MAINTAINER logicline GmbH <support@logicline.de>
 ---> Using cache
 ---> 5365f88f8be9
Step 3 : RUN apk -U upgrade &&     apk add nodejs &&     rm -rf /var/cache/apk/*
 ---> Using cache
 ---> 771f76110def
Step 4 : RUN adduser -D nodejs
 ---> Running in fe685486610c
 ---> bf13cc24a148
Removing intermediate container fe685486610c
Step 5 : ENV APPDIR "/app/user"
 ---> Running in 8e21977a8fed
 ---> a7c2502d0aa3
Removing intermediate container 8e21977a8fed
Step 6 : RUN mkdir -p $APPDIR
 ---> Running in 684e30fe1aad
 ---> c94ada03d227
Removing intermediate container 684e30fe1aad
Step 7 : RUN chown -R nodejs:nodejs $APPDIR
 ---> Running in 78f9d79929f8
 ---> 21ec8f1d6153
Removing intermediate container 78f9d79929f8
Step 8 : WORKDIR $APPDIR
 ---> Running in a786f6af534a
 ---> 5cf26b2fdda1
Removing intermediate container a786f6af534a
Step 9 : USER nodejs
 ---> Running in 20090d66ee8e
 ---> 058609ddff95
Removing intermediate container 20090d66ee8e
Step 10 : RUN npm config set prefix '$APPDIR/.npm-global'
 ---> Running in 440d58529afa
 ---> 240224dc400c
Removing intermediate container 440d58529afa
Step 11 : COPY package.json .
 ---> 4cb89bbdbf90
Removing intermediate container b0c81c431fe8
Step 12 : COPY ./src ./src
 ---> dbc53a6412d2
Removing intermediate container f77707264ac2
Step 13 : RUN npm install
 ---> Running in 6197e4b9e0f1
sample@0.0.1 /app/user
`-- express@4.14.0
  +-- accepts@1.3.3
  | +-- mime-types@2.1.11
  | | `-- mime-db@1.23.0
  | `-- negotiator@0.6.1
  +-- array-flatten@1.1.1
  +-- content-disposition@0.5.1
  +-- content-type@1.0.2
  +-- cookie@0.3.1
  +-- cookie-signature@1.0.6
  +-- debug@2.2.0
  | `-- ms@0.7.1
  +-- depd@1.1.0
  +-- encodeurl@1.0.1
  +-- escape-html@1.0.3
  +-- etag@1.7.0
  +-- finalhandler@0.5.0
  | +-- statuses@1.3.0
  | `-- unpipe@1.0.0
  +-- fresh@0.3.0
  +-- merge-descriptors@1.0.1
  +-- methods@1.1.2
  +-- on-finished@2.3.0
  | `-- ee-first@1.1.1
  +-- parseurl@1.3.1
  +-- path-to-regexp@0.1.7
  +-- proxy-addr@1.1.2
  | +-- forwarded@0.1.0
  | `-- ipaddr.js@1.1.1
  +-- qs@6.2.0
  +-- range-parser@1.2.0
  +-- send@0.14.1
  | +-- destroy@1.0.4
  | +-- http-errors@1.5.0
  | | +-- inherits@2.0.1
  | | `-- setprototypeof@1.0.1
  | `-- mime@1.3.4
  +-- serve-static@1.11.1
  +-- type-is@1.6.13
  | `-- media-typer@0.3.0
  +-- utils-merge@1.0.0
  `-- vary@1.1.0

 ---> 01b80823e7dc
Removing intermediate container 6197e4b9e0f1
Step 14 : ENTRYPOINT node
 ---> Running in 2fded18a5597
 ---> 340e4e9b3ba9
Removing intermediate container 2fded18a5597
Step 15 : CMD ./src/index.js
 ---> Running in 0569ecb5f98b
 ---> c2206c548003
Removing intermediate container 0569ecb5f98b
Successfully built c2206c548003

Das Image kann nun mittels docker run -it --rm --name="nodejs" mgruel/nodejs-static-serve:3.4 lokal ausgeführt werden. Die Anwendung meldet sich anschließend in der Konsole, wenn sie vollständig hochgefahren wurde.

mgruel@devbox:~/dockerstuff/nodejs-static-serve$ docker run -it --rm --name="nodejs" -p 9000:9000 mgruel/nodejs-static-serve:3.4
Application listens to 0.0.0.0:9000

Einen Blick auf die Anwendung unter localhost:9000 mit eurem Lieblingsbrowser zeigt eine HTML-Seite.

Deployment auf die Heroku Umgebung

Bevor wir nun den Container bei Heroku einspielen können, benötigen wir zunächst einige Dinge:

  1. Einen Heroku Account (sofern nicht schon vorhanden)
  2. Den Heroku Toolbelt
  3. Das Heroku Container Plugin (Installation über heroku plugins:install heroku-container-registry)

Sobald die Voraussetzungen erfüllt sind, kann es auch schon losgehen.

Als erstes melden wir uns an der Heroku Container Registry mit heroku container:login an. Hier werden wir nach unseren Zugangsdaten gefragt.

Sobald der Login erfolgt ist, initialisieren wir unsere Heroku App mit heroku create --region=eu <appname>. Mit diesem Befehl erstellen wir eine Heroku App in der EU-Region.

Nach einer kurzen Wartezeit erscheint die URL, unter dieser die Anwendung bei Heroku erreichbar ist.

Nun können wir unser Image bei Heroku bekannt machen. Wir verwenden hierzu heroku container:push web --app <appname>. Das Flag --app <name> geben wir die App an, unter der wir das Image verfügbar haben wollen.

Normalerweise ist das Flag nicht notwendig, manchmal kann es aber passieren, dass der Heroku Toolbelt die erstellte Anwendung im Verzeichnis nicht findet.

Ich habe den Befehl mit dem dem Flag ausgeführt. Zum Vergleich hier meine Logausgabe:

meykel@devbox:~/dockerstuff/nodejs-static-serve$ heroku container:push web --app 
Sending build context to Docker daemon 1.275 MB
Step 1 : FROM alpine:3.4
 ---> 4e38e38c8ce0
Step 2 : MAINTAINER logicline GmbH <support@logicline.de>
 ---> Using cache
 ---> 5365f88f8be9
Step 3 : RUN apk -U upgrade &&     apk add nodejs &&     rm -rf /var/cache/apk/*
 ---> Using cache
 ---> 771f76110def
Step 4 : RUN adduser -D nodejs
 ---> Using cache
 ---> bf13cc24a148
Step 5 : ENV APPDIR "/app/user"
 ---> Using cache
 ---> a7c2502d0aa3
Step 6 : RUN mkdir -p $APPDIR
 ---> Using cache
 ---> c94ada03d227
Step 7 : RUN chown -R nodejs:nodejs $APPDIR
 ---> Using cache
 ---> 21ec8f1d6153
Step 8 : WORKDIR $APPDIR
 ---> Using cache
 ---> 5cf26b2fdda1
Step 9 : USER nodejs
 ---> Using cache
 ---> 058609ddff95
Step 10 : RUN npm config set prefix '$APPDIR/.npm-global'
 ---> Using cache
 ---> 240224dc400c
Step 11 : COPY package.json .
 ---> Using cache
 ---> 4cb89bbdbf90
Step 12 : COPY ./src ./src
 ---> Using cache
 ---> dbc53a6412d2
Step 13 : RUN npm install
 ---> Using cache
 ---> 01b80823e7dc
Step 14 : ENTRYPOINT node
 ---> Using cache
 ---> 340e4e9b3ba9
Step 15 : CMD ./src/index.js
 ---> Using cache
 ---> c2206c548003
Successfully built c2206c548003
The push refers to a repository [registry.heroku.com//web]
b0f18070f39f: Pushed
eb60876f2d1d: Pushed
fed4f86577e7: Pushed
bc82b56c8153: Pushed
1e093ee36ee6: Pushed
c08895acfbcb: Pushed
f2dcfbc46013: Pushed
de2ac161cf8d: Pushed
4fe15f8d0ae6: Pushed
latest: digest: sha256:5d5ab9f633456dbb3878119b492fc4764fcf0c981891d8ff89a15c53e92e99e0 size: 2194

Nach diesem Schritt könnt ihr nun die Anwendung in eurem Lieblingsbrowser betrachten. Ihr seht eine HTML-Seite mit dem schicken logicline Logo 😀 Außer natürlich, ihr habe den Sourcecode angepasst. 🙁

Hier nochmal die Zusammenfassung der Tätigkeiten, die wir zusammen durchgeführt haben:

  • Wir haben einen Blick auf Docker geworfen und es in Beziehung zu anderen Virtualisierungstechniken gesetzt.
  • Wir haben ein kleines Basisimage mit Alpine und Nginx erstellt.
  • Wir haben ein anwendungsspezifisches Image mit Nodejs zusammengesetzt.
  • Und danach dieses bei Heroku bereitgestellt.

Das war’s fürs erste, wer Fragen zu Heroku oder Docker hat gerne melden. Danke für euer Interesse und ich freue mich auf Feedback und Kommentare!