Pár tipů jak napsat lepší Dockerfile

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ů.

Luděk Veselý

PHP Developer