Málokdo používá Docker tak že si spustí nějaký základní image, provede změny a zavolá docker commit
. Většinou napíšete Dockerfile, ve kterém je definované, jak se má požadaný image vytvořit. Díky tomu je možné build automatizovat, provádět na integračním serveru a opakovaně. Usnadní to třeba aktualizaci nástrojů na požadovanou verzi (nedávno jsem aktualizoval Elasticsearch a nebylo to tak hrozné). Díky kešování výsledků mezikroků to ani není moc pomalé. Přesto je třeba si uvědomit pár základních věcí a využít tak možnosti Dockeru naplno.
Vhodně zvolený base image
Na začátku každého Dockerfile stojí FROM ...
. Dost lidí nad tím očividně nepřemýšlí a zvolí nějakou linuxovou distribuci, nejčastěji Ubuntu. Schválně se podívejte na GitHub, kolikrát soubor Dockerfile obsahuje FROM ubuntu
. Pokud vyhledáte všechny soubory Dockerfile, zjistíte, že z přibližně 170 000 nalezených souborů jich asi 50 000 obsahuje frázi FROM ubuntu
. Výsledky nelze brát uplně přesně, nelze totiž vyhledávat pomocí přesné shody, jako ukazatel ale postačí. Navíc zde nejsou započitány image které jako základ použijí image který používá Ubuntu.
Co je na tom špatného? Proč nepoužít operační systém který dobře znám, na serveru už jej dávno mám a vidím jej často i u oficiálních obrazů? Je třeba si uvědomit, že v době vzniku Ubuntu a podobných distibucí (CentOS, Debian, ...) byl Docker vzdálenou budoucností a distribuce mezitím dost nabobtnaly. Nesou si tak s sebou spoustu nástrojů, které se na serveru můžou hodit, ale v běžícím kontejneru je vůbec nevyužijete. Poslat celý image Ubuntu po internetu tak chvíli zabere, instalace jediného nástroje trvá věčnost, s využitím RAM to také není úplně růžové. Jak z toho ven?
FROM alpine
Řešením je jako základ použít Alpine Linux. Jedná se o minimální verzi linuxu (5MB!) s připraveným balíčkovacím nástrojem. V dokumentaci mají příklad, který dokonale popisuje jak problém řeší. Chci vytvožit image s nainstalovaným MySQL klientem, abych se mohl přihlásit k MySQL databázi. Nic víc, nic míň, jen jeden balíček. V případě použití Ubuntu by Dockerfile vypadal následovně:
FROM ubuntu-debootstrap:14.04
RUN apt-get update -q \
&& DEBIAN_FRONTEND=noninteractive apt-get install -qy mysql-client \
&& apt-get clean \
&& rm -rf /var/lib/apt
ENTRYPOINT ["mysql"]
Sestavit image trvalo 19 vteřin a jeho výsledná velikost je 164 MB. S použitím Alpine by to vypadalo následovně:
FROM gliderlabs/alpine:3.3
RUN apk add --no-cache mysql-client
ENTRYPOINT ["mysql"]
Build za 3 vteřiny a velikost image je 36 MB (Zdroj). Představte si že chcete mít v kontejneru každý konzolový příkaz, který vám na serveru běží na pozadí - najednou použití Dockeru dává větší smysl. Použití Alpine s sebou nese určité změny oproti Ubuntu - místo shellu BASH je použit ASH a balíčky se instalují pomocí apk
místo apt-get
.
Minimalizace počtu příkazů v Dockerfile
Zejména se to týká příkazu RUN
použitého opakovaně za sebou. Podívejme se na tento Dockerfile:
FROM alpine
RUN apk update
RUN apk add php-cli
RUN apk add wget
RUN wget http://domain.com/run.php
ENTRYPOINT ["php" "run.php"]
Co je zde špatně? Nejprve je třeba si uvědomit, jak Docker image vytváří. Po provedení každého image vzniká jeho nová vrstva, která se jakoby nabalí na tu předchozí. Vlastně taková sněhová koule valící se z kopce. Problém je, že každá vrstva se uloží. K čemu ale potřebuji výsledek apt-get install wget
, který mi sloužil jen ke stažení souboru v následujícím kroku? Lepší by bylo příkaz nainstalovat, stáhnout soubor a opět odinstalovat (pominu fakt, že by šel nahradit příkazem ADD
), přičemž po odinstalaci by bylo dobré dostranit i všechny dočasné soubory. Nelze také opominout režiji spojenou s zpraováním jednotlivých vrstev. Lepší by tedy bylo všechny příkazy provést v jednom kroku:
FROM alpine
RUN apk update && \
apk add php-cli && \
apk add wget && \
wget http://domain.com/run.php && \
apk del wget && \
rm -rf /tmp/* && \
rm -rf /var/cache/apk/*
ENTRYPOINT ["php" "run.php"]
Pokud by to působilo Dockerfile nepřehledným, lze vytvořit shellový skript (například install.sh) a v Dockerfile ho přidat a spustit:
FROM alpine
COPY install.sh /install.sh
RUN bash /build.sh
ENTRYPOINT ["php" "run.php"]
Vhodné pořadí příkazů
Docker při buildu ve výchozím nastavení používá cache pro každou vrstvu image. Jakmile dostane příkaz (například COPY install.sh /install.sh
), nejprve zjistí, zda již nemá výsledek a pokud ano tak jej použije. Při COPY
souboru sjistí jeho hash, porovná s keší a případně použije již existující výsledek. Pokud u jednoho příkazu nepoužije keš, platí to i pro všechny následující příkazy. Toho je třeba maximálně využít a to co se často mění přesunout na konec Dockerfile. Takto například vypadá nevhodný image:
FROM alpine
COPY run.php /run.php
RUN apk update && \
apk add php-cli && \
rm -rf /tmp/* && \
rm -rf /var/cache/apk/*
ENTRYPOINT ["php" "/run.php"]
V čem je problém? Při změně zdrojového kódu v souboru run.php
, což Docker zjistí hned na druhém řádku, musí provést i všechny další kroky, tedy nainstalovat PHP. Předpokládám ale, že zdrojové kódy se mění vždy, kdežto nový balíček s PHP nevychází příliš často. Lepší tedy bude instalaci PHP přesunout na začátek a při dalších buildech jej vůbec neinstalovat, ale využít cache:
FROM alpine
RUN apk update && \
apk add php-cli && \
rm -rf /tmp/* && \
rm -rf /var/cache/apk/*
COPY run.php /run.php
ENTRYPOINT ["php" "/run.php"]
To že se používá cache je vidět v logu Dockeru. Pokud spustíte docker build
, u každého kroku uvidíte buď running in...
(není použita cache) nebo using cache
(je použita).
Co nejjednodušší kontejnery
Filozofii Dockeru nejlépe odpovídá situace, kdy je v něm spuštěn jeden proces, ten produkuje log na standardní výstup a končí odpovídajícím návratovým kódem. Chápu, že ne vždy je to možné, ale je dobré takto přemýšlet a pokud se rozhodnu mít v kontejneru více procesů, měl bych pro to mít dobrý důvod. Já takhle používám NGINX a PHP-FPM, protože mi jeden bez druhého nedávají smysl a nechci řešit restart a deploy každého kontejneru zvlášť. Podobné je to s logováním - nepřijde mi dobrý nápad řešit uvnitř kontejneru kam bude logovat, prostě vypisuji na standardní výstup a o sběr logů ať se postará někdo jiný. Pro tento účel používám Logspout a pokud vypadne, neovlivní to nijak chod ostatních kontejnerů.
Další postřehy
COPY vs ADD
Moc jsem nechápal rozdíl mezi ADD
a COPY
, z dokumentace jsem byl trochu zmatený:
ADD:
The
ADD
instruction copies new files, directories or remote file URLs from<src>
and adds them to the filesystem of the container at the path<dest>
.
COPY:
The
COPY
instruction copies new files or directories from<src>
and adds them to the filesystem of the container at the path<dest>
.
V praxi jde o to, že ADD
umí to samé co COPY
, navíc ale ještě umí:
- načíst soubor z URL (není tedy třeba instalovat wget/curl)
- rozbalit některé archivy (není tedy třeba volat
tar xvf ...
)
V některých starších verzích Dockeru bylo COPY
označené jako deprecated, to ale neplatí a pokud nevíte který zvolit, stačí se držet jedním pravidlem:
- pokud to jde použít
COPY
ENTRYPOINT vs CMD
Definici příkazu který se má provést při spuštění kontejneru jsem viděl více způsoby - pomocí ENTRYPOINT
, CMD
, v hranatých závorkách i bez nich. Jak to tedy je? Uvedu příklad:
ENTRYPOINT ["/bin/bash"]
CMD ["ls"]
Zde se při spuštění kontejneru provede /bin/bash ls
. Oba příkazy se tedy spojí a provedou jako jeden příkaz. CMD
lze definovat při spuštění kontejneru, měl by tedy obsahovat něco, co se může měnit.
Lze také vynechat hranaté závorky, co se stane? Pokud bychom zapsali ENTRYPOINT ["/run.sh"]
, provedlo by se /run.sh
. Pokud ale zapíšeme ENTRYPOINT run.sh
, provede se /bin/sh -c /run.sh
. Více k tématu naleznete v dokumentaci.
Závěrem
Také jste se setkali s nějakými záludnostmi při psaní Dockerfile? Podělte se o ně! Protože je dobré si informace načíst z více zdrojů, přihodím pár odkazů.
- Best practises for writing Dockerfiles přímo od Dockeru
- Další seznam best practises
- Dockerfile reference - naprostý základ
- Image postavený na Alpine řešící některé známé problémy
- Další užitečné image postavené na Alpine Linux: Java, Redis, PHP, Elasticsearch s českým slovníkem