Założenia wstępne
Celem wpisu jest ukazanie jak uruchomić aplikację symfony w trybie produkcyjnym z wykorzystaniem narzędzia docker.
Stack technologiczny:
PHP: 8.0
nginx: 1.19
Symfony: 5.3
Wersje dockera:
Docker: 20.10.8
docker-compose: 1.29.2
Za przykład aplikacji posłuży nam lekko zmodyfikowany (poszerzony o controller i webpack) symfony/website-skeleton.
git clone -b working-project https://github.com/grandmaster44/symfony-docker.git
Budujemy obrazy dev
W głównym katalogu projektu tworzymy plik Dockerfile oraz definiujemy wersje bibliotek
ARG PHP_VERSION=8.0 ARG NGINX_VERSION=1.19
php
Obraz php (dev) budujemy z bazowego obrazu php-fpm-apline w celu zmniejszenia jego rozmiaru.
Polecenie RUN apk add --no-cache --virtual dev-deps tworzy nam virtualną paczkę zależności o nazwie dev-deps, które łatwo później (po kompilacji php z rozszerzeniami) usunąć, co pozwala na kolejną redukcję rozmiaru obrazu. Wirtualną paczkę usuwamy przez apk del dev-deps.
Niektóre rozszerzenia PHP (takie jak np. intl) są używane podczas jego działania i nie powinno się ich usuwać, dlatego tworzymy z nich wirtualną paczkę .phpexts-rundeps, która nie zostanie usunięta z obrazu.
Do naszego developerskiego obrazu PHP dodajemy także composera.
##
## PHP dev
##
FROM php:${PHP_VERSION}-fpm-alpine AS symfony_php_dev
RUN apk add --no-cache --virtual dev-deps \
$PHPIZE_DEPS \
git \
zlib-dev \
libzip-dev \
icu-dev \
; \
docker-php-ext-configure zip; \
docker-php-ext-install \
zip \
intl \
; \
docker-php-ext-enable \
opcache \
; \
runDeps="$( \
scanelf --needed --nobanner --format '%n#p' --recursive /usr/local/lib/php/extensions \
| tr ',' '\n' \
| sort -u \
| awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \
)"; \
apk add --no-cache --virtual .phpexts-rundeps $runDeps; \
apk del dev-deps
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
WORKDIR /app
CMD ["php-fpm"]
nginx
Tworzymy plik konfiguracyjny nginx default.conf w katalogu docker/nginx https://symfony.com/doc/current/setup/web_server_configuration.html#nginx
server {
root /app/public;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass php:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $document_root;
internal;
}
location ~ \.php$ {
return 404;
}
}
Jako fastcgi_pass ustawiona została nazwa serwisu php, który znajdzie się w przyszłym docker-compose.
Dodajemy do Dockerfile obraz nginx (dev)
##
## nginx dev
##
FROM nginx:${NGINX_VERSION}-alpine as symfony_nginx_dev
COPY docker/nginx/default.conf /etc/nginx/conf.d
docker-compose
Tworzymy plik docker-compose.yml
version: '3.7'
services:
nginx:
build:
context: .
target: symfony_nginx_dev
restart: on-failure
depends_on:
- php
ports:
- "8000:80"
volumes:
- .:/app
php:
build:
context: .
target: symfony_php_dev
restart: on-failure
volumes:
- .:/app
Budujemy obrazy produkcyjne
frontend
Aby zbudować wszystkie style musimy uruchomić yarn build. W tym celu zostanie dodany nowy etap w Dockerfile:
## ## frontend ## FROM node:16-alpine as frontend WORKDIR /app COPY package.json /app/package.json COPY yarn.lock /app/yarn.lock COPY webpack.config.js /app/webpack.config.js RUN yarn install COPY assets /app/assets RUN yarn build
Nie kopiujemy wszystkich plików naszej aplikacji – wystarczą jedynie te, które potrzebne są do kompilacji styli.
php
W produkcyjnym obrazie chcemy mieć już zainstalowane wszystkie zależności composera (czyli utworzony katalog vendor) oraz środowisko read-only, aby nie można było zmieniać istniejących plików aplikacji na środowisku produkcyjnym.
Aplikacja może zmieniać jedynie dane w katalogu var/cache i var/logs, oraz wykonywać polecenia dostępne z konsoli bin/console.
##
## PHP prod
##
FROM symfony_php_dev as symfony_php_prod
ENV APP_ENV=prod
RUN echo "access.log = /dev/null" >> /usr/local/etc/php-fpm.d/www.conf
COPY composer.json composer.lock symfony.lock ./
RUN mkdir -p var/cache var/log; \
composer install --prefer-dist --no-dev --no-progress --no-scripts --no-interaction;
COPY . .
RUN composer dump-autoload --classmap-authoritative --no-dev; \
composer symfony:dump-env prod; \
composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync
RUN rm /usr/local/bin/composer
COPY --from=frontend /app/public/build /app/public/build
RUN php bin/console cache:warmup
RUN chown www-data:www-data -R /app/var
USER www-data
nginx
## ## nginx prod ## FROM symfony_nginx_dev as symfony_nginx_prod COPY --from=symfony_php_prod /app/public /app/public
docker-compose.prod
version: '3.7'
services:
nginx:
build:
context: .
target: symfony_nginx_prod
container_name: symfony_nginx
mem_limit: 128M
restart: on-failure:3
networks:
- symfony
depends_on:
- php
ports:
- 127.0.0.1:8101:80
php:
build:
context: .
target: symfony_php_prod
container_name: symfony_php
restart: on-failure:3
environment:
APP_ENV: prod
mem_limit: 256M
mem_reservation: 128M
cpus: 0.3
networks:
- symfony
volumes:
- logs:/app/var/log
volumes:
logs:
name: symfony_logs
networks:
symfony:
name: symfony
Wyjaśnienie
build.context wskazuje nam lokalizację pliku Dockerfile (znajduje się on w tym samym katalogu co docker-compose, dlatego wpisujemy .
build.target określa cel budowanego obrazu (wskazujemy, że ma zostać zbudowana wersja prod – a nie dev)
container_name definiuje nam nazwę kontenera, dzięki temu możemy się odwoływać do niego po nazwie, która jest stała a nie hashu, który jest zmienny (docker exec -it symfony_php sh)
restart określa nam maksymalną ilość błędów podczas uruchomienia, jakie może napotkać aplikacja przed zatrzymaniem kontenera
environment określa zmienne środowiskowe
mem_limit maksymalna ilosć pamięci RAM, jaką może wykorzystać kontener – warto taką zdefiniować, jeżeli na serwerze chcemy mieć kilka aplikacji, aby uniknąć wykorzystania 100% pamięci RAM na serwerze (np. w przypadku jakiegoś ataku DDoS czy błędu aplikacji która nadmiernie zwiększa pamięć RAM
mem_reservation rezerwacja pamięci RAM na serwerze, co daje nam pewność, że przynajmniej tyle pamięci zostanie przydzielone do aplikacji, niezależnie od wykorzystania pamięci na serwerze.
cpu_limit maksymalne użycie procesora, gdzie 1 oznacza rdzeń procesora a 2 oznacza dwa jego rdzenie. Tak jak w przypadku mem_limit, możemy dzięki temu uniknąć wykorzystania 100% użycia procesora na serwerze.
Z katalogu var/logs tworzymy volumen, aby logi aplikacji nie były usuwane przy jego zatrzymaniu. Gdy kontener się zatrzyma (np. z powodu błędu), będziemy mieć wgląd do logów zapisanych w volumenie.
Oba nasze kontenery będą działać w sieci symfony ale tylko nginx będzie udostępniał porty na zewnątrz (port 8101).
Czym się różni 127.0.0.1:8101:80 od 8101:80 ?
Dodając przedrostek IP lokalnego (czyli 127.0.0.1), gwarantujemy, że port 8101 nie zostanie udostępniony na zewnątrz serwera VPS. Niekiedy, gdy nie mamy skonfigurowanego firewalla, ekspozycja portu bez IP lokalnego spowoduje, że nasz port będzie widoczny na zewnątrz, czego chcemy uniknąć.
Uruchomienie
Aplikację uruchamiamy poleceniem docker-compose -f docker-compose.prod.yaml up --build i będzie ona dostępna pod adresem http://localhost:8101/.
Mając zainstalowane apache2-utils możemy zrobić benchmark naszej aplikacji (apache bench). W tym celu uruchamiamy polecenie
ab -t 10 -c 1000 -l http://localhost:8101/
które dla czasu 10s (argument t) wykona nam testy dla 1000 użytkowników (argument c). Uruchamiając polecenie docker stats w innej karcie terminala podczas testu ab, będziemy mogli sprawdzić zużycie zasobów przez naszą aplikację.
Podsumowanie
Udało się uruchomić produkcyjną aplikację symfony przy użyciu dockera. Końcowy projekt jest dostępny na https://github.com/grandmaster44/symfony-docker. Masz jakieś propozycje, co można by było jeszcze dodać do kontenera produkcyjnego ?
Dlaczego użyłem jednego pliku Dockerfile zamiast kilku ?
Tworząc 2 osobne pliki Dockerfile (osobno dla php i nginx) musiałbym 2 razy budować obrazy frontend, aby przekopiować zbuildowane style do konteneru php i nginx, co kosztowało by więcej czasu oraz zasobów. Problem ten można by było bez problemu rozwiązać na pipelinach czy w jenkins (najpierw zbudować obraz frontend, a potem kopiować z niego style do php i nginx przez osobne pliki Dockerfile), ale podczas buildu ręcznego, użycie multistage build wydaje się lepsze.
Rozwiązywanie problemów
Jeżeli napotkasz na problem przy budowaniu obrazów / uruchamianiu docker-compose, sprawdź czy posiadasz ich takie same wersje jak te wypisane na górze.
