tar-Archive in PHP mit begrenzter Pufferung streamen
Lesen Sie mit proc_open() die Ausgabe von GNU tar abschnittsweise und senden Sie
sie als HTTP-Antwort. PHP muss das Archiv weder im Arbeitsspeicher halten noch ein temporäres
Archiv schreiben. Das hat einen Nachteil: Wenn tar nach Beginn der
Antwort fehlschlägt, kann der Client HTTP 200 und ein lesbares Archiv erhalten, in dem Dateien
fehlen. Ein erfolgreicher Download beweist nicht, dass das Archiv vollständig ist.
Diese Anleitung erstellt einen lokalen Download-Endpunkt für ein festes Verzeichnis mit
abgeschlossenen Build-Logs und prüft anschließend jeden erwarteten Archiveintrag und seine Bytes.
Getestet wurde unter Linux mit dem lokalen Server von PHP 8.5.10 sowie mit PHP-FPM 8.4.26 hinter
Nginx 1.28.3, jeweils mit GNU tar 1.35. Sie benötigen außerdem Bash, cURL und
cmp; in PHP muss proc_open() aktiviert sein.
Windows und BSD tar sind nicht Gegenstand dieser Anleitung.
Das Archiv aus den PHP-Puffern heraushalten
Die Einstellung memory_limit von PHP gilt weiterhin. Der Vorteil: Die
Übertragungsschleife hält nur einen Abschnitt von 8 KiB statt einer Zeichenfolge in Archivgröße.
Der separate Prozess tar, die PHP-Laufzeitumgebung, der Webserver und
das Betriebssystem benötigen ebenfalls Arbeitsspeicher. Hier geht es um begrenzte Pufferung in
der Anwendung, nicht darum, sämtliche Speicherlimits aufzuheben.
PharData bietet eine API zum Bearbeiten
von tar- und ZIP-Archiven auf dem Datenträger. Die Nutzung bedeutet nicht zwangsläufig, dass die
Inhalte aller Dateien in eine einzige Zeichenfolge geladen werden. Für diesen einmalig erstellten
Download liefert ein externer Prozess tar eine einfache Pipe und einen
Exit-Status für die Erstellung.
Speichern Sie dies als setup.sh in einem Arbeitsverzeichnis und führen Sie
anschließend bash setup.sh aus. Das Skript erstellt ein neues Verzeichnis
php-tar-demo und verweigert die Wiederverwendung eines vorhandenen Verzeichnisses.
Die Eingabe enthält eine Textdatei, eine leere Datei und binäre Bytes. Die nachfolgenden Dateien
gehören in php-tar-demo.
#!/usr/bin/env bash
set -euo pipefail
mkdir php-tar-demo
cd php-tar-demo
mkdir public logs logs/build-123
printf 'Build 123 passed\n' > logs/build-123/build.log
: > logs/build-123/empty.log
printf '\000\001\177\200\377\n' > logs/build-123/payload.bin
tar-Ausgabe mit proc_open() streamen
Speichern Sie dies als tar-streaming.php. Die
Array-Form von proc_open()
startet das Programm ohne Shell. Absolute Verzeichnispfade und --
verhindern, dass Dateinamen zu Befehlsoptionen werden. Verwenden Sie auf dem Server einen
vertrauenswürdigen Wert für PATH und lassen Sie
TAR_OPTIONS ungesetzt.
<?php
declare(strict_types=1);
/** Both paths must already have been resolved with realpath(). */
function isWithin(string $path, string $root): bool
{
return $path === $root || str_starts_with($path, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
}
function safeAttachmentName(string $downloadName): string
{
$name = trim((string) preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($downloadName)), '._');
return $name === '' ? 'archive.tar' : substr($name, 0, 100);
}
function clearOutputBuffers(): void
{
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
{
$realDir = realpath($directory);
if ($realDir === false || !is_dir($realDir)) {
throw new InvalidArgumentException('Directory does not exist or is not accessible.');
}
clearOutputBuffers();
// Discard stderr rather than risk a full, unread pipe blocking tar.
// The exit status still tells the caller whether creation succeeded.
$descriptors = [
0 => ['file', '/dev/null', 'r'],
1 => ['pipe', 'w'],
2 => ['file', '/dev/null', 'w'],
];
$process = proc_open(['tar', '-C', $realDir, '-cf', '-', '--', '.'], $descriptors, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Could not start tar.');
}
// Let our cleanup run when PHP notices a disconnected client.
$previousIgnoreAbort = ignore_user_abort(true);
$reachedEnd = false;
try {
header('Content-Type: application/x-tar');
header('Content-Disposition: attachment; filename="' . safeAttachmentName($downloadName) . '"');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');
header('X-Accel-Buffering: no');
while (true) {
$chunk = fread($pipes[1], 8192);
if ($chunk === false) {
throw new RuntimeException('Failed to read the tar output stream.');
}
if ($chunk === '') {
break;
}
echo $chunk;
flush();
if (connection_aborted()) {
throw new RuntimeException('Client disconnected.');
}
}
$reachedEnd = true;
} finally {
fclose($pipes[1]);
if (!$reachedEnd) {
proc_terminate($process);
}
$exitCode = proc_close($process);
ignore_user_abort($previousIgnoreAbort !== 0);
}
if ($exitCode !== 0) {
throw new RuntimeException("tar exited with status {$exitCode}");
}
}
class SecureTarStreamer
{
private array $allowedRoots;
public function __construct(array $allowedRoots)
{
$this->allowedRoots = array_map(function (string $root): string {
$resolved = realpath($root);
if ($resolved === false || !is_dir($resolved)) {
throw new InvalidArgumentException('Invalid allowed root directory.');
}
return $resolved;
}, $allowedRoots);
}
public function send(string $directory, string $downloadName = 'archive.tar'): void
{
$path = realpath($directory);
if ($path === false || !is_dir($path)) {
throw new InvalidArgumentException('Directory does not exist or is not accessible.');
}
foreach ($this->allowedRoots as $root) {
if (isWithin($path, $root)) {
streamTarArchive($path, $downloadName);
return;
}
}
throw new RuntimeException('Access to the specified directory is not allowed.');
}
}
Die Pipe ist blockierend, daher bedeutet ein leerer Lesevorgang EOF;
false bedeutet einen Lesefehler. Nach dem Schließen der Pipe wartet
proc_close() auf den erzeugenden Prozess
und gibt dessen Exit-Status zurück. Weder EOF noch der letzte erfolgreiche Schreibvorgang zeigen
an, dass tar erfolgreich war. Wer stderr verwirft, verliert detaillierte
Diagnosedaten. Wenn Sie diese benötigen, lesen Sie stdout und stderr gleichzeitig aus und halten
Sie nur eine begrenzte Menge an Diagnosetext vor.
Ein Verzeichnis mit abgeschlossenen Build-Logs ausliefern
Speichern Sie dies als public/download.php. Der opake Wert
log_id wählt ein konfiguriertes Verzeichnis aus; die Anfrage liefert
niemals einen Dateisystempfad. Nur public/ wird ausgeliefert, sodass die
Quell-Logs und das Hilfsskript außerhalb des Dokumentenstammverzeichnisses bleiben.
<?php
declare(strict_types=1);
require __DIR__ . '/../tar-streaming.php';
try {
$logId = $_GET['log_id'] ?? '';
$directories = ['build-123' => __DIR__ . '/../logs/build-123'];
if (!is_string($logId) || !isset($directories[$logId])) {
throw new InvalidArgumentException('Unknown log identifier.');
}
$streamer = new SecureTarStreamer([__DIR__ . '/../logs']);
$streamer->send($directories[$logId], $logId . '.tar');
error_log('Archive generation completed.');
} catch (Throwable $error) {
error_log('Archive generation failed: ' . $error->getMessage());
if (!headers_sent()) {
header_remove('Content-Disposition');
http_response_code($error 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";
}
// After headers, adding an error message would contaminate the archive bytes.
}
Aufrufe von proc_open() absichern
Dieses lokale Beispiel hat keine Authentifizierung. Bevor Sie es in eine Anwendung einbinden,
müssen Sie sowohl eine Anmeldung als auch eine Berechtigung für den gewählten Build voraussetzen.
Halten Sie archivierte Verzeichnisse unter Kontrolle der Anwendung und stoppen Sie zuerst die
Prozesse, die darin schreiben. Das Auflösen des Stammverzeichnisses blockiert ein benachbartes
Verzeichnis wie logs-evil, schützt den Verzeichnisbaum aber nicht vor
Änderungen durch einen anderen Prozess. Der Befehl folgt keinen symbolischen Links; der Inhalt
der Zieldatei eines Links wird nicht aufgenommen. Verwenden Sie für dieses Beispiel gewöhnliche
Dateien und Verzeichnisse, keine Sockets oder speziellen Gerätedateien.
Speichern Sie dies als serve.sh und führen Sie
bash serve.sh aus php-tar-demo aus. Das Skript läuft im
Vordergrund; stoppen Sie es mit Strg+C. Wenn Port 8080 belegt ist, verwenden Sie
PORT=8090 bash serve.sh und setzen Sie beim Ausführen des folgenden Client-Skripts
denselben Wert für PORT. Schlägt das Binden fehl, wird das Skript mit
einem Fehler beendet.
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f public/download.php ]; then
printf 'Run serve.sh from the project directory after saving public/download.php.\n' >&2
exit 1
fi
exec env -u TAR_OPTIONS php \
-d memory_limit=32M -d output_buffering=0 -d zlib.output_compression=0 \
-d display_errors=0 -d log_errors=1 \
-S "127.0.0.1:${PORT:-8080}" -t public
Der integrierte Server von PHP ist für die lokale Entwicklung
gedacht. Deaktivieren Sie bei einem bestehenden PHP-FPM-Deployment auch die PHP-Ausgabepufferung
und Komprimierung, lassen Sie display_errors ausgeschaltet und konfigurieren Sie
Zeitlimits für Anfragen und Upstream-Verbindungen. Die PHP-Funktion
flush() kann nicht jeden nachgelagerten
Puffer außer Kraft setzen. Verwenden Sie bei Nginx für diese Route
fastcgi_buffering off und fastcgi_ignore_client_abort off. Nginx erkennt außerdem den
Header X-Accel-Buffering: no des Hilfsskripts,
sofern es nicht so konfiguriert ist, dass es ihn ignoriert. Ein blockierter Lesevorgang im
Dateisystem oder Schreibvorgang in der Ausgabe kann die Aufräumarbeiten von PHP weiterhin
verzögern. Betrachten Sie diese Schleife nicht als verbindliche Grenze für die verstrichene Zeit
oder als ausfallsicheren Hintergrundjob.
Prüfen, was der Client tatsächlich erhalten hat
Speichern Sie dies als verify.sh. Führen Sie in einem zweiten Terminal in
php-tar-demo den Befehl bash verify.sh aus.
Das Skript lädt das Archiv herunter, prüft die exakte Liste der Einträge, extrahiert die lokale
Fixture und vergleicht alle drei Dateien mit ihren Originalen. Es erstellt
downloaded/ einmalig und verweigert bei erneuter Ausführung das Überschreiben.
Falls ein Schritt fehlschlägt, bleibt dieses Verzeichnis zur Untersuchung bestehen, möglicherweise
mit einem unvollständigen Archiv. Verwenden Sie für einen weiteren Durchlauf ein neues, leeres
Projekt.
#!/usr/bin/env bash
set -euo pipefail
mkdir downloaded
curl -fsS --max-time 30 \
"http://127.0.0.1:${PORT:-8080}/download.php?log_id=build-123" \
--output downloaded/build.tar
tar -tf downloaded/build.tar | LC_ALL=C sort > downloaded/members.txt
printf './\n./build.log\n./empty.log\n./payload.bin\n' | cmp - downloaded/members.txt
tar -xf downloaded/build.tar -C downloaded
cmp logs/build-123/build.log downloaded/build.log
cmp logs/build-123/empty.log downloaded/empty.log
cmp logs/build-123/payload.bin downloaded/payload.bin
printf 'All expected members and bytes match.\n'
Die letzte Zeile erscheint erst, wenn die Vergleiche erfolgreich waren. Nur den Archivinhalt aufzulisten, ist eine weniger aussagekräftige Prüfung. GNU tar kann das Schreiben eines Archivs abschließen und dabei einen Lesefehler melden; der nicht lesbare Eintrag fehlt dann. Die dokumentierten Exit-Statuswerte unterscheiden zwischen erfolgreicher Erstellung, geänderten Eingaben und Fehlern. Der Status des erzeugenden Prozesses ist jedoch nicht im HTTP-Antwortkörper enthalten.
Wenn beispielsweise der PHP-Worker payload.bin nicht lesen kann, kann die Route
einen tar-Fehler protokollieren, während cURL erfolgreich endet und
tar -tf die übrigen Einträge erfolgreich auflistet. Der obige Vergleich der
Eintragsliste erkennt die Auslassung, weil er eine unabhängige Erwartung hat. Bei einem Export
an einen entfernten Empfänger benötigt dieser ein unabhängig bereitgestelltes Verzeichnis der
Dateien und Inhalts-Hashes, um diese Art der Prüfung durchzuführen. Ein aus dem empfangenen Archiv
abgeleitetes Dateiverzeichnis beweist nichts darüber, ob Quelldateien fehlen.
Was passiert bei unterbrochenen Downloads?
Eine unterbrochene Verbindung kann eine unvollständige tar-Datei hinterlassen. Ein später Fehler
im erzeugenden Prozess kann dagegen ein lesbares, aber unvollständiges Archiv hinterlassen.
Sobald PHP Header gesendet hat, verhindert
headers_sent(), dass der Handler HTTP 200
durch einen Fehlerstatus ersetzt. Der Handler protokolliert den Fehler und fügt dem Archiv keinen
Text hinzu. Er kann nicht garantieren, dass cURL oder ein Browser einen fehlgeschlagenen Download
meldet.
Das Hilfsskript prüft connection_aborted()
nach dem Schreiben, schließt dann die Pipe und fordert tar zum Beenden
auf. Die Erkennung hängt davon ab, ob PHP und der Webserver den Verbindungsabbruch bemerken.
Selbst ein serverseitiger Protokolleintrag zum Abschluss bedeutet nur, dass die Erstellung ohne
erkannten Übertragungsfehler beendet wurde, nicht, dass der Client die Datei gespeichert hat.
Dieser Endpunkt implementiert keine HTTP-Range-Anfragen. Ein erneuter Versuch startet ein neues Archiv, dessen Bytes sich unterscheiden können, wenn sich die Quelle geändert hat. Zum Fortsetzen muss dieselbe stabile Archivrepräsentation ausgeliefert werden; der sequenzielle Aufbau von tar schließt HTTP-Byte-Bereiche an sich nicht aus.
Fortschritt aus demselben Job melden
Ein zweiter Prozess tar -v misst einen anderen Durchlauf, selbst wenn er
dasselbe Verzeichnis liest. Verwenden Sie ihn nicht als Fortschritts- oder Abschlusssignal für den
Download. Ein separater SSE-Endpunkt benötigt eine Job-Kennung und einen gemeinsamen Zustand, den
der tatsächlich erzeugende Prozess schreibt. Halten Sie den Erstellungsstatus eines Jobs vom
Übertragungsstatus des Clients getrennt. Dieses synchrone Beispiel hat weder einen
Fortschrittsspeicher noch eine Empfangsbestätigung des Clients für den Abschluss.
Ansätze vergleichen
Direktes Streaming eignet sich für Downloads, bei denen es wichtig ist, das temporäre Speichern des Archivs zu vermeiden, und bei denen die Aufrufenden die Einschränkung bei späten Fehlern kennen. Für ein Backup oder einen Export, der Erstellungsfehler vor Beginn des Downloads melden muss, erzeugen Sie das Archiv in einer privaten temporären Datei, prüfen Sie den Exit-Status des erzeugenden Prozesses und veröffentlichen Sie nur ein erfolgreiches Ergebnis. Damit bleibt der PHP-Arbeitsspeicherbedarf begrenzt, aber es wird Speicherplatz für das Archiv benötigt.
Liefern Sie die fertige, unveränderliche Datei mit ihrer bekannten Länge und einer Prüfsumme aus, wenn Empfänger die Integrität der Übertragung prüfen oder sie fortsetzen müssen. Den erzeugenden Prozess zuerst zu prüfen, löst das zeitliche Problem bei Erstellungsfehlern; es macht aus sich ändernden Quelldateien jedoch keinen konsistenten Snapshot. Frieren Sie die Eingaben ein oder verwenden Sie einen Dateisystem-Snapshot, wenn diese Konsistenz zum zugesicherten Umfang des Exports gehört.
