Jak (ne)používáme v PHP exec

Posted 13. 09. 2015 / By Petr Soukup / Cloud

elephpantNa funkci exec v PHP se dívám podobně jako na eval. Určitě má svoje využití, ale pokud je to možné, raději se jí vyhnu. Obvykle totiž celý problém lze vzít z jiného konce s mnohem lepším výsledkem.

Co je exec()?

Pokud jste byli vždycky jen na sdíleném hostingu, tak exec možná ani neznáte, protože tam je prakticky vždy zablokovaný. Exec (a jemu podobné shellexec, system, passthru atd.) slouží k zavolání systémové příkazu a získání výstupu. Může to vypadat například takhle:

<?php
exec('convert /tmp/image.jpg -resize 500x500');
exec('rm -rf /'); // suicide!

První příkaz převede obrázek pomocí ImageMagick a druhý smaže všechny (co práva dovolí) soubory na disku. Už možná tušíte, co se mi na něm nelíbí.

Reálný příklad

Ať nepíšu jen prázdnou teorii, tak vezmu reálný příklad, o kterém jsme diskutovali pod starším článkem. Když v PHP potřebujete převést HTML do PDF, tak můžete buď použít knihovnu mpdf (která sice funguje, ale je otřesná) nebo můžete přes exec zavolat wkhtmltopdf.

<?php
exec('wkhtmltopdf https://www.souki.cz /tmp/file.pdf 2>&1');

Problémy exec

Zabezpečení

Exec je poměrně mocná zbraň, která může nadělat pořádnou paseku při špatném použití. PHP je například omezeno pomocí open_basedir, do jakých složek v systému smí přistoupit. Toto omezení se ale nijak nevztahuje na exec, se kterým můžete všude. To lze různými způsoby vyřešit (chroot, Docker, ...), ale najednou musíte jeden problém řešit na dvou místech.

Exec běží pod stejným uživatelem jako PHP. Díky tomu není možné (kromě zmiňovaných složek) provádět nic, na co byste neměli práva přímo v PHP. Zároveň to ale znamená, že pokud aplikace spouštěná přes exec potřebuje nějaká práva navíc, musí je dostat i PHP.

Omezení přístupu a obezličky

Pokud máte k dispozici exec, tak se nabízí delegovat na něj některé úkoly. V čistém PHP například dá práci vytvořit si strom složek, ale přes exec je to jeden jednoduchý příkaz. Bohužel je to ale ve výsledku pomalejší a můžete tím skrýt některé problémy.

Ideální by proto bylo omezit použití exec jen na konkrétní případy. Jenže to nejde. Můžete jedině zablokovat funkci pro určitou cestu, což je ale k ničemu, když všechny requesty prochází skrz /index.php

Logování

Pokud v PHP dojde k nějakému problému, bude evidován v logu. Co se ale stane v exec, zůstane v exec. Monitoring běhu si musíte vyřešit sami a u složitějších (hlavně složených) příkazů to nemusí být až tak jednoduché. Dostanete sice chybový výstup, ale jen jako text - pokud selže nějaká část posloupnosti příkazů, musíte to jedině vyparsovat z výstupu.

Alternativa

Většina těchto "drobných" problémů jde nějak vyřešit. Nepřijde mi ale, že by se vyplatilo tím ztrácet čas. Jak to lze udělat lépe?

Místo použití exec raději převod zapouzdříme do Node.js. Kód potom vypadá nějak takto:

var http = require('http');
var url = require('url');
var execFile = require('child_process').execFile;

server = http.createServer(function(request, response) {
  var query = url.parse(request.url, true);
  execFile('wkhtmltopdf', [query.website, query.output], function (error, stdout, stderr) {
            if (error !== null) {
                response.writeHead(500, {'Content-type': 'application/json; charset=utf-8'});
                response.write(JSON.stringify({
                  error:error,
                  stdout:stdout,
                  stderr:stderr
                }));
            }else{
              response.writeHead(200, {'Content-type': 'application/json; charset=utf-8'});
                response.write(JSON.stringify({
                  stdout:stdout
                }}));
            }
            response.end();
        });
});

server.listen(10333, '127.0.0.1');

V PHP pak zpracování vyvolám například takto:

<?php
$response = (new GuzzleHttp\Client())->get('http://127.0.0.1:10333/',[
  'query' => [
    'website' => 'https://www.souki.cz',
    'output' => '/tmp/file.pdf'
  ]
]);

V reálu bychom se samozřejmě v Node.js netrápili s exec a místo toho rovnou použili NPM balíček, který 90% práce vyřeší. Pak bychom spolu ale porovnávali jablka a hrušky :) Kromě toho ani není nutné používat zrovna Node.js. Klidně bychom mohli použít zase PHP, které by ale běželo pod jiným uživatelem ve vlastním poolu. Proč používám zrovna Node.js za moment vysvětlím.

Výhody mikroslužby

Zabezpečení

Po oddělení problému do samostatné mikroslužby můžu v PHP exec zakázat a ušetřit si nějaké problémy. Vytváření PDF navíc může běžet pod samostatným uživatelem, které má jen ta nejnutnější práva. Pokud bych pak řešil třeba digitální podepisování výsledného PDF, tak k privátnímu klíči bude mít přístup pouze uživatel mikroslužby a nic jiného.

Při generování PDF pak nemusím nijak řešit escapování na straně PHP, protože to je odpovědností protistrany. Daleko lépe se pak hlídá zabezpečení systémového volání v krátkém skriptu než v kompletně celé aplikaci.

Multiplatformost, portabilita

Pokud bych potřeboval, tak můžu nyní vytvářet PDF i z jiných aplikací než je PHP, aniž bych musel cokoliv měnit. Komunikuje se přes obyčejné HTTP, takže to lze propojit s čímkoliv.

Mikroslužba navíc ani nemusí být na stejném serveru. Z PHP se volá jen URL a ve výsledku je jedno, kde se převod skutečně provede.

Paralelní běh

Bez větší námahy můžu nyní vytvářet třeba 100 PDF paralelně, zatímco s obyčejným exec bych vytvářel PDF postupně.

<?php
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
$client = new Client(['base_uri' => 'http://127.0.0.1:10333/']);
$promises = [
    $client->getAsync('/','query'=>['website' => 'https://www.souki.cz/1', 'output' => '/tmp/file1.pdf']),
    $client->getAsync('/','query'=>['website' => 'https://www.souki.cz/2', 'output' => '/tmp/file2.pdf']),
    $client->getAsync('/','query'=>['website' => 'https://www.souki.cz/3', 'output' => '/tmp/file3.pdf']),
    ...
];
$results = Promise\unwrap($promises);

Škálovatelnost

Předchozí příklad v praxi nebude fungovat až tak dobře, jak možná zní. V reálu jsme totiž stejně omezeni výkonem serveru, takže sice pošleme 100 požadavků, ale reálně se budou paralelně zpracovávat třeba 3-4 najednou a server bude zcela vytížený. Protože ale máme tvorbu PDF oddělenou od zbytku aplikace, dává nám to nové možnosti.

Cloud FTW!

Protože jsme v cloudu, tak přestaneme přemýšlet jako v minulém století a skutečně cloud použijeme. Konkrétně Amazon k tomu má zcela ideální službu - Lambdu. Jde o hostované Node.js, které se umí samo škálovat a je účtované po násobcích 100ms skutečného běhu. Pokud tedy v noci žádné PDF netvoříte, nic neplatíte. Pokud potřebujete vytvořit najednou 10 000 PDF, nemá s tím žádný problém a platíte jen těch pár vteřin běhu (konkrétně $0.000000208/100ms).

Abychom s tím měli ještě méně práce, tak to celé schováme za API Gateway - díky tomu nemusíme v PHP změnit ani čárku (snad kromě adresy, pokud jsme ji měli napevno). Node.js musíme trochu upravit, ale budeme spíš mazat:

var execFile = require('child_process').execFile;

exports.handler = function (event, context) {
  execFile('wkhtmltopdf', [event.website, event.output], function (error, stdout, stderr) {
            if (error !== null) {
                context.fail({
                  error:error,
                  stdout:stdout,
                  stderr:stderr
                });
            }else{
                context.succeed({
                  stdout:stdout
                });
            }
        });
};

Kód zůstal víceméně stejný, jen zmizelo spouštění serveru a převody parametrů (obojí řeší API Gateway).

Nyní můžeme vesele pouštět klidně 1000 dokumentů k převodu najednou a rychlost bude vždy stejná. Navíc nám Amazon rovnou tvoří logy, dělá grafy využití a tak dále. A přitom se to všechno vejde do $1/měsíc.

Jak tedy?

Řešit zrovna generování PDF přes cloudovou službu může být možná overkill. Přesto se ale může hodit mít to celé v Node.js rovnou připravené a převod je pak otázkou chvilky. Zároveň se tím ale řeší spousta potenciálních problémů a otevírají se nové možnosti, jako třeba zmiňované dávkové zpracování.

Mimochodem: když jsme pod minulými články řešili, co přesně znamená převod aplikace pro cloud, tak je to přesně toto - rozdělení aplikace na dílky s konkrétními úkoly, kde každý dílek můžeme škálovat samostatně.

Update: Nestačí vám články? Nově nabízím i konzultace AWS cloudu :)



O blogu
Blog o provozování eshopů a technologickém zázemí.
Aktuálně řeším hlavně cloud, bezpečnost a optimalizaci rychlosti.

Rozjíždím službu pro propojení eshopů s dodavateli.