Auf Amazons "Lambda"-Service laufen selbstgeschriebene Python-Skripts in Container-Umgebungen, demonstriert im heutigen Programming Snapshot am Beispiel eines AI-Programms zur Bewegungsanalyse in Überwachungsvideos.
Den Babyschritten in der letzten Ausgabe zum Einrichten eines AWS-Accounts,
einer S3-Storage mit statischem Webserver, sowie der ersten Lambda-Funktion
folgt heute als Schlussspurt der Setup eines API-Servers auf Amazon zum
Aufstöbern von interessanten Szenen in Aufnahmen einer Überwachungskamera. Die
per Web-Aufruf, entweder vom Browser oder von einem Kommandozeilen-Tool wie
curl getriggerte Lambda-Funktion holt hierzu ein Video vom Netz, jagt es
durch einen mittels der OpenCV-Library implementierten AI-Algorithmus, erzeugt
daraus ein Bewegungsprofil und gibt den URL eines als JPEG-Datei generierten
Kontaktabzugs mit den wichtigsten Videobewegungen zurück (Abbildung 1 und 2).
Im Gegensatz zu Amazons EC2-Instanzen mit ihren vollblütigen (wenngleich auch nur virtuellen) Linux-Servern bietet der Lambda-Service nur eine containerisierte Umgebung. In ihr laufen NodeJS, Python-, oder Java-basierte Lösungen in einem Sandkasten, den Amazon nach Belieben zwischen echten Servern herumschubst oder bei Inaktivität gar ganz webputzt, um ihn erst später beim nächsten Zugriff wieder hervorzuzaubern. Daten auf der virtuellen Festplatte des Containers liegen zu lassen und darauf zu hoffen, dass sie beim nächsten Aufruf noch da sind, ergäbe also eine extrem instabile Applikation. Statt dessen kommunizieren Lambda-Funktionen mit anderen AWS-Angeboten wie der S3-Storage oder der Dynamo-Datenbank um Daten zu sichern und agieren ansonsten "stateless".
Was eine Applikation nicht in einem Python-Skript beschreiben kann, darf der Entwickler allerdings auch als .zip-Datei in den auf, so munkelt man, Cent-OS basierenden Container hochladen. Eine Lambda-Funktion, die wie im vorliegenden Fall Artificial-Intelligence-Funktionen aus der OpenCV-Library nutzt, muss die notwendigen Binaries oder Libraries vorher in einer dem Lambda-Container ähnlichen Unix-Umgebung kompilieren, verpacken, hochladen, und später zur Laufzeit aus dem Python-Skript aus aufrufen. Dabei kommen entweder verfügbare Python-Bindings zu shared Libraries zum Einsatz oder das Python-Skript ruft vorkompilierte Binaries als externen Prozess auf.
|
| Abbildung 1: Das AI-Programm zur Bewegungsanalyse läuft auf einem Amazon-Server hinter einer REST-API. |
|
| Abbildung 2: Der auf AWS erzeugte Kontaktabzug zeigt die Sekunden im Überwachungsvideo, in denen sich tatsächlich etwas bewegt hat. |
Damit das AI-Programm aus [2] nach der Installation in der Amazon-Cloud nicht
zuviel Rechenzeit und nach dem Verlassen des kostenlosen Plans "Free Tier" auch
Geld verbrät, sucht der Code in der gegenüber der letzten Ausgabe
verbesserten Version in Listing 1 nun nicht mehr
in jedem Frame, also 50 mal pro Sekunde, nach Bewegungen, sondern hüpft in Zeile
99 nun in Schritten von einer halben Sekunde durch den Film. Nach einem
gefundenen Frame mit Bewegung springt Zeile 96 gar zwei Sekunden (zweimal die
Frames/Sekunde-Rate in fps) vorwärts. Im Gegensatz zu vid.read()
dekodiert das in Zeile 50 aufgerufene vid.grab() nicht mehr aufwändig,
sondern wirft ihn einfach weg, um zum nächsten zu gelangen.
Und während die erste Version in [2] nur die Sekundenwerte im Video in die
Ausgabe schrieb, an denen der Algorithmus Bewegungen erkannte, um nachfolgend
über Tausendsassa mplayer die zugehörigen Frames als JPEG-Dateien zu
extrahieren, schreiben die Zeilen 92-94 erkannte Frames gleich mittels der
OpenCV beiliegenden Bildverarbeitungsfunktionen imwrite() im Format
0001.jpg auf die virtuelle Festplatte. Ein zweiter Durchlauf, sowie die
Frickelei zur Installation von mplayer in den Lambda-Container entfallen
somit.
048 int frames_skip( VideoCapture vid, int n, int *i ) {
049 for( int c = 0; c < n; c++ ) {
050 if (!vid.grab())
051 break;
052 (*i)++;
053 }
054 }
...
056 int main(int argc, char *argv[]) {
...
080 while (1) {
081 if (!vid.read(frame))
082 break;
083 i++;
084
085 int movie_second = i / fps;
086
087 cframe = frame.clone();
088 cvtColor(frame,frame,COLOR_BGR2GRAY);
089 if(move_test(oframe, frame)) {
090 cout << movie_second << "\n";
091
092 char filename[80];
093 sprintf( filename, "%04d.jpg", i/fps );
094 imwrite( filename, cframe );
095
096 frames_skip( vid, 2*fps, &i );
097 } else {
098 // fast-forward to next 1/2 sec
099 frames_skip( vid, fps/2, &i );
100 }
101
102 oframe = frame;
103 }
...
Aus diesen JPEG-Bildern macht dann ein weiteres Python-Skript, mk-montage.py,
unter Zuhilfenahme der Image-Magick-Library einen Kontaktabzug, ebenfalls im
JPEG-Format. Diese Datei legt das Lambda-Programm dann in Amazons
S3-Cloud-Speicher ab und schickt eine URL darauf an den aufrufenden Client
zurück.
Wie holt ein Python-Programmierer ein Dokument vom Web? Ein Ansatz wäre
die Methode read() nach einem urlopen(), die alle eingeholten Bytes
gleich wieder mit write() in eine lokale Datei schreibt, aber das hätte
zur Folge, dass eine eventuell große Videodatei komplett ins RAM gelesen würde,
bevor Python sie auf die Platte schriebe. RAM kostet Geld auf Amazon,
also verwendet Listing 2 die Methode urlretrieve aus dem Modul urllib, die
(hoffentlich) mehr oder weniger intelligent stückweise puffert.
Die Python-Welt leidet unter dem Zwiespalt zwischen Python 2.x und 3.
Letzteres ist eine Art Paradieszustand, in dem Kinderkrankheiten
behoben, Unstimmigkeiten bereinigt sind, und in dem alle coolen
Neuentwicklungen stattfinden. Allerdings nutzt kaum jemand Python 3
in Produktionsumgebungen und auch Amazon bietet auf AWS nur 2.7 an.
In Python 2.x schlägt sich der Programmierer dann mit hanebüchenem
Wildwuchs an Libraries herum, und muss sich zum Beispiel beim Einholen von
Webdaten zwischen den zwei inkompatiblen Erzeugnissen urllib und,
kein Scherz, urllib2 entscheiden. Oder wer externe Programme starten möchte,
nutzt in 2.x check_output() des Moduls subprocess>, während in Python 3.x
die Methode run() mit anderen Parametern zum Einsatz kommt und
check_output() nicht mal mehr existiert.
Die Lambda-Funktion in Listing 2 bekommt den URL der zu analysierenden
Video-Datei im Parameter-Dictionary event unter dem Schlüssel movie_url
zugespielt.
01 #!/usr/bin/python
02 import urllib
03 import tempfile
04 import shutil
05 import subprocess
06 import boto3
07 import os
08
09 def lambda_handler(event, context):
10 tmpd = tempfile.mkdtemp()
11
12 # fetch movie
13 movie_url = event['movie_url']
14 movie_file = os.path.join(tmpd,
15 os.path.basename(movie_url))
16 urllib.urlretrieve(movie_url,movie_file)
17
18 # motion analysis
19 print subprocess.check_output([
20 "bin/max-movement-lk.py",
21 movie_file])
22
23 # generate montage
24 print subprocess.check_output([
25 "bin/mk-montage.py",tmpd])
26
27 # store montage in s3
28 s3 = boto3.resource('s3')
29 bucket = "snapshot.linux-magazin.de"
30 data = open(os.path.join(
31 tmpd,'montage.jpg')).read()
32 s3.Bucket(bucket).put_object(
33 Key="montage.jpg",
34 Body=data,ContentType="image/jpeg")
35
36 result = { "montage_url":
37 "https://s3-us-west-2.amazonaws.com" +
38 "/snapshot.linux-magazin.de/" +
39 "montage.jpg"}
40
41 shutil.rmtree(tmpd)
42 return result
In einer echten Produktionsumgebung darf kein Python-Skript in einem festen
Verzeichnis wie data operieren und hoffen, dass niemand dazwischenfunkt. Da
Amazon-Lambda-Funktionen parallel aufgerufen werden, müssen sie für solche
Zwecke mit Pythons tempfile-Modul zunächst ein instanzeigenes temporäres
Verzeichnis anlegen, und nach Abschluss der Tätigkeit wieder abräumen.
Damit dies auch passiert, falls eine der vorher aufgerufenen Funktionen auf
einen Fehler läuft und eine Exception wirft, sollte der letzte Zeile im
Produktionsbetrieb eigentlich in einem Exception-Handler stehen, das unterblieb
in der Testversion. Listing 2
ruft in Zeile 10 die Methode mkdtemp() auf und nutzt das neu
angelegte Verzeichnis um in Zwischenschritten ermittelte Daten für die nächsten
Stufen des Skripts abzulegen.
So legt Zeile 16 die per Webrequest eingeholte Videodatei unter dem
in der Variable movie_file abgelegten Namen ab, der aus dem letzten Teil
des Pfads der URL stammt. Als nächste Stufe
ruft Zeile 19 das Skript max-movement-lk.py in Listing 3 auf, einen Python-Wrapper
um das C++-Programm in Listing 1, und übergibt ihm den Pfad zur Videodatei
im temporären Verzeichnis.
01 #!/usr/bin/python
02 import sys
03 import os
04 import subprocess
05
06 top_dir = os.getcwd()
07 movie_path = sys.argv[1]
08
09 os.chdir(os.path.dirname(movie_path))
10
11 os.environ["LD_LIBRARY_PATH"] = os.path.join(top_dir,"lib")
12
13 print subprocess.check_output(
14 [ os.path.join(top_dir, "bin/max-movement-lk") ] +
15 [ os.path.basename(movie_path) ] )
Hinterlässt die Bewegungsanalyse dort nun eine Reihe von JPEG-Dateien im
Format 0001.jpg, 0002.jpg ..., kommt in der nächsten
Verarbeitungsstufe ab Zeile 24 in Listing 2 das Wrapper-Skript
mk-montage.py in Listing 4 zum Einsatz, das
den in die Dateinamen eingebetteten Sekundenwerte ins
Format SS::MM:ss umwandelt und die alten Dateinamen sowie die formatierten
Labels Imagemagicks montage zu fressen gibt:
montage.py -label 00:00:01 tmp/001.jpg ...
Das Programm baut daraus einen Kontaktabzug in der Datei montage.jpg,
die später in Amazons S3 gespeichert wird, damit User sie per Web-Link
auf den Client holen können.
01 #!/usr/bin/python
02 import glob
03 import subprocess
04 import re
05 import time
06 import os
07 import sys
08
09 dir = sys.argv[1]
10 files = glob.glob(
11 os.path.join(dir,'*.jpg'))
12 cmds = ["bin/montage.py"]
13
14 r = re.compile('.*?(\d+)\.jpg')
15
16 for file in sorted(files):
17 match = r.match(file)
18 if match:
19 label = time.strftime("%H:%M:%S",
20 time.gmtime(int(match.group(1))))
21 cmds.append("-label")
22 cmds.append(label)
23 cmds.append(file)
24 else:
25 print "no match: " + file
26
27 cmds.append(os.path.join(
28 dir,'montage.jpg'))
29
30 print subprocess.check_output(cmds)
Das Python-Skript in Listing 5 fungiert als Wrapper um das Binary montage,
dem im Verzeichnis lib eine Reihe von shared Libraries beiligen, damit das
dynamisch gelinkte Binary im Container läuft. Die Environment-Variable
LD_LIBRARY_PATH setzt den Suchpfad für shared Libs auf dieses nicht
standardisierte Verzeichnis, damit das Binary diese zur Laufzeit auch findet.
1 #!/usr/bin/python
2 import sys
3 import os
4 import subprocess
5
6 os.environ["LD_LIBRARY_PATH"] = "lib"
7
8 print subprocess.check_output(
9 [ "bin/montage" ] + sys.argv[1:])
Die Frickelei, alle von einem Binary genutzten shared Libraries in ein
Verzeichnis zum späteren Bündeln zu kopieren übernimmt das Python-Skript
in Listing 6. In einer kontrollierte Umgebung wie einem Docker-Container
oder einer Vagrant-VM, die dem Zielsystem (also Cent-OS) möglichst nahe ist,
installiert der Admin hierzu das gewünschte Binary als Paket der
verwendeten Distribution mittels yum, lässt das Skript dann mit
dem Unix-Tool ldd die verwendeten shared Libs ermitteln und sammelt diese
in einem neu angelegten Verzeichnis:
for i in `ldd-ls.py program`
do cp $i /build/libs
done
Diese Sammlung assistierender Libs zum Binary wird später in einem gezippten
Archiv auf Amazons Lambda-Service hochgeladen und steht damit der
Lambda-Funktion im Container zur Verfügung. Dabei bietet die AWS-Konsole
sowohl die Möglichkeit eines direkten File-Uploads im Browser als auch
den Zugriff auf einen S3-Bucket, auf den der Admin die Zip-Datei vorher
per Kommandozeilen-Tool aws hochgeladen hat (Abbildung 3).
|
| Abbildung 3: Hochladen der Zip-Datei auf den Lambda-Server über einen Amazon-S3-Bucket. |
01 #!/usr/bin/python
02 import subprocess;
03 import sys;
04
05 if len(sys.argv) != 2:
06 print("usage: {} file".format(sys.argv[0]))
07 sys.exit(1)
08
09 file = sys.argv[1]
10
11 output = subprocess.check_output(['ldd',file])
12 for line in output.split("\n"):
13 words = line.split()
14 if len(words) > 3:
15 print words[2]
|
| Abbildung 4: Libs |
Abbildung 4 zeigt die so gesammelten shared Libs, offensichtlich zieht das mit OpenCV gelinkte AI-Programm zur Bewegungsanalyse einen ganzen Rattenschwanz an Bibliotheken hinter sich her. Schließlich steckt hinter einem Video geballte Compressionstechnik, die es zu dekodieren gilt, wenn das Programm an die rohen Framedaten heran möchte.
Ist der Kontakabzug montage.jpg erstellt, kopiert der Code
ab Zeile 28 in Listing 2 die Datei aus dem temporären Verzeichnis
in einen vorher angelegten S3-Bucket auf Amazons Cloud-Storage-System.
Das Python-Modul boto3 steht auf Lambda-Servern standardmäßig zur
Verfügung und bietet allerlei Tools zur Kommunikation mit verwandten
Serviceangeboten. Die Methode put_object in Zeile 32 legt die
von der virtuellen Festplatte gelesene Ausgabedatei als Objekt vom
Typ image/jpeg im Cloudspeicher ab.
Von dort liefert sie der in der letzten Ausgabe
besprochene S3-Webserver an den interessierten User aus, dem der API-Aufruf
nach Abschluß den zugehörigen URL gesteckt hat. Damit dieser
sie auch findet, stellt Zeile 36 eine JSON-Antwort zusammen, die
dem Web-Client den zur Montage-Datei zugehörigen S3-URL mitteilt.
Am Ende der Lambda-Funktion in Zeile 41 bleibt nur noch, das temporär
angelegte Verzeichnis wieder zu löschen.
Damit das Lambda-Skript Schreibrechte an dem als snapshot.linux-magazin.de
konfigurierten S3-Bucket erhält, muss der User letzterem entsprechende
Rechte verleihen. Abbildung 5 zeigt, dass der S3-Bucket jedem ausgewiesenen
AWS-User Zugriff gewährt. Auf der anderen Seite müssen vom Lambda-Server
im S3-Bucket erzeugte Dateien auch weltweit für interessierte User
lesbar sein. Dies erfolgt über eine sogenannte Bucket-Policy, deren
Inhalt Listing 7 zeigt. Jede dort neu erzeugte Datei ist demnach für alle
lesbar, also kann der am S3-Bucket hängende Webserver sie auch
an anfragende Webclients ausliefern.
|
| Abbildung 5: Der Lamda-Server benötigt Zugriffsrechte am S3-Bucket. |
01 {
02 "Version": "2012-10-17",
03 "Statement": [
04 {
05 "Sid": "",
06 "Effect": "Allow",
07 "Principal": "*",
08 "Action": "s3:GetObject",
09 "Resource": "arn:aws:s3:::snapshot.linux-magazin.de/*"
10 }
11 ]
12 }
Amazon hilft beim Testen von Lambda-Funktionen, der Entwickler kann
hochgeladene Skripts entweder durch die Kommandozeilen-Utilty aws oder
auch den Test-Button der Console im Browser ausführen. Aber schließlich sollen
User die Funktion letztlich aus dem offenen Internet ausführen können und
hierzu bietet sich Amazons API-Gateway an. Dieser ebenfalls auf der Console
anklickbare Service legt einen Cloud-Webserver mit einer REST-API an, deren
Methoden (wie zum Beispiel /vimo im vorliegenden Beispiel) es neben
anderen Optionen auf User-definierte Lambda-Funktionen umleitet.
|
| Abbildung 6: POST Method |
Die Verbindung zwischen Webserver und Applikation auf dem Lambda-Service erledigt Amazon AWS hinter den Kulissen ohne viel Heckmeck, wenn der User für die Option "Integration Type" beim Anlegen der REST-Methode (zum Beispiel GET oder POST) die Option "Lambda Function" angibt und weiter unten die Region des Datencenters (z.B. "us-west-2") und den Namen der Lambda-Funktion (z.B. "vimo") angibt.
Im vorliegenden Fall soll der Pfad /vimo
die POST-Methode verwenden und im Body des Requests einen JSON-Blob mit
benamten Parametern (z.B. "movie_url") führen. Setzt der Web-Client wie in
Abbildung 1 sichtbar den Header "Content-Type" auf "application/json",
dann fängt bereits das API-Gateway den JSON-Blob ab und analysiert ihn. Die
später aufgerufene Lambda-Funktion erhält dann bereits die dekodierten
Wertepaare aus den JSON-Daten in einem Python-Dictionary als
Funktionsparameter event. Im vorliegenden Fall legt der Client in Abbildung
1 den URL zum Überwachungsvideo im JSON-Blob im Parameter movie_url
ab, während die Lambda-Funktion in Listing 2 mit event['movie_url']
darauf zugreift.
|
| Abbildung 7: Deploy |
Live geschaltet wird die REST-API erst nachdem der User im Kontextmenü
unter "API Actions" die Funktion Deploy API angeklickt und eine
Produktionsumgebung ("Stage") ausgewählt hat (z.B. "Beta"). Im Browser
zeigt AWS dann die URL an, unter der der neue Webservice erreichbar ist.
|
| Abbildung 8: Dank Amazons Free Tier halten sich die Kosten für den Lambda-Service im Rahmen. |
Als Produktionsumgebung installiert empfiehlt sich der Einsatz von
API-Tokens, mit denen sich der Zugang zur API regeln lassen. Auch ein
Drosseln des Ansturms (auf z.B. 1000 Requests/Sekunde) ist hiermit möglich,
um einer überraschenden Kostenexplosion vorzubeugen, falls der Link sich
lauffeuerartig verbreitet. Während der Entwicklung dieses Artikels hatte
ich immer ein wachsames Auge auf eventuell anfallende Kosten, fand aber,
dass diese sich im "Free Tier" im Rahmen hielten, es fielen lediglich Kosten
von $0.01 an, um die während zahlreicher Testdurchgänge aufgebrauchte
Bandbreite zum Hochladen der ständig aktualisierten und verbesserten
.zip-Datei mit Testcode und Libs zu decken.
Listings zu diesem Artikel: http://www.linux‐magazin.de/static/listings/magazin/2017/03/perl‐snapshot
"Schaut auf diese Stadt", Bewegungsanalyse in Überwachungsvideos, Linux-Magazin 12/2016: http://www.linux-magazin.de/Ausgaben/2016/12/Perl-Snapshot
"AWS Lambda in Action", Danilo Poccia, Manning 2017