Cuidados ao criar um script de Cron

Artigo que apresenta dicas e cuidados ao criar scripts de Cron.

Introdução

Em 2010, escrevi o artigo Tarefas agendadas via Cron + PHP, que ensinava o básico sobre como montar um script de cron e configurá-lo para que fosse executado com certa periodicidade automaticamente. Hoje, quase 6 anos depois, revolvi escrever mais um artigo sobre o assunto, mas desta vez para compartilhar algumas lições valiosíssimas que devem ser levadas em conta ao se preparar um script de cron.

Observação: as dicas apresentadas neste artigo também valem para a construção de serviços (daemons) em PHP.

O tempo de execução

performance

É comum scripts de cron processarem grandes volumes de dados e, portanto, levarem muito tempo para serem executados, especialmente se comparada à execução de um script convencional de uma página web. Por este motivo, é preciso estar atento se o script está preparado para permanecer a execução mesmo após um longo período.

No PHP, existe a diretiva de configuração max_execution_time, que possui valor "30" (segundos) por padrão para execução web, mas possui valor "0" (zero) para execução em php-cli, ou seja, no terminal. O valor "0" indica que o script poderá ser executado por tempo indeterminado sem ser interrompido.

Portanto, por padrão, não é necessário ajustar o valor dessa diretiva nos scripts php-cli, porém, sabemos que existem alguns frameworks que podem forçar algum tempo limite. Então para garantir que o script rode por tempo indeterminado, usamos a função set_time_limit passando o valor "0" por parâmetro, logo no início da execução do script cron.

A quantidade de memória usada

sobrecarga

Normalmente scripts de cron possuem um loop principal onde é realizada alguma operação para um determinado conjunto. Por exemplo, consultar uma lista em um Banco de Dados e, para cada item consultado, realizar alguma operação sobre ele. Este formato de script costuma ter algumas características que podem causar o estouro de memória, tais como:

  • A quantidade de dados consultada da fonte (Banco de Dados) é muito grande.
  • O loop principal realiza alguma operação que popula um array e, portanto, a cada nova iteração é necessário mais consumo de memória.
  • O loop principal constrói um log dinamicamente concatenando em uma string, para só no final guardar em um arquivo, causando o acumulo de memória durante cada iteração.

Bem, se você tem dúvidas se seu script de cron está consumindo cada vez mais memória a cada iteração do loop, você pode realizar alguns experimentos de depuração relativamente simples. Basta usar a função memory_get_usage em pontos estratégicos para obter a quantidade de memória usada naquele momento. Veja o exemplo:

<?php

// Depurar quanto de memoria foi necessario para iniciar o PHP
printf("%0.2f MB\n", memory_get_usage() / 1048576);

// Consultando itens para serem processados pelo cron
...

// Depurar quanto de memoria foi necessario para guardar os itens consultados
printf("Consulta: %0.2f MB\n", memory_get_usage() / 1048576);

foreach ($itens as $item) {

    // Realizar a operacao sobre o item percorrido
    ...

    // Depurar quanto de memoria esta sendo consumida ao percorrer o item
    printf("Loop: %0.2f MB\n", memory_get_usage() / 1048576);
}

// Depurar qual foi o pico de memoria ao longo da execucao do script
printf("Pico: %0.2f MB\n", memory_get_peak_usage() / 1048576);

Algumas dicas de como evitar o uso excessivo de memória:

  • Cogitar a possibilidade de utilizar unbuffered query ao realizar a busca principal do script. Para mais detalhes sobre os benefícios e limitações das unbuffered queries, veja o artigo Buffered and Unbuffered queries
  • Evitar que o loop principal do script acumule memória (guardar valores em arrays ou strings)
  • Cogitar a possibilidade de realizar várias consultas com LIMIT, até esgotarem os itens a serem processados.

Por fim, vale resaltar que o PHP também possui uma diretiva de configuração que especifica a quantidade máxima de memória que um script poderá acumular ao longo de sua execução (e que causa um erro fatal caso ultrapasse esse limite). Esta diretiva pode ser configurada em tempo de execução desta forma:

ini_set('memory_limit', '-1');

Observação: o valor "-1" significa "sem limite de memória" (mas lembre-se que o servidor possui um limite físico de memória que você provavelmente não vai gostar de ultrapassar). Mas você pode especificar um valor fixo como "256M", usando a notação para representação de bytes prevista pelo PHP.

A perda de conexão com o Banco de Dados

Como falamos anteriormente, é comum que o script cron tenha uma longa duração. E, em alguns scripts em particular, o tempo de execução de uma única iteração do loop principal do script pode ser muito grande. Por exemplo, se a operação realizada dentro do loop envolve a requisição a uma API externa, que pode estar sobrecarregada e demorar para responder em algum momento do dia. Nesta situação, pode ocorrer a perda da sua conexão com o banco de dados por conta da inatividade. Normalmente a mensagem apresentada é "Fatal error: Uncaught exception 'PDOException' with message 'SQLSTATE[HY000]: General error: 2013 Lost connection to MySQL server during query'".

Normalmente bancos de dados possuem uma configuração de tempo limite de inatividade e, quando esse tempo é excedido, a conexão é fechada pelo servidor. Neste caso, seu script de cron pode falhar no meio de uma iteração e o script ser abortado por conta disso.

No MySQL, a diretiva que controla esse tempo é a wait_timeout. Mas não pense que só por ela existir você pode colocar um valor absurdamente alto. Lembre-se que se você manter várias conexões ociosas, estará ocupando recursos do seu servidor de Banco de Dados, que estará deixando de responder outras requisições. Porém, se você estiver lidando com algum servidor slave de banco de dados, reservado exclusivamente para atender a crons, pode ser útil aumentar esse tempo.

Porém, caso essa alternativa seja inviável, você pode optar por reconectar no Banco de Dados a cada iteração. Aliás, caso haja interações com Banco de Dados em conjunto com outras operações que podem demorar, então é melhor que você abra a conexão, realize as operações no banco e feche a conexão, para depois realizar a operação potencialmente demorada. Assim você já libera o banco o quanto antes.

A execução concorrente (em paralelo)

Scripts cron possuem periodicidade. Porém, como esses scripts podem ser demorados, pode ocorrer de alguma execução comece antes que a execução anterior tenha terminado. Por exemplo, se você coloca um script em cron para ser executado a cada 1 minuto, mas cada execução leva 5 minutos.

Neste caso, você precisa estar atento aos itens que está consultando para serem processados para evitar que um mesmo item seja processados mais de uma vez indevidamente.

Para resolver esse tipo de inconveniente, você pode:

  • Ajustar a periodicidade do script a um valor mais apropriado.
  • Ao iniciar a execução do script, checar se existe alguma outra instância do script que ainda não terminou. Caso exista, aborta a execução corrente.
  • Utilizar transações do banco de dados para garantir que o item selecionado não seja obtido por nenhum outro script. Isso é possível definindo o tipo de isolamento de transações no BD.

Falhas/Exceções não previstas ao longo da execução

Como os scripts de cron normalmente possuem um loop principal que processa itens, pode ocorrer alguma falha ou exceção com o processamento de determinado item. Se isso não for tratado adequadamente, todo o script pode ser abortado, então o funcionamento do script é comprometido.

Para resolver isso, certifique-se que todos os pontos de exceção foram devidamente capturados (try/catch), para que alguma atitude seja tomada em cada caso, nem que seja armazenar um log de erro.

Recursos de uso comum

Em scripts de cron que realizam algum processamento sobre um conjunto de itens, é comum a utilização de uma library ("service", "recurso", ou seja como queira falar) para auxiliar o processamento de cada item a cada iteração.

Uma preocupação a ser tomada é garantir que o recurso usado como serviço esteja adequadamente configurado a cada iteração, para que as configurações usadas numa iteração comprometa o comportamento de uma iteração futura.

Por exemplo, se você constrói um único resource de cURL e a cada iteração do loop suas configurações são alteradas para realizar uma requisição HTTP bem específica, então é preciso estar atento para que tudo que tenha sido "setado" esteja de acordo com o esperado nas requisições seguintes. Isso também ocorre, por exemplo, com um objeto que serve de serviço para envio de e-mails que, a cada iteração, pode precisar modificar o destinatário, conteúdo, anexos, etc.

Nestes casos, existem duas abordagens: ou você redefine todas configurações do recurso a cada iteração (recriando o recurso ou resetando suas configurações), ou então prepara o script para que adapte apenas as configurações necessárias a cada iteração.

Quem executa

Um item um pouco incomum, mas que pode causar alguns problemas em determinadas situações é qual usuário foi especificado para ser dono do processo executado no cron.

Isso deve ser avaliado principalmente quanto à possibilidade de leitura/escrita de arquivos em locais previamente permissionados com algumas restrições.

A especificação do usuário que executa o cron é feita logo após a especificação do horário (periodicidade) do script de cron.

Variáveis de Ambiente

Outro item pouco comum é a visibilidade de variáveis de ambiente pelo script de cron. Note que quando você executa um script com seu usuário no Shell, você provavelmente carregou várias variáveis de ambiente. Portanto, se o script consome alguma destas variáveis, é preciso que o cron também defina valores antes de serem executados.

Para isso, basta especificar o valor das variáveis de ambiente logo no início do /etc/crontab, conforme exemplo:

LANG=pt_BR.UTF-8
LC_ALL=pt_BR.UTF-8

0 8 * * * root /usr/bin/php /caminho/do/script.php

Gere logs

notas

Por fim, mas não menos importante, destaca-se a geração de logs para scripts de cron. Afinal, como são scripts executados em segundo plano, ninguém sabe o que ele está fazendo exatamente, a não ser que ele tenha uma forma de dizer isso.

Registre em log tudo aquilo que possa ser útil para depuração futura, mas se atente também à quantidade de dados acumulados do passado. Uma dica é compactar ou apagar arquivos de logs muito antigos.

Pode ser útil registrar no final da execução do seu script de cron quanto tempo ele durou, qual foi o pico de memória, quantos itens foram processados com sucesso e quantos com falha, como neste exemplo:

fprintf(STDOUT, "Total de itens percorridos: %d\n", $total_itens);
fprintf(STDOUT, "Total de sucesso: %d\n", $total_sucesso);
fprintf(STDOUT, "Total de erro: %d\n", $total_erro);
fprintf(STDOUT, "Tempo: %d segundos\n", time() - $_SERVER['REQUEST_TIME']);
fprintf(STDOUT, "Pico de memoria: %0.2f MB\n", memory_get_peak_usage() / 1048576);

Uma última dica é que você pode separar os logs de sucesso dos logs de erro ao longo do loop principal do seu script de cron. Basta escrever as saídas de sucesso no resource especial STDOUT e escrever as saídas de erro no resource especial STDERR, conforme exemplo:

fprintf(STDOUT, "Mensagem de sucesso: %s\n", $sucesso);
fprintf(STDERR, "Mensagem de erro: %s\n", $erro);

Para armazenar as saídas de sucesso e de erros em arquivos separados, basta registrar seu cron utilizando o desvio de saídas de sucesso (representada por ">") e o desvio de saídas de erro (representada por "2>"), conforme exemplo (note que eles limpam o arquivo caso já exista), ou então o desvio de saída de sucesso com append (representada por ">>") e o desvio de saída de erros com append (representada por "2>>"), conforme exemplos:

# Gerando um arquivo de log de sucesso e de erro por dia
0 8 * * * /usr/bin/php /caminho/ate/o/script/script.php > "/var/log/sucesso-$(date +%Y-%m-%d).log" 2> "/var/log/erros-$(date +%Y-%m-%d).log"

# Gerando um arquivo de log de sucesso e de erro
0 8 * * * /usr/bin/php /caminho/ate/o/script/script.php >> /var/log/sucesso.log 2>> /var/log/erros.log

Caso opte por usar um único arquivo (exemplo 2), lembre-se que o arquivo pode ficar muito grande. Pode ser útil quebrá-lo em partes periodicamente usando, por exemplo, o comando logrotate do Linux.

3 comentários

Túlio Spuri disse...

Bacana Rubens. Valeu pelas dicas!!

Depois da uma olhada nesta biblioteca: https://github.com/jobbyphp/jobby

O objetivo dela é manter apenas uma linha no crontab e ela garante apenas uma tarefa de cada tipo por vez.

Rod Elias disse...

Excelente artigo.
Obrigado por tê-lo escrito.
Uma dúvida, nesse último exemplo a respeito dos desvios de saída, não seria melhor usar os operadores >> (ao invés de apenas > ) para que dessa forma as mensagens de saída sejam sempre anexadas (append) nos logs, registrando assim todo um histórico, ao invés de manter somente a entrada mais recentes?

Rubens Takiguti Ribeiro (autor do blog) disse...

Oi, Rod Elias
Você tem toda a razão. O ideal é mesmo dar append da saída sempre no mesmo arquivo ou então gerar um arquivo novo a cada execução (colocando um nome em função de uma data, por exemplo). Vou atualizar essa parte do artigo. Obrigado.