Ein guter Freund von mir berichtete, dass er seit einem Docker-Update einen PostgreSQL-Container nicht mehr starten konnte, ihn deshalb löschen musste und dann seine Daten verloren gingen. Datenverlust ist nie lustig und aus diesem Grund will ich dem geneigten Leser einige Erfahrungen mit auf den Weg geben, die ich in den letzten anderthalb Jahren bei meinem Arbeitgeber sammeln konnte.

Container sind zustandslos und unveränderbar

Was heißt „zustandslos“?

Zwar verhält es sich nicht immer so, dass Container zustandslos und unveränderbar sind, aber man sollte sie dennoch so betrachten. Damit wird dann die Frage aufgeworfen, wenn der Zustand der Anwendung nicht im Container gespeichert wird, wo wird er dann gespeichert? Das Handbuch gibt hier zwei Möglichkeiten vor:

  • Volumes
  • Bind-mount

docker volumes

Volumes sind laut Dokumentation der präferierte Weg den Zustand eines Containers zu persistieren. Meine persönliche Erfahrung ist eine andere. Ich benutze lieber bind mounts, da hier keine Verwaltung von nöten ist. Volumes kann ich auch wesentlich einfacher aus Versehen löschen, ein Ordner im Dateisystem erfordert mehr Aufwand.

bind mounts

Mittels eines „bind mounts“ kann ich auf einem Linux-System einen Ordner auf einen anderen Ordner zeigen lassen, so dass beide über den selben Inhalt verfügen. So kann ein Ordner auf dem Hostsystem und in dem Docker-Container den selben Inhalt besitzen. Dadurch wird der Zustand des Containers außerhalb des Containers gespeichert und man kann ihn mit seinen regulären Werkzeugen sichern und beobachten.

Was heißt „unveränderbar“?

Nachdem ich nun den Zustand der Datenbank außerhalb des Containers speichere, gilt es die zweite Eigenschaft „unveränderbar“ (engl immuteable) zu betrachten. Es ist zwar möglich einen Container zu bearbeiten, aber spätestens wenn man diesen Container löscht oder einen zweiten anlegt, wird sich das rächen, da die Änderung weg ist. Willst du wirklich eine Änderung an einem Container vornehmen, empfiehlt es sich, das dem Container zugrunde liegende Abbild zu modifizieren und in ein eigenes abzuspeichern.

Aber ich kann doch nicht den Container löschen

Was mich am meisten verdutzt an der Aussage des Freundes war, dass das Löschen eines Containers so ein großer Akt ist. Auf Arbeit mache ich das ständig. Das liegt primär daran, dass wir mit docker-compose arbeiten und dies selbst für jeden kleinsten Dienst. Das Arbeiten mit compose hat den großen Vorteil, dass die Konfiguration der Docker Container in einer YAML-Konfigurationsdatei gespeichert ist und diese Datei über GIT versioniert ist.

Einen Dienst mit docker-compose einrichten

Privat benutze ich für ein Projekt odoo und verwende docker-compose für die Konfiguration der Anwendung. Hier meine komplette docker-compose.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
version: '2'
services:
  web:
    image: odoo:10.0
    depends_on:
      - db
    volumes:
      - ./data:/var/lib/odoo
      - ./config:/etc/odoo
      - ./addons:/mnt/extra-addons
    ports:
      - "127.0.0.1:18069:8069"
    environment:
      - HOST=db
      - USER=odoo
      - PASSWORD=somethingrandom
  db:
    image: postgres:9.6
    volumes:
        - ./postgres:/usr/local/var/lib/postgresql
    environment:
        - "PGDATA=/usr/local/var/lib/postgresql"
        - "POSTGRES_PASSWORD=somethingrandom"
        - "POSTGRES_USER=odoo"

Ich habe hier 2 Container definiert. Einmal den Container web für die Anwendung an sich, in diesem Fall habe ich mit image: odoo:10.0 ein Image aus dem Hub angegeben. Man könnte aber auch ein Dockerfile angeben, dann würde docker-compose zuerst das image bauen. Als nächstes habe ich mit depends_on definiert, dass der Container web von dem Container db abhängig ist. Das bedeuted auch, dass zuerst db gestartet wird und dann web. Ob die PostgreSQL-Datenbank so schnell hoch fährt, dass sie bereit ist, wenn die Anwendung sie benötigt ist unter Umständen nicht gegeben, kann also je nach Anwendungen zu Problemen führen und sollte im im Hinterkopf behalten werden. Im Fall von Odoo ist das allerdings kein Problem. Die Abhängigkeit bietet auch den Vorteil das compose einen Eintrag in der Datei /etc/hosts vornimmt, so dass man den Datenbank-Container mit seinem Namen referenzieren kann. Mit dem Eintrag volumes spezifiziere ich 3 bind mounts, die von dem lokalen Dateisystem in das Container Dateisystem zeigen. Über das Schlüsselwort ports kann ich Port-Weiterleitungen von dem Host-System in dem Container erstellen. Dabei sollte man beachten, dass man Ports immer lokal bindet und nicht auf allen Geräten zur Verfügung stellt. Über environment kann man zusätzliche Umgebungsvariablen in den Container reinreichen. Bei dem PostgreSQL-Image gibt es z.B. einige Variablen, mit denen man das Image konfigurieren kann.

Man startet die Komposition mit dem Befehl up:

1
$ docker-compose up

Wenn das System ordentlich hochfährt, kann man es mit STRG+C beenden und dann mit dem parameter -d starten, damit es im Hintergrund verschwindet:

1
$ docker-compose up -d

Will man die Dienste runterfahren, gibt es den Befehl down:

1
$ docker-compose down

Dabei werden sogar etwaige Container schon gelöscht. Aber da der komplette Zustand der beiden Container außerhalb im aktuellen Verzeichnis des Host-Systems gespeichert wird, ist das alles kein Problem.

Fazit

Alles in allem hoffe ich, dass ich darlegen konnte, wieso Container zustandslos (stateless) und unveränderbar (immuteable) sein sollen. Zusätzlich sollte man sich immer bei Docker überlegen, wo sich die Daten befinden. Es ist nämlich schnell und sehr leicht passiert, dass die Daten im Container sind, weil man sich das Image nicht genau angesehen hat oder sich auch einfach nicht überlegt hat, wo die Daten sein könnten.

Ziel dieses Artikel ist es, zu zeigen wie mein OpenVPN-Setup funktioniert, wie ich meine Clients konfiguriere und was das für Vorteile bringt.

Fangen wir mal mit dem ‘Warum’ an. Mein initiales Anliegen war in das virtuelle Netzwerk meines libvirt/KVM-Hostes einzusteigen, damit ich die virtuellen Maschinen besser verwalten kann. Ich habe zuerst an IPSec versucht, bin da aber an der harschen Internet-Realität gescheitert. So hat mein Internet-Anbieter Kabel Deutschland fragmentierte UDP-Pakete klammheimlich verworfen. Hat mit mich einige Zeit gekostet, das rauszufinden. Aber dank Netalyzr findet man solche Sachen dann doch relativ schnell. An dieser Stelle muss ich mal eine Lanze für Netalyzr brechen. Wenn ihr in ein neues Netz kommt, führt den Test einmal aus, um euch der Limitationen des Netzes bewusst zu werden. Dann habe ich mich an OpenVPN versucht und alles funktionierte ohne mit der Wimper zu zucken.

Bevor ich jetzt zu der Konfiguration komme, noch ein paar Takte zu meiner Infrastruktur.

Infrastruktur

Ich habe einen Server bei Hetzner gemietet, auf dem ich mehrere virtuelle Maschinen mit unterschiedichen Diensten betreibr. Da ich nur eine IPv4-Addresse habe und aus Kostengründen nichts ändern will, habe ich ein privates LAN mit NAT für die ganzen virtuellen Rechner eingerichtet. Das IPv4-Lan hat den Prefix 192.168.122.0/24, dieser Prefix sollte natürlich auch über das VPN erreichbar sein. Daneben habe ich von Hetzner einen nativen IPv6 Zugang erhalten und habe den Prefix 2a01:4f8:200:2265::/64. Was ich im Verlauf des Einrichtens gelernt habe ist: Wenn man verschiedene Unterinfrastrukturen hat, sollte man diese auch mit eigenen Prefixen beglücken. So habe ich den /64 in mehrere /112-Netze aufgeteilt. Die virtuellen Server haben den Prefix 2a01:4f8:200:2265:3::/112 und das VPN-Netz hat 2a01:4f8:200:2265:4::/112. Was ich in diesem Kontext auch gelernt habe, das 2000::/3 der komplette öffentlich routbare Teil von IPv6 ist. Zum Schluss sei noch gesagt, dass ich einen LDAP-Verzeichnis betreibe, in welchem ich Benutzer verwalte. Das sollte auch an das OpenVPN angebunden werden. Soviel zur Infrastruktur, jetzt zur Konfiguration:

Server Konfiguration

Meine Server Konfiguration sieht so aus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Crypto konfigurieren
ca /etc/ipsec.d/cacerts/cacert.pem
cert /etc/ipsec.d/certs/node2.datenknoten.me.pem
key /etc/ipsec.d/private/node2.datenknoten.me.pem
dh /etc/ipsec.d/dh4096.pem

# Netzwerk aufsetzen
server 10.8.0.0 255.255.255.0
server-ipv6 2a01:4f8:200:2265:4::1/112
push "route 192.168.122.0 255.255.255.0"
push "redirect-gateway def1 bypass-dhcp"
push "route-ipv6 2000::/3"
push "dhcp-option DNS 192.168.122.9"

# Einstellungen
keepalive 10 120
comp-lzo
persist-key
persist-tun
verb 3
cipher AES-256-CBC
port 1194
proto tcp
dev tun

# LDAP aktivieren
plugin /usr/lib/openvpn/openvpn-auth-ldap.so /etc/openvpn/auth-ldap.conf

Die LDAP-Konfiguration sieht so aus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<LDAP>
        URL             ldaps://ldap.datenknoten.me
        BindDN          cn=systemuser,ou=users,dc=datenknoten,dc=me
        Password        tolles passwort
        Timeout         15
        TLSEnable       no
        FollowReferrals yes
</LDAP>

<Authorization>
        BaseDN          "ou=users,dc=datenknoten,dc=me"
        SearchFilter    "(uid=%u)"
        RequireGroup    false
        <Group>
                BaseDN          "ou=groups,dc=datenknoten,dc=me"
                SearchFilter    "cn=vpnusers"
                MemberAttribute memberUid
        </Group>
</Authorization>

Hier ist anzufügen, dass die Limitierung auf die Gruppe vpnusers irgendwie nie geklappt hat. Für sachdienliche Hinweise wäre ich sehr dankbar.

Als nächstes muss auf dem Server noch die Firewall eingerichtet werden. Ich benutze das Programm „Ferm”, um meine IPTables-Regeln zu verwalten. Entsprechend sieht mein Script so aus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# -*- shell-script -*-
#
#  Configuration file for ferm(1).
#

@def $DEV_WORLD = eth0;
@def $DEV_DMZ = virbr1;

@def $HOST_STATIC = 144.76.154.114;


@def $DEV_PRIVATE = virbr1;
@def $NET_PRIVATE = 192.168.122.0/24;

@def $DEV_VPN = tun0;
@def $NET_VPN = 10.8.0.0/24;

# convenience function which creates both the nat/DNAT and the filter/FORWARD
# rule
@def &FORWARD_TCP($proto, $port, $dest) = {
    # interface (lo $DEV_WORLD $DEV_VPN $DEV_PRIVATE)
    table filter chain FORWARD outerface $DEV_DMZ daddr $dest proto $proto dport $port ACCEPT;
    table nat chain PREROUTING daddr $HOST_STATIC proto $proto dport $port DNAT to $dest;
}

table filter {
    chain INPUT {
        policy ACCEPT;

        mod state state INVALID DROP;
        mod state state (ESTABLISHED RELATED) ACCEPT;

        interface $DEV_DMZ proto (tcp udp) dport (53 67) ACCEPT;
    }
    chain OUTPUT {
        policy ACCEPT;
    }
    chain FORWARD {
        policy ACCEPT;
        mod state state INVALID DROP;
        mod state state (ESTABLISHED RELATED) ACCEPT;
        interface $DEV_PRIVATE ACCEPT;
        interface $DEV_VPN mod conntrack ctstate NEW ACCEPT;
        mod conntrack ctstate (ESTABLISHED RELATED) ACCEPT;
    }
}

table nat {
    chain POSTROUTING {
        # masquerade private IP addresses
        saddr ($NET_PRIVATE $NET_VPN) outerface $DEV_WORLD MASQUERADE;
    }
}

domain ip6 {
    table filter {
        chain INPUT {
            policy ACCEPT;
        }
        chain OUTPUT {
            policy ACCEPT;
        }
        chain FORWARD {
            policy ACCEPT;
            interface ($DEV_WORLD $DEV_VPN) outerface $DEV_DMZ daddr 2a01:4f8:200:2265::/64 ACCEPT;
            outerface ($DEV_WORLD $DEV_VPN) interface $DEV_DMZ saddr 2a01:4f8:200:2265::/64 ACCEPT;
            interface $DEV_DMZ outerface $DEV_DMZ ACCEPT;
            interface ($DEV_WORLD $DEV_VPN) outerface $DEV_DMZ REJECT reject-with icmp6-port-unreachable;
            outerface ($DEV_WORLD $DEV_VPN) interface $DEV_DMZ REJECT reject-with icmp6-port-unreachable;

        }
    }
}

&FORWARD_TCP(tcp, (80 443), 192.168.122.2);

Es empfiehlt sich natürlich, sich etwas mit der Materie auseinanderzusetzen, damit man versteht, was ich hier schreibe. Vieles, was in diesen Konfigurations-Dateien steht, hat sich über die Jahre so entwickelt.

Client Konfiguration (Linux)

Unter Linux ist bis auf einen Punkt eigentlich alles sehr entspannt. Das Problem ist, dass der DNS-Server, den ich bereitstelle, nicht übernommen wird. Dafür gibt es eine Lösung und jetzt erstmal die Config:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
client
dev tun
proto tcp
remote 144.76.154.114
resolv-retry infinite
nobind
persist-key
persist-tun
ca dk-ca.crt
cert manjaro.crt
key manjaro.key
verb 3
cipher AES-256-CBC
auth SHA1
reneg-sec 0
route-delay 4
comp-lzo no
auth-user-pass
script-security 2
up /home/hana/openvpn/datenknoten/update-dns
down /home/hana/openvpn/datenknoten/update-dns

2 Anmerkungen: Zum einen sei hier der Eintrag auth-user-pass hervorzuheben, der den Client auffordert sich Zugangsdaten vom Benutzer zu erfragen. Zum anderen die letzten 3 Zeilen. Diese sorgen nämlich mittels einem kleinen Skript, welches openresolv aufruft, dafür, dass die mitgelieferten DNS-Server in die Datei /etc/resolv.conf eingetragen werden. Hier das Skript:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#!/bin/bash
#
# Parses DHCP options from openvpn to update resolv.conf
# To use set as 'up' and 'down' script in your openvpn *.conf:
# up /etc/openvpn/update-resolv-conf
# down /etc/openvpn/update-resolv-conf
#
# Used snippets of resolvconf script by Thomas Hood <jdthood@yahoo.co.uk>
# and Chris Hanson
# Licensed under the GNU GPL.  See /usr/share/common-licenses/GPL.
# 07/2013 colin@daedrum.net Fixed intet name
# 05/2006 chlauber@bnc.ch
#
# Example envs set from openvpn:
# foreign_option_1='dhcp-option DNS 193.43.27.132'
# foreign_option_2='dhcp-option DNS 193.43.27.133'
# foreign_option_3='dhcp-option DOMAIN be.bnc.ch'
# foreign_option_4='dhcp-option DOMAIN-SEARCH bnc.local'

## You might need to set the path manually here, i.e.
RESOLVCONF=/sbin/resolvconf

case $script_type in

up)
  for optionname in ${!foreign_option_*} ; do
    option="${!optionname}"
    echo $option
    part1=$(echo "$option" | cut -d " " -f 1)
    if [ "$part1" == "dhcp-option" ] ; then
      part2=$(echo "$option" | cut -d " " -f 2)
      part3=$(echo "$option" | cut -d " " -f 3)
      if [ "$part2" == "DNS" ] ; then
        IF_DNS_NAMESERVERS="$IF_DNS_NAMESERVERS $part3"
      fi
      if [[ "$part2" == "DOMAIN" || "$part2" == "DOMAIN-SEARCH" ]] ; then
        IF_DNS_SEARCH="$IF_DNS_SEARCH $part3"
      fi
    fi
  done
  R=""
  if [ "$IF_DNS_SEARCH" ]; then
    R="search "
    for DS in $IF_DNS_SEARCH ; do
      R="${R} $DS"
    done
  R="${R}
"
  fi

  for NS in $IF_DNS_NAMESERVERS ; do
    R="${R}nameserver $NS
"
  done
  #echo -n "$R" | $RESOLVCONF -p -a "${dev}"
  echo -n "$R" | $RESOLVCONF -a "${dev}.inet"
  ;;
down)
  $RESOLVCONF -d "${dev}.inet"
  ;;
esac

Client Konfiguration (Android)

Unter Android benutze ich OpenVPN for android, welches es auch im F-Droid Store gibt.

Das Einrichten ist einfach und es gibt nichts zu beachten. Als ich das VPN eingerichtet habe, habe ich einen Bug in dem von mir benutzen XMPP-Client Conversations gefunden, weil dieser, bzw. die darunterliegende DNS-Bibliothek die DNS-Server nicht richtig bestimmen konnte (Ist inzwischen behoben).

Fazit

Insgesamt bin ich mit dem Setup sehr zufrieden, vorallem weil es kaputte Netze brauchbar macht, da ich durch das VPN ein zensurfreies Netz, IPv6, einen DNSSEC fähigen Resolver bekomme. Bei Fragen könnt ihr mich entweder per E-Mail, im Chat des Krautspaces kontaktieren oder hinterlasst einen Kommentar am Ende.

Ich habe gestern im Krautspace ein paar Takte zu DNS im Allgemeinen und DNSSEC im Speziellen erzählt.

Ziel des Abends war es, die Schibboleth vorzustellen, die es braucht um DNSSEC mit Bind und OVH zum laufen zu bekommen und diese will ich hier nochmal für die Nachwelt hinterlassen. Ich habe das ganze für die Domain „kaoskinder.de“ gemacht.

Zurest erzeugt man einen „zone signing key“:

1
dnssec-keygen -a RSASHA512 -b 4096 -n ZONE kaoskinder.de

Danach brauchts noch einen „key signing key“:

1
dnssec-keygen -f KSK -a RSASHA512 -b 4096 -n ZONE kaoskinder.de

Diese Befehle erzeugen 4 Dateien:

  • Kkaoskinder.de.+010+11091.key
  • Kkaoskinder.de.+010+11091.private
  • Kkaoskinder.de.+010+13430.key
  • Kkaoskinder.de.+010+13430.private

Die key-Dateien enthalten die öffentlichen Schlüssel und die private-Dateien aus offensichtlichen Gründen die privaten Schlüssel.

Als nächstes inkludiert man die key-Dateien in die Zone-Datei:

1
2
$INCLUDE Kkaoskinder.de.+010+11091.key
$INCLUDE Kkaoskinder.de.+010+13430.key

Danach muss man die zone-Datei unterschreiben:

1
dnssec-signzone -A -3 $(head -c 1000 /dev/random | sha1sum | cut -b 1-16) -N INCREMENT -o kaoskinder.de -t db.kaoskinder.de.zone

Diesen Schritt muss man alle 30 Tage wiederholen, da dann die Signaturen auslaufen.

Der letzte Schritt erzeugt eine unterschriebene Zonen-Datei „db.kaoskinder.de.zone.signed“, diese muss dann noch in die bind-Konfiguration eintragen.

Jetzt kann man z.B. mit dem Verisign DNSSEC Debugger schonmal testen ob DNSSEC funktioniert. Es wird noch eine Fehlermeldung kommen, das kein DS-Record in der übergeordneten Zone existiert. Dieser Fehler wird in dem nächsten Schritt behoben, in dem wir unseren öffentlichen Schlüssel bei OVH eintragen.

Dazu loggt man sich im alten OVH Interface ein. Danach wählt man oben die gewünschte Domain, wählt im linken Menü „Domain & DNS“, dann rechts oben „Sichere Delegation (DNSSEC)“. Dort klickt man auf „Änderung“.

Jetzt sucht man sich eine der beiden key-Dateien aus, ich habe jetzt die Datei „Kkaoskinder.de.+010+11091.key“ ausgewählt. Die Datei sieht wie folgt aus:

1
2
3
4
5
; This is a key-signing key, keyid 11091, for kaoskinder.de.
; Created: 20150407191914 (Tue Apr  7 21:19:14 2015)
; Publish: 20150407191914 (Tue Apr  7 21:19:14 2015)
; Activate: 20150407191914 (Tue Apr  7 21:19:14 2015)
kaoskinder.de. IN DNSKEY 257 3 10 AwEAAZ5v3RLmjVMcjEodqam6IXkkG9NQp3G88hddDY1VClGtIsJtgU42 6t61fDrKoHFRn607lbn06OkCre9fWBophP4xTt9sX877yNb1LRtOpLAS lEYY8p4w6OiDv3CMoyT6oO7j+L3g3puYc+57NmFa4hzWFrEF4RuVis4b argcPudoTISwA+/DB3C5UNwOQB5WsnSEXd4krVO/49Gs2FIOCj3/4Ja6 g/v3x0R3axkLZV1PnawYlDVpAI0qI3xXhxlzZvT64GI+HYQds3Im+Bvs aMO1S224xm/99v0TKwSLfPenX3DW0VpRY5efvUgVUu8zl6HaEQolLLmu ZaKVe9kEn/9mzDX30SkBtNNc0athdNDRofd710n86SnybDpn5K0qME7W qcW6n53voAaObv1yR3dmvFsVeu2dRhYHHqOzMH94JnqixsjTAGH80DKR ZjMEK666Va1jgBY928XPRx3zH8thQe+FrOK4Ad/kihZYwi9kovKeGBdl VVZDoI/CaRjdhSzpBShyXakNhNWtSo/qs7QN4TjxDdN9TYPKLSToIc2m mzvG/u5saTh/oTDSkP9Xh3bOceFKAV5iJJDVo5oDEUYNCyQL5YvcYJ1R tD2Fb1mzIrvPyOq5q3MDDhTjPEqBqiiVYwDKJ4eMy81AuxLUG4+Bekbc iprdIfcp3HdR6QAZ

Der Wert nach keyid trägt man bei Kennung ein, die Zahl nach DNSKEY wählt man bei Flags aus. Als Algorithmus wählt man 10. Bei „öffentlicher Schlüssel“ trägt man den ganzen Kram hinter der 10 ein, also von „AwEAAZ5v“ bis „HdR6QAZ“. Dann klickt man auf „Bestätigen“ und wartet auf die Erfolgsmeldung.

Jetzt da die Zone per DNSSEC gesichert ist, kann man sich auch den schönen Sachen wie DANE oder SSHFP hinwenden.