#Microblog med Docker containers
I denna artikeln ska vi jobba igenom kapitel 19 i Miguel’s guide. Vi skall titta på hur man kan bygga en container för Microblog applikationen och koppla upp den mot en separat databas container. Slutligen skall vi också publicera den på Docker-registret så att vi kan komma åt applikationen utan att behöva källkoden.
Som referens finns “Driftsätta en Flask app”, där ni kan se hur appen körs utan Docker.
#Förutsättningar
Du har virtualiseringsmiljon docker installerat.
#Bygg en Container Image
Det första steget i att skapa en container för Microblog är att bygga en image. En container image är en typ av mall som används när man skapar en container. Den innehåller en översikt av hela filsystemet tillsammans med övriga inställningar som hanterar miljövariabler, hur nätverket är uppsatt och mycket annat. För att generera en image kommer vi skapa en ny Dockerfile Dockerfile_prod
och lägga den i mappen docker
. En Dockerfile är ett installationsskript som ser till att en applikation kan distribueras och köras likvärdigt på alla maskiner som har Docker installerad.
docker/Dockerfile_prod
för Microblog:
FROM python:3.8-alpine
RUN adduser -D microblog
WORKDIR /home/microblog
# COPY . .
COPY app app
COPY migrations migrations
COPY requirements requirements
COPY requirements.txt microblog.py boot.sh ./
RUN python -m venv .venv
RUN .venv/bin/pip3 install -r requirements.txt
ENV FLASK_APP microblog.py
RUN chmod +x boot.sh
RUN chown -R microblog:microblog ./
USER microblog
EXPOSE 5000
ENTRYPOINT ["./boot.sh"]
Varje rad i en Dockerfile är ett eget kommando som körs vid installationen. FROM
anger den container image som vår nya image ska byggas på. Oftast börjar man från en existerande image och anpassar den till sitt projekt. Imagen innehåller ett namn och en tagg, separerade med ett kolon. Taggen används som en versionshantering vilket gör att en container image kan ha mer än bara en variant. Namnet på vår image är python, vilket är den officiella Dockerimagen för Python. Taggarna för den här imagen låter dig ange vilken version av python man vill köra och vilket operativsystem den skall ligga på. Taggen 3.8-alpine
väljer en Python v3.8 installerad på Alpine Linux. Alpine Linux-distributionen används ofta istället för andra populära distors som Ubuntu på grund av dess minimala storlek. Är du nyfiken kan man se vilka taggar som finns tillgängliga för Python-imagen på Pythons image repository
RUN
exekverar ett kommando inuti i containern, liknande när man skriver något i terminalen. Många dockerfiler gör misstaget och använder sig av default användaren (root
) vilket inte är bra säkerhetsmässigt. Så för att begränsa åtkomsten lägger vi till en ny användare microblog
med hjälp av adduser -D
kommandot.
WORKDIR
skapar och sätter standardkatalog där applikationen ska installeras. När vi skapade microblog
-användaren ovan skapades det redan en hemkatalog automatiskt, så jag väljer att göra denna mappen till vår working directory. Den nya mappen kommer att gälla för alla återstående kommandon i våran Dockerfile, och även senare när containern körs.
COPY
kopierar filer från vår maskin till containern. Kommandot tar emot två eller flera argument, käll och destinations -filer/kataloger. Källan är relativ från den mappen Dockerfilen ligger i och destinationen är antingen den absoluta sökvägen eller den relativa från WORKDIR
. Just nu kopierar vi bara de “nödvändiga” filerna som behövs för produktion. Vill man koripera alla filer kan istället skriva COPY . .
.
När requirements filerna har kopierats, kan vi skapa en ny virtuell miljö med venv
modulen och därifrån installera alla moduler vi behöver.
Utöver våra requirements lägger vi också till migrations
som hanterar databas migrationerna och microblog.py
tillsammans med app
mappen som innehåller koden till projektet. Vi lägger även till en ny fil, boot.sh
som vi skapar lite senare.
RUN chmod
och chown
både säkerställer att den här nya boot.sh -filen har rättigheter som en körbar fil och att filerna som ligger i /home/microblog bara ägs av microblog
användaren.
ENV
kommandot definierar vår containers miljövariabler. Vi behöver ställa in variabeln FLASK_APP
som används när flask
skall köras.
Med hjälp av USER
kommandot sätter vi den nya microblog
-användare som standardanvändare för alla kommande kommandon, detta kommer även gällas när containern startas.
EXPOSE
konfigurerar porten som vår container skall använda för sin server. Detta är nödvändigt så att Docker kan konfigurera nätverket i containern. Jag har valt 5000 som är standardporten för flask
, men det kan vara vilken port som helst.
Slutligen definierar kommandot ENTRYPOINT
vad som ska köras när containern startas. Detta är kommandot som startar våran webbserver. För att det skall vara lite mer väl organiserat skapar vi ett separat skript boot.sh
, som vi kopierade till containern tidigare.
I boot.sh lägger vi till följande:
#!/bin/sh
source .venv/bin/activate
flask db upgrade
exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app
Scriptet aktiverar den virtuella miljön, uppgraderar databasen och startar servern med gunicorn
.
Tittar vi närmre hittar vi ett exec
kommando som läggs till innan gunicorn
. I olika shell -scripts kommer exec
att trigga den processen som kalla på scriptet och sedan ersätta den med det nya kommandot. Detta är en viktig del då Docker kopplar en containers livslängd till den första processen som körs på den. I detta fallet skulle startprocessen inte vara containerns huvudprocess, istället behöver vi då se till att startprocessen sätts som huvudprocess för att säkerställa att behållaren inte avslutas tidigt av Docker.
En annan intressant sak om Docker är att allt som containern skriver till stdout
eller stderr
fångas upp och lagras som loggar för containern. Av den anledningen är både --access-logfile
och --error-logfile
konfigurerade med en -
, som skickar loggen till stdout
så att Docker kan hantera loggarna istället.
Nu när vår nya Dockerfile är skapad kan vi bygga vår container image:
$ docker build -t microblog:1.0.0-prod -t microblog:prod -f docker/Dockerfile_prod .
Argumentet -t
som vi lägger till i kommandot docker build
anger namnet och taggen för den nya container imagen.
-f
specificerar vilken Dockerfile som skall användas. Sätter vi inte denna kommer Docker leta efter en fil som heter Dockerfile där vi sätter kontexten.
.
säger vart kontexten för vår container är. Det här är katalogen som vår Dockerfile kommer att använda när den bland annat kopierar filerna. Byggprocessen kommer att köra alla kommandon i Dockerfile och sedan skapa en image som kommer att lagras på din egna maskin.
Vill man se en lista av alla images som existerar lokalt kan man göra det med docker images
:
$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE microblog 1.0.0-prod 54a47d0c27cf About a minute ago 216MB python 3.6-alpine a6beab4fa70b 9 months ago 88.7MB
Lista kommer att innehålla den nya imagen och även den bas imagen som den byggdes på. Varje gång du gör ändringar i programmet kan du uppdatera container imagen genom att köra byggkommandot igen.
#Starta upp Containern
Efter att container imagen är byggd kan vi starta den med kommandot docker run
. Denna tar vanligtvis emot ett stort antal argument, men vi börjar med ett mindre exempel:
$ docker run --name microblog -d -p 8000:5000 --rm microblog:1.0.0-prod 021da2e1e0d390320248abf97dfbbe7b27c70fefed113d5a41bb67a68522e91c
Kolla att du kommer åt webbsidan i webbläsaren med localhost:8000
. Om något är fel kan du kolla loggarna med docker logs microblog
.
--name
tilldelar ett namn för containern.
-d
berättar för Docker att köra containern i bakgrunden.
-p
mappar containerns port till host-datorns. Den första porten är porten på host-datorn och den till höger är porten vi vill komma åt inuti containern. Ovanstående exempel öppnar port 5000 i containern till port 8000, så att man kan komma åt applikationen på localhost:8000
även om containern internt använder 5000.
--rm
tar automatiskt bort containern när den avslutas. Även om detta inte riktigt krävs brukar inte containers som avslutas eller avbryts behövas längre, så då kan vi radera dem automatiskt.
Det sista argumentet är namnet och taggen på container imagen vi vill starta. När du har kört ovanstående kommando kan du komma åt applikationen på http://localhost:8000.
Det som skrivs ut efter docker run
är det ID som tilldelats den nya containern. Det är en lång hexadecimal sträng som man kan använda när vi behöver hänvisa till en container. Man behöver inte använde hela strängen, det skall räcka med att använda de 4 första tecknen. De flesta av Dockers kommandon brukar skriva ut de 12 första vilket också fungerar lika bra att använda.
Om man vill se vilka containers som är körandes kan man använda docker ps
:
$ docker ps
CONTAINER ID IMAGE COMMAND PORTS NAMES
021da2e1e0d3 microblog:1.0.0-prod "./boot.sh" 0.0.0.0:8000->5000/tcp microblog
Om man nu vill stoppa containern kan man använda docker stop
följt av dess ID:
$ docker stop 021da2e1e0d3 021da2e1e0d3
Om du kommer ihåg finns det ett antal alternativ i programmets konfiguration som kommer från miljövariabler. Till exempel importeras flasks “hemliga nyckel” och “databas URL” från miljövariabler. I docker run
-exemplet ovan har vi inte ställt in någon av dessa, så alla konfigurations alternativ kommer att använda sina standard värden.
I ett mer realistiskt exempel ställer man in dessa miljövariabler i containern. Vi såg tidigare att ENV
i Dockerfile ställer in miljövariabler, och det är ett bra alternativ för variabler som kommer att vara statiska. För variabler som beror på installationen är det dock inte lika praktiskt att ha dem som en del av byggprocessen. Man vill ha en container image som är flexibel. Om vi vill ge applikationen till en annan person eller köra den på en annan server, vill vi kunna använda den som den är och inte behöva bygga om den med nya variabler.
Så extra miljövariabler för byggtid kan vara användbara, men det finns också ett behov av att ha “runtime variables” som kan ställas in via docker run
-kommandot. Dessa variabler kan ställas in med -e
. I följande exempel anges SECRET_KEY
och databasnamnet:
$ docker run --name microblog -d -p 8000:5000 --rm -e SECRET_KEY=my-secret-key \ -e MYSQL_DATABASE=microblog \ microblog:1.0.0-prod
Det är inte ovanligt för docker run
kommandon att bli långa på grund av att de har många miljövariabler som behöver definieras.
#Lägg till en MySQL Container
Liksom många andra produkter och tjänster har MySQL offentliga container images tillgängliga i Docker-registret. Liksom vår Microblog-container är MySQL beroende av miljövariabler som måste skickas till docker run
. Dessa konfigurerar saker som lösenord, databasnamn osv. Även om det finns många MySQL-images i registret så använder vi den officiella som underhålls av MySQL-teamet.
Här är kommandot jag använder för att starta MySQL servern:
$ docker run --name mysql -d -e MYSQL_RANDOM_ROOT_PASSWORD=yes \ -e MYSQL_DATABASE=microblog -e MYSQL_USER=microblog \ -e MYSQL_PASSWORD=<database-password> \ mysql/mysql-server:5.7
Inget mer behövs, alla maskiner som har Docker installerad kan köra kommandot ovan och en fullständigt installerad MySQL-server körs. Containern får ett slumpmässigt genererat root-lösenord, en helt ny databas som heter microblog
och en användare med samma namn som är färdig konfigurerad med fullständiga behörigheter för att komma åt databasen. Observera att du måste ange ett rätt lösenord som värdet för miljövariabeln MYSQL_PASSWORD
.
Vi kan nu starta om Microblog, men den här gången med en länk till databascontainern så att båda kan kommunicera via nätverket:
$ docker run --name microblog -d -p 8000:5000 --rm -e SECRET_KEY=my-secret-key \ --link mysql:dbserver \ -e DATABASE_URL=mysql+pymysql://microblog:<database-password>@dbserver/microblog \ microblog:1.0.0-prod
I loggarna för microblogen kan du se om kopplingen mellan microblog och mysql fungerar, docker logs microblog
. Om där inte är något fel så funkar det.
--link
berättar för Docker att göra en annan container är tillgänglig. Argumentet innehåller två namn åtskilda av ett kolon. Den första delen är namnet eller ID på containern som ska länkas, i det här fallet den som heter mysql
som vi skapade ovan. Den andra delen definierar ett host-namn som kan användas och hänvisar till den vi länkar. Här använder jag dbserver
som representerar databasservern.
Nu när länken mellan de två containarna är uppsatta kan vi ställa in miljövariabeln ‘DATABASE_URL’ så att SQLAlchemy använder rätt databas. Databasens URL kommer att använda dbserver
, microblog
som databasnamn och användare och lösenordet ändrar du till den du valde när du startade MySQL.
När man startar upp en container brukar det ta några sekunder innan det är redo att användas och acceptera nya connections, så om vi då startar MySQL containern och applikationens container direkt efter kommer den inte vara connectad till databasen. När flask db upgrade
då körs i boot.sh kommer det att krascha, vilket betyder att vi behöver ändra lite i boot scriptet:
#!/bin/sh
source .venv/bin/activate
while true; do
flask db upgrade
if [[ "$?" == "0" ]]; then
break
fi
echo Upgrade command failed, retrying in 5 secs...
sleep 5
done
exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app
Denna loop kontrollerar exit-koden för kommandot flask db upgrade
, och om den inte är 0
antar den att något gick fel, så den väntar fem sekunder och försöker sedan igen.
#Validera Dockerfile
Som med all annan kod vi skriver finns det så klart en linter/validator till koden i Dockerfiles. Vi ska använda hadolint. Det finns olika sätt att installera den, men det lättaste är att använda deras docker container. Testa validera er kod med följande kommando.
docker run --rm -i hadolint/hadolint < docker/Dockerfile_prod DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation. DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.
Du borde få samma fel som jag fick. Vi kan skriva om koden så det blir ett RUN kommando istället, i nyare versioner av Docker finns det stöd för HereDoc. Med det kan vi skriva flera rader i RUN.
Istället för:
RUN command1 RUN command2 RUN command3
Skriver vi:
RUN <<-EOF command1 command2 command3 EOF
#Aktivera HereDoc
Om du har tillräckligt ny version av Docker räcker det med att lägga till # syntax=docker/dockerfile:1.4
överst i din Dockerfile. Sen kan du skriva om koden och bygga din image igen. Om du får felet…. har du en för gammal version av Docker.
Om du har en äldre version av Docker behöver du också sätta miljövariabeln export DOCKER_BUILDKIT=1
innan du gör build
. Det aktiverar ett bygg verktyg med mer funktionalitet. Men det funkar inte om du måste ha med sudo
när du kör docker kommandon. För att slippa köra sudo
behöver du lägga till din använda i dockers användargrupp. Gör det med:
sudo usermod -aG docker <användare> su - <användare>
Nu borde docker build
med HereDoc i Dockerfile fungera.
En lista över hadolint’s möjliga fel hittar du under rules, där finns också förslag på lösningar.
#The Docker Container Registry
Så nu när vår Microblog-container fungerar fint skall vi pusha den till Docker-registret, så att vi senare kan köra applikationen på vår server.
För att få tillgång till Docker-registret måste du gå till https://hub.docker.com och skapa ett konto. Användarnamnet du väljer kommer att användas i alla images som du publicerar, så välj något du gillar.
När det är klart kan du nu logga in via terminalen med kommandot docker login
:
$ docker login
Vi har en image som heter microblog:1.0.0-prod
lagrad lokalt på datorn men, för att kunna publicera den här imagen till Docker-registret, behöver vi ändra taggen lite genom att lägga till namnet på vårt konto:
$ docker tag microblog:1.0.0-prod <your-docker-registry-account>/microblog:1.0.0-prod
Om du listar dina images igen med docker images
kommer vi att se två stycken, en för Microblog (den ursprungliga med microblog:1.0.0-prod
namnet) och en ny som innehåller ditt kontonamn. Det här är egentligen två alias för samma image.
För att publicera din image i Docker-registret, använd kommandot docker push
:
$ docker push <your-docker-registry-account>/microblog:1.0.0-prod
Nu är din image offentligt tillgänglig och redo att användas.
#Revision history
- 2022-11-03: (C, aar) La till heredoc och logs.
- 2021-11-09: (B, aar) Updaterade python version för att bli av med gcc problem.
- 2020-10-25: (A, moc) Skapad inför HT2020.