Um die Simplizität von Docker zu erläutern, möchte ich eine kleine Python-Anwendung erstellen, welche wir über Docker veröffentlichen und nutzen können. Der Code sollte ab Python 3.6 funktionieren und ist auf meinem Github-Repo einsehbar. Für eine Einführung in
Flask
, schaut euch das offizielle Tutorial an.
Im ersten Schritt soll nur eine simple API
geschrieben werden, welche beim Aufrufen von /response/<string>
den angegeben String als Antwort zurücksenden.
Bevor wir beginnen sollten folgende Pakete installiert werden: pytest, flask
Gerne auch über python -m pip install -r app/requirements.txt
Tests
Nach TDD-manier, beginnen wir erstmal mit einem Test – ich nutze gerne pytest
für meine Tests, weil das ein enorm gutes Framework ist, um schnell, einfach und professionel Tests zu schreiben.
Zuerst erstellen wir die ./setup.cfg
für pytest
.
[tool:pytest]
log_auto_indent = True
testpaths = app/tests
Dann können wir mit dem Schreiben erster Tests (./app/tests/test_responses.py
) beginnen:
import pytest
from app.app import app
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
class TestResponse:
@pytest.mark.parametrize("test_string", [
"hi", "bye", "xxx", "123!", "-12312",
])
def test_simple_response__simple_message(self, client, test_string):
response = client.get(f"/respond/{test_string}")
assert response.status_code == 200
assert response.json == {'response': test_string}
@pytest.mark.parametrize("test_string", [
"../", "\\//..", "cd ..//",
])
def test_simple_response__wrong_url(self, client, test_string):
response = client.get(f"/respond/{test_string}")
assert response.status_code == 404
In Zeile 6 bis 11 erstellen wir einen Flask
-Testclient und nutzen diesen, um unsere API
zu testen.
Über die TestResponse
Klasse überprüfen wir zwei simple Szenarien
- Nutzer übergibt eine akzeptierte Nachricht ein
test_simple_response__simple_message
- Nutzer gibt etwas nicht akzeptables ein
test_simple_response__wrong_url
In Szenario 1 wird als Rückmeldung eine JSON
-Antwort erwartet, welche so aussieht: {'response': test_string}
Im zweiten Szenario wird ein 404-Fehler
erwartet.
REST API
Da wir die Tests nun haben, können wir uns an die REST API
setzen und unsere Tests mit ./app/app.py
zum laufen bringen.
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/respond/<string>", methods=['GET'])
def simple_response(string: str):
return jsonify(
response=string
)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
In Flask
kann man natürlich auch klassenbasiert arbeiten, aber so wie es jetzt ist, ist es erstmal in Ordnung.
Unsere API
erlaubt eine GET
Anfrage an /respond/<string>
und kreiert eine JSON
-Antwort mit dem Inhalt {response: string}
Nun können wir unsere App via Python starten: python app/app.py
Die Tests können wir mit dem Befehl pytest
ausführen.
(venv) C:\..\docker-example-restapi> pytest
===================================== test session starts =====================================
platform win32 -- Python 3.7.4, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: C:\..\docker-example-restapi, inifile: setup.cfg, testpaths: app/tests
collected 8 items
app\tests\test_responses.py ........ [100%]
===================================== 8 passed in 0.40s =======================================
Da alle Tests problemlos durchgelaufen sind, wissen wir, dass unsere API
funktioniert wie wir wollen.
Docker
Docker nutzt u. a. Dockerfiles
, um die einzelnen Schritte der Instanziierung zu beschreiben.
FROM python:3.7.7-alpine
COPY app /app
WORKDIR /app
RUN pip3 install --upgrade pip
RUN pip3 install -r requirements.txt
EXPOSE 5000
ENTRYPOINT ["python3"]
CMD ["app.py"]</pre>
Gehen wir hier einmal Zeilenweise durch
FROM
beschreibt wo wir beginnen wollen – wir wollen bei einer Dockerumgebung beginnen, die durch python:3.7.7-alpine definiert ist- Dadurch wird Python 3.7.7 installiert, ggf. heruntergeladen, falls nicht lokal vorhanden
COPY
funktioniert wie der Linux-cp
-Befehl.cp source destination
– Damit wird der lokale Ordnerapp
in den Docker-Ordnerapp/
kopiertWORKDIR
definiert den aktuellen ArbeitsordnerRUN
beschreibt was genau innerhalb der Docker-Instanz (bei mir eine Linux-Instanz) aufgerufen werden soll- Im ersten Schritt upgrade ich pip
- Im zweiten Schritt werden alle nötigen Abhängigkeiten installiert (genau wie bei einer lokalen Installation)
EXPOSE
sagt aus unter welchen Ports unsere Docker-Instanz von außen erreichbar sein soll – hier 5000ENTRYPOINT
erlaubt höhere Kontrolle der Eingangsargumente beim Starten des Containers – alle Argumente vondocker run
sind damit Argumente desENTRYPOINT
’sCMD
irgendeine Anweisung an unseren Container- Da unser
ENTRYPOINT
python3
ist, wäre der gesamte Befehlpython3 app.py
- Da unser
.dockerignore
Eine .dockerignore
-Datei funktioniert wie eine .gitignore
-Datei – alles darin angegebene wird nicht Teil des Dockerbuilds. Mehr Details in der Docker Dokumentation.
**/%appdata%
**/venv</pre>
docker build
Jetzt können wir endlich anfangen zu bauen. Insgesamt wurden nur zwei Dateien mit zusammen 10 Zeilen Code hinzugefügt. Nun geht es endlich in eine Shell-Umgebung (tty, powershell, etc.)
[node1] (local) root ~
$ ls
docker-example-restapi
[node1] (local) root ~
$ cd docker-example-restapi/
[node1] (local) root ~/docker-example-restapi
$ ls
Dockerfile LICENSE README.md app setup.cfg
[node1] (local) root ~/docker-example-restapi
$ docker image build --tag xcalizorz/docker-example-restapi:1.0 .
Sending build context to Docker daemon 123.4kB
Step 1/8 : FROM python:3.7.7-alpine
3.7.7-alpine: Pulling from library/python
aad63a933944: Pull complete
f229563217f5: Pull complete
71ded8122394: Pull complete
807d0888ee2e: Pull complete
95206a02ba21: Pull completeDigest: sha256:4a704ebee45695fa91125301e43eee08a85fc984d05cc75650cc66fad7826c56
Status: Downloaded newer image for python:3.7.7-alpine
---> 7fbc871584eb
Step 2/8 : COPY app /app
---> 7988d031143d
Step 3/8 : WORKDIR /app
---> Running in d71f7ff48d85
Removing intermediate container d71f7ff48d85
---> 3f69a63db409
Step 4/8 : RUN pip3 install --upgrade pip
---> Running in 305280adbbc9
Requirement already up-to-date: pip in /usr/local/lib/python3.7/site-packages (20.0.2)
Removing intermediate container 305280adbbc9
---> 6c9da2129f02
Step 5/8 : RUN pip3 install -r requirements.txt
---> Running in fa85c4c1c56b
Collecting flask
Downloading Flask-1.1.1-py2.py3-none-any.whl (94 kB)
Collecting pytest
Downloading pytest-5.4.1-py3-none-any.whl (246 kB)
Collecting Jinja2>=2.10.1
Downloading Jinja2-2.11.1-py2.py3-none-any.whl (126 kB)
Collecting itsdangerous>=0.24
Downloading itsdangerous-1.1.0-py2.py3-none-any.whl (16 kB)
Collecting Werkzeug>=0.15
Downloading Werkzeug-1.0.0-py2.py3-none-any.whl (298 kB)
Collecting click>=5.1
Downloading click-7.1.1-py2.py3-none-any.whl (82 kB)
Collecting importlib-metadata>=0.12; python_version < "3.8"
Downloading importlib_metadata-1.5.1-py2.py3-none-any.whl (30 kB)
Collecting attrs>=17.4.0
Downloading attrs-19.3.0-py2.py3-none-any.whl (39 kB)
Collecting packaging
Downloading packaging-20.3-py2.py3-none-any.whl (37 kB)
Collecting more-itertools>=4.0.0
Downloading more_itertools-8.2.0-py3-none-any.whl (43 kB)
Collecting pluggy<1.0,>=0.12
Downloading pluggy-0.13.1-py2.py3-none-any.whl (18 kB)
Collecting wcwidth
Downloading wcwidth-0.1.9-py2.py3-none-any.whl (19 kB)
Collecting py>=1.5.0
Downloading py-1.8.1-py2.py3-none-any.whl (83 kB)
Collecting MarkupSafe>=0.23
Downloading MarkupSafe-1.1.1.tar.gz (19 kB)
Collecting zipp>=0.5
Downloading zipp-3.1.0-py3-none-any.whl (4.9 kB)
Collecting six
Downloading six-1.14.0-py2.py3-none-any.whl (10 kB)
Collecting pyparsing>=2.0.2
Downloading pyparsing-2.4.6-py2.py3-none-any.whl (67 kB)
Building wheels for collected packages: MarkupSafe
Building wheel for MarkupSafe (setup.py): started
Building wheel for MarkupSafe (setup.py): finished with status 'done'
Created wheel for MarkupSafe: filename=MarkupSafe-1.1.1-py3-none-any.whl size=12629 sha256=235a27d61d695fffaab5a991ce459a0ba4a9eae57113c9b6bf5a9a5c89e45088
Stored in directory: /root/.cache/pip/wheels/b9/d9/ae/63bf9056b0a22b13ade9f6b9e08187c1bb71c47ef21a8c9924
Successfully built MarkupSafe
Installing collected packages: MarkupSafe, Jinja2, itsdangerous, Werkzeug, click, flask, zipp, importlib-metadata, attrs, six, pyparsing, packaging, more-itertools, pluggy, wcwidth, py, pytest
Successfully installed Jinja2-2.11.1 MarkupSafe-1.1.1 Werkzeug-1.0.0 attrs-19.3.0 click-7.1.1 flask-1.1.1 importlib-metadata-1.5.1 itsdangerous-1.1.0 more-itertools-8.2.0 packaging-20.3 pluggy-0.13.1 py-1.8.1 pyparsing-2.4.6 pytest-5.4.1 six-1.14.0 wcwidth-0.1.9 zipp-3.1.0
Removing intermediate container fa85c4c1c56b
---> d640ba0e6471
Step 6/8 : EXPOSE 5000
---> Running in abf93887096d
Removing intermediate container abf93887096d
---> 9792a24ce22b
Step 7/8 : ENTRYPOINT ["python3"]
---> Running in 5559040fecef
Removing intermediate container 5559040fecef
---> fb509e1def0d
Step 8/8 : CMD ["app.py"]
---> Running in 52f84a3385e2
Removing intermediate container 52f84a3385e2
---> c4256a07215a
Successfully built c4256a07215a
Successfully tagged xcalizorz/docker-example-restapi:1.0
Wir navigieren einfach in den Arbeitsordner docker-example-restapi
und geben folgenden Befehl ein:
docker image build --tag xcalizorz/docker-example-restapi:1.0 .
docker image build
erstellt das Docker Image--tag
erlaubt es uns einen benutzerdefinierten Namen zu vergeben.
ist der relative Pfad zum Ordner mit demDockerfile
xcalizorz
ist meine Dockerhub ID
Nun haben wir ein Docker Image, jedoch läuft es noch nicht. Wenn man eine VM installiert und vorbereitet hat, ist diese z. B. in Virtualbox sichtbar – sie wurde schon gebaut, jedoch ist die VM noch aus.
Mit folgendem Befehl starten wir unser Image:
[node1] (local) root ~/docker-example-restapi
$ docker container run --detach --publish 8080:5000 --name responder xcalizorz/docker-example-restapi:1.0
7679e475e1517d2850c14fd6ffc7cc5947bc43ae506dbb9cc6cc22e2fa9a74e7</pre>
docker container run
startet einen neuen Container mittels unseres Images--detach
startet den Container im Hintergrund--publish 8080:5000
veröffentlicht unseren Container auf Port 5000 des Docker Containers und 8080 des Host-Computers--publish host_port:container_port
– wenn nun Anfragen auf Port 8080 meines Host-Computers treffen, werden diese direkt an Port 5000 des Containers weitergegeben
--name
vergibt einen Namen an unseren Container- Zuletzt noch den Namen unseres Images
Schon ist unser Container gestartet. Mit docker container ls
, können wir die aktuell laufenden Container sehen
[node1] (local) root ~/docker-example-restapi
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7679e475e151 xcalizorz/docker-example-restapi:1.0 "python3 app.py" 18 minutes ago Up 18 minutes 0.0.0.0:8080->5000/tcp responder</pre>
Damit haben wir einen Container, der über Port 8080
unseres Host-Computers erreichbar ist.
Später wird die Anwendung erweitert und wir tauchen tiefer in die Materie der Container-Welt ein.