Um Ambiente de Desenvolvimento PHP com Docker

O Problema

Há um tempo atrás, eu estava programando uma aplicação com PHP, que na época, estava chegando à sua versão 8.1. No entanto, eu prosseguia me limitando a usar a versão 7.4, pois era a que estava disponível nos repositórios da minha Distro Linux - o Void Linux - que não chega a ser popular como variantes do Ubuntu, Debian, ou Fedora.

Cansado desse problema, pensei em formas de resolvê-lo: compilar do zero seria gambiarrento, uma vez que eu também queria manter a versão nova em paralelo à presente, de forma isolada; e foi pensando em isolamento que cheguei ao Docker. Neste artigo vou descrever como configurei um ambiente para o desenvolvimento de uma aplicação minha.

Docker

Uma explicação detalhada do Docker está fora do escopo deste artigo, mas resumidamente, ele implementa uma forma de virtualização em que criam-se ambientes isolados - os contêineres, com sua própia estrutura de diretórios e conjuntos de libraries e binários, utilizando o kernel do sistema nativo. Isso quer dizer que o Docker não tenta simular um sistema por completo, mas apenas uma parte dele necessária para a criação do contâiner. Um mesmo contâiner pode ser utilizado por diferentes distros linux, por exemplo, mas não funcionaria da mesma maneira no Windows, devido à diferença de kernel.

Apesar desse detalhe, ganhamos em performance, pois contêineres são muito mais leves e rápidos que máquinas virtuais, como eram usadas no Vagrant, justamente por dispensarem emulações que na maioria dos casos são desnecessárias.

Iniciando

Para começar, vamos precisar de um servidor web e uma maneira de rodar PHP junto a ele. Poderiamos utilizar o Apache com seu módulo de PHP, porém, resolvi optar pelo Nginx, passando a execução de código via FastCGI para o PHP-FPM, pois trata-se de uma solução mais leve e rápida. São dois serviços separados, e usaremos um contêiner para cada, pois é no geral uma boa prática no Docker. Para facilitar o gerenciamento de múltiplos contêineres, existe a ferramenta Docker Compose, que automatiza boa parte dos comandos ao utilizar um arquivo de configuração em yaml, titulada docker-compose.yml.

version: '3'
services:
  web: 
    image: nginx:alpine
    ports: 80:80

Começamos ditando a versão do Docker Compose e configurando o contêiner responsável pelo nosso servidor, o Nginx. Afirmamos que iremos utilizar a imagem - basicamente uma camada de libraries e programas - baseada no Alpine Linux, e vamos expor o port 80 do contêiner para o port 80 do nosso localhost. Ao rodarmos o comando docker-compose up no diretório onde deixamos a configuração, e acessarmos http://localhost:80 no navegador de escolha, já nos deparamos com a página index do Nginx:

Página Padrão do Nginx

Porém, queremos usar nosso própio index, e ainda, já que estou usando o padrao MVC, configurar o index para ser o ponto único de entrada da nossa aplicação. Como proceder?

Configurando o Nginx

O Nginx pode ser configurado por um ou mais arquivos compostos por um conjuntos de diretrizes e blocos. Blocos contém diretrizes ou outros blocos, e alguns deles são o events e o http. Como o Nginx permite que múltiplos domínios sejam servidos (chamados de Virtual Hosts), e para cada domínio precisamos configurar seu respectivo bloco server, que é declarado de forma separada do arquivo principal, o /etc/nginx/nginx.conf, onde configuramos diretrizes e blocos universais, que se aplicam a todos os domínios servidos. Os arquivos referentes a cada domínio ficam, ou no diretorio /etc/nginx/conf.d/, e seguindo o formato <nome-do-domínio>.conf, ou no /etc/nginx/sites-enabled/ e seguindo <nome-do-domínio>. Aqui vamos optar pela primeira alternativa, e criar um arquivo chamado localhost.conf. Note que não precisamos criar o nginx.conf, a menos que precisemos de configurações muito específicas. A seguir, vamos inserir no arquivo:

# Isto é um comentario
server {
    listen 80 default_server; # Use o Port 80, e torne este o bloco server padrão
    root /app/public; # A root do servidor será o diretório public da nossa aplicação
    server_name localhost; # Associamos o bloco ao nome localhost

    index index.php; # Configure o arquivo index

    location / {
        # Se o uri não levar a um arquivo ou diretório, sirva o index.php com os parâmetros GET
        try_files $uri $uri/ /index.php$is_args$query_string
    }
}

Note que, como nosso servidor só terá um único virtual host, tornamos esta a configuração padrão.

Para levarmos esse arquivo de configuração ao nosso contêiner, vamos usar um volume, de modo a compartilhar o arquivo entre o nosso sistema e o contêiner. No docker-compose.yml, acrescentamos:

...
    volumes:
       - ./docker/nginx:/etc/nginx/conf.d   

No caso, guardei o arquivo dentro da pasta nginx, que por sua vez fica numa pasta chamada docker. Os arquivos dentro dessa pasta serão compartilhados com o diretório conf.d do contêiner. Iniciando o contêiner com o docker-compose e acessando localhost/index.php pelo navegador não vemos nada além de uma tela em branco. Por que será?

Executando PHP

Esquecemos que o Nginx por si só não é capaz de executar código; precisamos de um serviço separado para isso, utilizando uma implementação do FastCGI para a respectiva linguagem. No caso do PHP, usamos o PHP-FPM. Poderiamos subi-lo no mesmo contêiner em que executamos o Nginx, usando o supervisord, mas prezando pela simplicidade, vamos executa-lo em um contêiner separado, o que é, no geral, uma boa prática no Docker. No fim do docker-compose.yml, acrescentamos:

...
  php: # Nome do Serviço
    image: php:8.0-fpm-alpine
    volumes: 
      - ./app:/app

Na seção volumes, compartilhamos o conteúdo do diretório root de nossa aplicação com uma um diretório app dentro do contêiner, afim de que o PHP-FPM tenha os arquivos a serem executados. Também será necessário fazer configurações adicionais no Nginx para pedir o PHP-FPM para executar o código solicitado.

server {
    listen 80 default_server; # Use o Port 80, e torne este o bloco server padrão
    root /app/public; # A root do servidor será o diretório public da nossa aplicação
    server_name localhost; # Associamos o bloco ao nome localhost

    index index.php; # Configure o arquivo index

    location / {
        # Se o uri não levar a um arquivo ou diretório, sirva o index.php com os parâmetros GET
        try_files $uri $uri/ /index.php$is_args$query_string
    }

    # Se o URI terminar com .php, o arquivo referido será passado para o FastCGI
    location ~ \.php$ {
        # O Request é passado para o serviço do PHP-FPM, que estará escutando no Port 9000
        fastcgi_pass php:9000; 

        # Indicamos o nome do arquivo do script
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param SCRIPT_NAME $fastcgi_script_name;

        # Passe os parâmetros indicados para o FastCGI
        include fastcgi_params;     
    }
        
}

E está feito. Subindo os contêiners com o docker-compose, temos nossa aplicação funcionante - só que não.

Bancos de Dados

Se sua aplicação depende do uso de banco de dados - como é na maior parte dos casos - então ela claramente apresentará comportamento defeituoso, uma vez que não fizemos nenhuma configuração relacionado ao banco e a persistência de seus dados. Aqui usarei SQLite, pois julgo o suficiente pra minha aplicação, mas em alguma atualização deste artigo talvez eu acrescente detalhes sobre o setup com PostgreSQL.

Lidando com o SQLite

Primeiramente, precisamos de uma forma de persistir o arquivo do banco sqlite. Faremos isso por meio de um volume gerenciado pelo própio docker, que chamaremos de database. Anexamos então, ao docker-compose.yml:

...
volumes:
  database: {}

Apenas indicamos que usaremos um volume chamado database, sem configurações adicionais, que será automaticamnete criado pelo docker-compose caso não exista. Ainda será necessário fazer uns ajustes na configurações do serviço php:

  php: # Nome do Contêiner
    image: php:8.0-fpm-alpine
    volumes: 
      - ./app:/app
      - database:/app/var/data

Dessa forma, os arquivos do diretório /app/var/data/ serão compartilhados com o volume, e vice-versa, assim persistindo ao longo do tempo. Porém ainda falta o mais importante: criar o própio banco de dados:

docker-compose run -u www-data php touch /app/var/data/database.sqlite

Como o usuário www-data, criamos o arquivo do banco sqlite. E, com sorte, tudo deve estar finalmente funcionante.