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.