Symfony produkcyjnie przy użyciu Docker’a

Czas czytania: 4 minut

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.

Następne kroki

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *