Tar-Archive in PHP ohne Speicherlimits streamen
Das Erstellen großer tar-Archive in PHP kann knifflig werden, sobald Ihr Datenbestand über einige
hundert Megabyte hinauswächst. Die Klasse PharData von PHP arbeitet bei vielen Vorgängen zwar
streamorientiert, der Gesamtprozess bleibt aber weiterhin an das in PHP konfigurierte memory_limit
gebunden. Wenn Sie sehr viele Dateien oder einzelne, sehr große Dateien archivieren, kann der Fehler
„Allowed memory size exhausted“ auftreten, insbesondere bei Standardlimits wie 128M.
Glücklicherweise können wir das systemseitige Dienstprogramm tar mit proc_open() aufrufen und die
Schwerstarbeit dem Betriebssystem überlassen. Das Ergebnis ist ein Stream mit konstantem
Speicherbedarf, den Sie direkt an den Browser, an Object Storage oder an einen anderen Prozess
weiterleiten können und der effizientes tar-Streaming in PHP ermöglicht.
PHP-Speicherlimits mit PharData verstehen
PharData ist zwar für viele Vorgänge effizient, kann bei sehr großen Archiven oder bei
Massenoperationen aber an Speichergrenzen stoßen. Die wesentliche Einschränkung ergibt sich aus der
Einstellung memory_limit von PHP und nicht aus PharData selbst. So können etwa Pufferung, die
Verarbeitung von Metadaten und Opcode-Speicher zusammengenommen dazu beitragen, dass dieses Limit
bei Tausenden von Dateien oder bei außergewöhnlich großen Dateien überschritten wird.
Wenn Sie nur ein Archiv benötigen, das einmal geschrieben und nie gelesen wird, bringt es meist
keinen nennenswerten Vorteil, den gesamten Vorgang im PHP-Prozess zu halten. Wenn Sie die Arbeit an
den Befehl tar des Systems auslagern, sind Sie von den Speichergrenzen in PHP befreit und
erhalten Streaming von Haus aus. Das macht diesen Ansatz zu einer hervorragenden Lösung für ein
speichereffizientes Erstellen von tar-Archiven.
Tar-Ausgabe mit proc_open() streamen
proc_open() startet einen externen Befehl und stellt dessen Standardeingabe-, Standardausgabe- und
Standardfehler-Streams als PHP-Ressourcen bereit. Die folgende Hilfsfunktion liest die Ausgabe des
Befehls tar über stdout und schiebt die Bytes direkt an den Client, sodass der
Speicherverbrauch konstant bleibt. Das ist ideal für einen tar-Download in PHP.
<?php
/**
* Report whether an already-resolved path sits inside an already-resolved root.
* Both arguments must come from realpath() so that symlinks and '..' are gone.
* Passing '/' as a root allows the entire filesystem, so only configure it deliberately.
*/
function isWithin(string $path, string $root): bool
{
return $path === $root || str_starts_with($path, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
}
/**
* Reduce a download name to a conservative ASCII attachment filename.
* Everything outside [A-Za-z0-9._-] is replaced, so no quote, backslash, or control
* character can escape the quoted-string in Content-Disposition. A backslash matters as
* much as a quote here: "report\" ends the value one character later than it looks.
*/
function safeAttachmentName(string $downloadName, string $fallback = 'archive.tar'): string
{
$name = trim((string) preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($downloadName)), '._');
return $name === '' ? $fallback : substr($name, 0, 100);
}
function clearOutputBuffers(): void
{
// Any remaining framework buffer would accumulate the archive despite flush().
while (ob_get_level() > 0) {
$status = ob_get_status();
if (($status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE) === 0 || !ob_end_clean()) {
throw new RuntimeException('Cannot disable output buffering for this response.');
}
}
}
function streamTarArchive(string $directory, string $downloadName = 'archive.tar'): void
{
$safeDownloadName = safeAttachmentName($downloadName);
// tar reads a leading '-' as an option, so a directory literally named
// "--checkpoint-action=exec=..." would run a command. realpath() gives an absolute
// path, and '--' ends option parsing for anything it still cannot cover.
$realDir = realpath($directory);
if ($realDir === false || !is_dir($realDir)) {
throw new InvalidArgumentException('Directory does not exist or is not accessible.');
}
clearOutputBuffers();
$cmd = 'tar -C ' . escapeshellarg($realDir) . ' -cf - -- .';
// stdin is never used by `tar -c`, and stderr is discarded. Capturing stderr would
// mean either draining a second stream while stdout is mid-transfer, or buffering it,
// and a chatty tar can emit one line per file. The exit code is the diagnostic we keep.
$descriptorSpec = [
0 => ['file', '/dev/null', 'r'],
1 => ['pipe', 'w'],
2 => ['file', '/dev/null', 'w'],
];
$pipes = [];
$process = proc_open($cmd, $descriptorSpec, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Failed to create tar process. Is tar installed and in PATH?');
}
header('Content-Type: application/x-tar');
header('Content-Disposition: attachment; filename="' . $safeDownloadName . '"');
header('X-Content-Type-Options: nosniff'); // Security: prevent MIME-sniffing
header('Cache-Control: private, no-store'); // Build logs are confidential: do not cache
// Stream the tar output. fread() returns '' at EOF and false on a real read error,
// and those two have to stay distinguishable: treating false as EOF reports a
// truncated archive as a complete one.
$readFailed = false;
while (true) {
$chunk = fread($pipes[1], 8192); // Read in 8KB chunks
if ($chunk === false) {
$readFailed = true;
break;
}
if ($chunk === '') {
break; // EOF
}
echo $chunk;
flush(); // Flush output to the client
}
fclose($pipes[1]);
// Use one completion path: wait for tar and collect its exit code here.
$exitCode = proc_close($process);
if ($readFailed) {
throw new RuntimeException('Failed to read the tar output stream.');
}
if ($exitCode !== 0) {
throw new RuntimeException("tar exited with status {$exitCode}");
}
}
Diese Funktion legt das Archiv zu keinem Zeitpunkt zwischen: keine temporäre Datei und kein
wachsender Puffer in PHP. Die Bytes wandern in Blöcken von 8 KB von der Standardausgabe von tar
zum Client, sodass der Speicherverbrauch von PHP unabhängig von der Archivgröße konstant bleibt. Die
Hilfsfunktion leert vor dem Start jeden verschachtelten Ausgabepuffer von PHP; verhindert ein
Framework das, schlägt sie ausdrücklich fehl, statt den Download zu puffern. Die Pufferung durch
Webserver und Proxys muss separat konfiguriert werden. tar liest die Quelldateien weiterhin von
der Festplatte, und die Pfade der Archiveinträge sind relativ zum ausgewählten Verzeichnis, statt
dessen vollständigen Dateisystempfad zu enthalten.
Zwei Annahmen sind fest eingebaut. str_starts_with() setzt PHP 8.0+ voraus, und /dev/null zusammen mit
einem POSIX-konformen tar, das -cf - und -- versteht, bedeutet Unix; sowohl GNU tar als
auch bsdtar erfüllen diese Anforderung, Windows nicht.
Eine Einschränkung sollte außerdem ausdrücklich genannt werden: Das Archiv ist nur dann gültig, wenn
tar mit Status 0 endet, und das steht erst fest, nachdem das letzte Byte gesendet wurde. Sobald
Bytes hinausgeschrieben sind, ist der Antwortstatus bereits übertragen und lässt sich nicht mehr
zurücknehmen. Der Fehler wird deshalb an den Aufrufer gemeldet und serverseitig protokolliert,
während der Client ein abgeschnittenes Archiv erhält, das er selbst erkennen muss. Wenn der Fehler
eindeutig sein muss, schreiben Sie das Archiv zunächst in eine temporäre Datei und liefern es erst
danach aus; damit tauschen Sie konstanten Speicherbedarf gegen Plattenplatz.
CI/CD-Logs in Echtzeit archivieren
Server für Continuous Integration (CI/CD) erzeugen häufig zahlreiche kleine Logdateien, die sich gut
für eine Archivierung in Echtzeit per PHP-Streaming eignen. Build-Logs sind vertraulich, deshalb
setzt alles Folgende voraus, dass es nach der bestehenden Authentifizierung und der
projektbezogenen Autorisierung Ihres Frameworks läuft: Die Route muss bereits festgestellt haben,
wer der Aufrufer ist und dass er die Logs dieses Projekts lesen darf. Einen öffentlichen Log-Download
gibt es hier nicht, und dieser DevTip versucht nicht, Ihnen ein Autorisierungssystem zu zeigen. Die
Aufgabe des Snippets selbst ist enger gefasst: eine opake Kennung auf einen Pfad abbilden,
bestätigen, dass dieser Pfad innerhalb des erlaubten Wurzelverzeichnisses liegt, und ihn streamen.
Speichern Sie das erste Beispiel als tar-streaming.php und laden Sie es mit
require_once, bevor Sie eines der folgenden Beispiele verwenden; sie nutzen gemeinsam dessen
Hilfsfunktionen.
<?php
// Mount this behind your existing auth middleware. It assumes the caller is already
// authenticated and already authorized for this project's build logs.
function downloadCiLogs(string $logDirIdentifier): void
{
// Example: map an identifier to an actual path
// In a real app, this might come from a config or database, scoped to the caller's project
$logPathMappings = [
'project-alpha-build-123' => '/var/logs/ci-cd/project-alpha/build-123',
'project-beta-deploy-45' => '/var/logs/ci-cd/project-beta/deploy-45',
];
if (!isset($logPathMappings[$logDirIdentifier])) {
throw new InvalidArgumentException('Invalid log directory identifier.');
}
// Use realpath to resolve symbolic links and '..'
$realDir = realpath($logPathMappings[$logDirIdentifier]);
$allowedRoot = realpath('/var/logs/ci-cd'); // Ensure this base path is secure
if ($realDir === false || !is_dir($realDir)) {
throw new InvalidArgumentException('Directory does not exist or is not accessible.');
}
// Compare against the root plus a separator. A bare prefix check would also accept
// a sibling such as /var/logs/ci-cd-evil for the root /var/logs/ci-cd.
if ($allowedRoot === false || !isWithin($realDir, $allowedRoot)) {
throw new RuntimeException('Access denied to the specified directory.');
}
streamTarArchive($realDir, 'ci-logs-' . $logDirIdentifier . '-' . date('Y-m-d') . '.tar');
}
// Example route handler, running after auth and authorization have already passed:
try {
$logIdentifier = $_GET['log_id'] ?? '';
if (!is_string($logIdentifier) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/', $logIdentifier)) {
throw new InvalidArgumentException('Invalid log identifier format.');
}
downloadCiLogs($logIdentifier);
} catch (Throwable $e) {
// Diagnostics stay server-side. Echoing $e->getMessage() would hand the client
// filesystem paths and tar internals, and would confirm which identifiers exist.
error_log('CI log download failed: ' . $e->getMessage());
// headers_sent() is the whole story here: once streamTarArchive() has flushed a byte,
// the 200 and its headers are already gone and nothing below can undo them.
if (!headers_sent()) {
header_remove();
http_response_code($e instanceof InvalidArgumentException ? 400 : 500);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: private, no-store');
echo "The archive could not be generated.\n";
}
}
Fortschritt mit Server-Sent Events melden
Wenn Sie Tausende von Dateien archivieren, verbessert eine Rückmeldung zum Fortschritt das Erlebnis.
Da tar -v jeden Dateinamen während der Verarbeitung auf stderr ausgibt, lassen sich diese Zeilen
als Server-Sent Events (SSE) streamen.
Machen Sie sich klar, worum es sich handelt: um einen zweiten, unabhängigen Lauf von tar,
dessen Archivausgabe verworfen wird. Er meldet den Fortschritt für ein gleichwertiges Archiv, nicht
für den Download aus dem vorherigen Abschnitt. Das sind zwei getrennte Prozesse ohne gemeinsamen
Zustand; um sie zu korrelieren, bräuchte es eine Job-ID sowie einen Fortschrittsspeicher, den beide
Anfragen erreichen können, was dieses Beispiel nicht umsetzt. Nutzen Sie ihn als Vorabschätzung,
nicht als Fortschrittsbalken für einen laufenden Download.
<?php
function sseTarProgress(string $directory, array $allowedRoots): void
{
// Same authentication and authorization assumptions as downloadCiLogs() above.
$realDir = realpath($directory);
if ($realDir === false || !is_dir($realDir)) {
throw new InvalidArgumentException('Directory does not exist or is not accessible.');
}
$isAllowed = false;
foreach ($allowedRoots as $root) {
$realRoot = realpath($root);
if ($realRoot !== false && isWithin($realDir, $realRoot)) {
$isAllowed = true;
break;
}
}
if (!$isAllowed) {
throw new RuntimeException('Access denied to the specified directory for progress reporting.');
}
clearOutputBuffers();
header('Content-Type: text/event-stream');
header('Cache-Control: private, no-store');
header('Connection: keep-alive'); // Important for SSE
// -v sends one filename per file to stderr. The archive itself goes to /dev/null:
// this run exists only to report progress, so stdout can never fill and block.
$descriptorSpec = [
0 => ['file', '/dev/null', 'r'], // stdin - not used by tar -c
1 => ['file', '/dev/null', 'w'], // stdout - the archive, discarded
2 => ['pipe', 'w'], // stderr - where tar -v outputs filenames
];
$pipes = [];
$process = proc_open('tar -C ' . escapeshellarg($realDir) . ' -cvf - -- .', $descriptorSpec, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('tar process failed to start for progress reporting.');
}
ignore_user_abort(true);
set_time_limit(0); // Allow script to run indefinitely for long tar processes
// A plain blocking read to EOF. Note what this does not do: there are no periodic
// keep-alives, so a tar that stays silent for minutes sends nothing at all. If a proxy
// between you and the client times idle connections out, drive this with
// stream_select() on a timeout and emit a ": keepalive" comment when it expires.
while (($line = fgets($pipes[2])) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
echo 'data: ' . json_encode(['file' => $line]) . "\n\n";
flush(); // Flush data to the client
}
fclose($pipes[2]);
// Exactly one terminal event, chosen by tar's real exit status. Emitting "complete"
// from a finally block would tell the client the archive is fine even when tar died.
$exitCode = proc_close($process);
if ($exitCode === 0) {
echo "event: complete\n" . 'data: ' . json_encode(['done' => true]) . "\n\n";
} else {
error_log('sseTarProgress: tar exited with status ' . $exitCode);
echo "event: error\n" . 'data: ' . json_encode(['message' => 'Archiving failed.']) . "\n\n";
}
flush();
}
Der Client konsumiert diesen Stream über die JavaScript-API EventSource. Behandeln Sie jeden Wert von
file als opake Fortschrittszeile: stderr enthält auch Diagnosemeldungen, und GNU tar und bsdtar
formatieren Einträge unterschiedlich. Er sollte error und complete als sich gegenseitig
ausschließend behandeln: Genau eines von beiden trifft ein, und nur complete bedeutet, dass das
Archiv gültig gewesen wäre.
Aufrufe von proc_open() absichern
Der Aufruf von Shell-Dienstprogrammen aus PHP lädt immer zu Command-Injection-Fehlern ein, wenn er nicht sorgfältig gehandhabt wird. Beachten Sie diese Regeln:
- Eingaben validieren und bereinigen: Validieren Sie stets alle von Nutzern bereitgestellten
Daten. Dateipfade lösen Sie mit
realpath()auf, um den kanonisierten absoluten Pfadnamen zu erhalten und die Existenz zu prüfen. - Verzeichnisse auf eine Allowlist setzen: Pflegen Sie eine strikte Allowlist von Verzeichnissen, für die Vorgänge erlaubt sind. Weisen Sie alle Pfade zurück, die dieser Liste nicht entsprechen.
- Shell-Argumente escapen: Entscheidend ist, jedes an Shell-Befehle übergebene Argument je nach
Bedarf mit
escapeshellarg()oderescapeshellcmd()zu escapen. Für einzelne Argumente istescapeshellarg()in der Regel vorzuziehen. Verketten Sie niemals rohe Eingaben direkt in eine Shell-Befehlszeichenkette. - Prinzip der geringsten Rechte: Betreiben Sie den PHP-Prozess (und den Webserver-Prozess) mit
den minimal nötigen Rechten. Vermeiden Sie es, ihn als
rootauszuführen. Der Benutzer sollte nur Lesezugriff auf die zu archivierenden Verzeichnisse und Ausführungsrechte für das Dienstprogrammtarhaben. - Exit-Codes prüfen und eine einzige Strategie für stderr wählen: Verwenden Sie
proc_close(), um auf den Abschluss zu warten und den Exit-Code zu erfassen, wie hier gezeigt. Wenn Sie zusätzlich mitproc_get_status()pollen, berücksichtigen Sie die PHP-Version: Vor PHP 8.3 lieferte nur der erste Aufruf nach dem Beenden den echten Code, spätere Aufrufe lieferten-1. PHP 8.3 hat ein Caching des Exit-Codes ergänzt. Fürstderrverwerfen Sie ihn entweder (wie es die Hilfsfunktion für den Download tut) oder leeren ihn nebenläufig mit einer strikten Obergrenze für die Größe. Wird er erst nach der Übertragung gelesen, füllt ein gesprächiger Befehl den 64 KiB großen Pipe-Puffer und blockiert für immer; wird er vollständig gepuffert, kehrt genau der unbegrenzte Speicherverbrauch zurück, den Sie mit dem Umstieg auf Streaming vermeiden wollten. - Fehlerbehandlung: Implementieren Sie eine robuste Fehlerbehandlung rund um Aufrufe von
proc_open(), um Fälle abzudecken, in denen der Befehl nicht gefunden wird, nicht startet oder mit einem Fehler endet.
Das Beispiel der Klasse SecureTarStreamer zeigt einen guten Ansatz, indem es die Pfadvalidierung
und die Allowlist-Prüfungen kapselt:
<?php
class SecureTarStreamer
{
private array $allowedRoots;
public function __construct(array $allowedRoots)
{
// Ensure allowedRoots are absolute and valid paths during construction
$this->allowedRoots = array_map(function($root) {
$realRoot = realpath($root);
if ($realRoot === false || !is_dir($realRoot)) {
throw new InvalidArgumentException("Invalid allowed root directory: {$root}");
}
return $realRoot;
}, $allowedRoots);
}
public function send(string $userSuppliedDir, string $downloadName = 'archive.tar'): void
{
$path = realpath($userSuppliedDir); // Resolve the user-supplied path
if ($path === false || !is_dir($path)) {
throw new InvalidArgumentException('Invalid or non-existent directory specified.');
}
$isAllowed = false;
foreach ($this->allowedRoots as $root) {
if (isWithin($path, $root)) {
$isAllowed = true;
break;
}
}
if (!$isAllowed) {
throw new RuntimeException('Access to the specified directory is not allowed.');
}
// Now it's safer to call the streaming function
streamTarArchive($path, $downloadName);
}
}
// Example Usage, again behind your existing authentication and authorization:
// $streamer = new SecureTarStreamer(['/var/www/safe_uploads', '/mnt/user_data']);
// $streamer->send($_GET['directory_to_archive']);
//
// The allowlist is checked with realpath(), so it inspects the tree and then hands the
// resolved path to tar. Between those two steps a symlink could be swapped. Keep the
// archived directories under your application's control rather than in a tree that other
// local users can write to.
Ansätze vergleichen
Die Performance-Eigenschaften unterscheiden sich je nach Anwendungsfall:
PharData: Optimal für die Bearbeitung von Archiven (Lesen/Schreiben einzelner Dateien innerhalb eines Archivs), wenn Speicherlimits keine Rolle spielen, oder für kleinere Archive.- Streaming mit
proc_open+tar: Am besten geeignet, um große Archive mit minimalem Speicherverbrauch in PHP zu erstellen (Archiv-Streaming in PHP), insbesondere für Szenarien mit einmaligem Schreiben wie Backups oder Log-Archivierung. Das ist eine sehr speichereffiziente tar-Methode. - Transloadit Robot: Ideal für einen skalierbaren Einsatz in der Produktion, wenn Sie den gesamten Vorgang auslagern möchten. Er bietet automatische Optimierung, verarbeitet verschiedene Formate (tar, zip usw.) und lässt sich in Cloud-Storage integrieren.
Wählen Sie die Methode, die am besten zu Ihrer Arbeitslast passt: wahlfreier Dateizugriff, Streaming on premises oder vollständig verwaltete Komprimierung in der Cloud.
Was ist mit abgebrochenen Downloads?
Das tar-Format ist sequenziell, daher wird ein echtes Fortsetzen über Byte-Bereiche bei einem einfachen HTTP-Download nicht nativ unterstützt, solange keine Drittanbieter-Tools oder keine serverseitige Logik zum Indizieren des Streams zum Einsatz kommen. In der Praxis gehen Clients mit Unterbrechungen von Downloads mit mehreren Gigabyte nicht immer elegant um. Wenn das Fortsetzen für Ihre Anwendung entscheidend ist:
- Daten aufteilen: Erwägen Sie, die Daten auf mehrere kleinere tar-Dateien aufzuteilen. Clients können diese dann einzeln herunterladen, und ein Fehler betrifft nur einen Teil.
- Fortsetzbare Transportprotokolle: Verwenden Sie einen fortsetzbaren Transportmechanismus.
Nachdem das tar-Archiv erzeugt wurde (auch wenn es zunächst in eine temporäre lokale Datei
gestreamt oder direkt weitergeleitet wird), können Sie es mit Protokollen wie
tus.ioausliefern oder hochladen oder Funktionen wie S3-Multipart-Uploads nutzen, wenn das Ziel Cloud-Storage ist. - Transloadit übernehmen lassen: Die Plattform von Transloadit ist auf robuste Dateiverarbeitung und Auslieferung ausgelegt und bewältigt viele Komplexitäten großer Dateiübertragungen von Haus aus.
Fazit
proc_open() in Kombination mit dem Dienstprogramm tar des Systems zu verwenden, ist ein einfaches
und zugleich leistungsfähiges Muster, um große Archive in PHP mit einem nahezu bei null liegenden
Speicherbedarf innerhalb des PHP-Prozesses selbst zu erstellen. Diese Technik ist besonders
wirkungsvoll für Aufgaben wie das Archivieren von CI/CD-Logs mit PHP, das Erstellen nächtlicher
Backups oder den Umgang mit beliebig großen Datenbeständen, die einmal geschrieben und effizient
gestreamt werden müssen. Das ist ein hervorragender Weg zu tar ohne Speicherlimits in PHP.
Sie möchten noch weniger selbst übernehmen? Der Robot 🤖 /file/compress
von Transloadit kann Archive im Format .tar (optional gzip-komprimiert) für Sie erstellen. Eine
minimale Assembly sieht so aus:
{
"steps": {
"compressed": {
"robot": "/file/compress",
"use": ":original",
"format": "tar",
"gzip": true
}
}
}
Der Robot unterstützt die beiden Formate tar und zip, mit optionaler
gzip-Komprimierung. Probieren Sie ihn aus und überlassen Sie Speicherlimits, Nebenläufigkeit und den
Umgang mit Sonderfällen unserer Infrastruktur, während Sie sich auf den Bau von Features
konzentrieren.
