Transmite archivos tar en PHP sin límites de memoria
Crear archivos tar de gran tamaño en PHP puede volverse complicado en cuanto tu conjunto de datos
supera unos cuantos cientos de megabytes. Aunque la clase PharData de PHP está orientada a streams
en muchas operaciones, el proceso en conjunto sigue ejecutándose dentro del memory_limit configurado de
PHP. Al archivar una enorme cantidad de archivos o archivos individuales muy grandes, puedes
encontrarte con el error «Allowed memory size exhausted», sobre todo con límites predeterminados
como 128M. Por suerte, podemos invocar la utilidad tar del sistema con proc_open() y dejar que el
sistema operativo haga el trabajo pesado. El resultado es un stream de memoria constante que puedes
canalizar directamente al navegador, al almacenamiento de objetos o a otro proceso, lo que permite
un streaming de tar eficiente en PHP.
Comprende los límites de memoria de PHP con PharData
Aunque PharData es eficiente en muchas operaciones, puede toparse con restricciones de memoria con
archivos tar muy grandes o al realizar operaciones masivas. La limitación principal proviene de la
configuración memory_limit de PHP y no de PharData en sí. Por ejemplo, el uso de búferes, el manejo de
metadatos y la memoria de opcodes pueden contribuir en conjunto a superar ese límite cuando se
manejan miles de archivos o archivos excepcionalmente grandes.
Si solo necesitas un archivo tar que se escribe una vez y nunca se lee, a menudo no hay un beneficio
significativo en mantener toda la operación dentro del proceso de PHP. Delegar el trabajo al comando
tar del sistema te libera de las limitaciones de memoria de PHP y aporta capacidades de streaming
de forma inherente. Eso lo convierte en una excelente solución para crear archivos tar con un uso
eficiente de la memoria.
Transmite la salida tar con proc_open()
proc_open() inicia un comando externo y expone sus streams de entrada, salida y error estándar como
recursos de PHP. La siguiente función auxiliar lee lo que el comando tar escribe en su stdout y
envía los bytes directamente al cliente, lo que mantiene constante el uso de memoria; ideal para una
descarga tar en 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}");
}
}
Esta función nunca deja el archivo tar en una etapa intermedia: no hay archivo temporal ni un búfer
que crezca en PHP. Los bytes pasan de la stdout de tar al cliente en fragmentos de 8 KB, así que
el uso de memoria de PHP se mantiene plano sin importar el tamaño del archivo tar. La función
auxiliar vacía todos los búferes de salida anidados de PHP antes de empezar; si un framework lo
impide, falla de forma explícita en lugar de almacenar la descarga en búfer. El uso de búferes del
servidor web y del proxy requiere una configuración aparte. tar sigue leyendo los archivos de
origen desde el disco, y las rutas de los miembros del archivo tar son relativas al directorio
seleccionado, en lugar de incluir su ruta completa en el sistema de archivos.
Hay dos supuestos integrados. str_starts_with() requiere PHP 8.0+, y /dev/null más un tar POSIX
que entienda -cf - y -- implica Unix; tanto GNU tar como bsdtar cumplen, Windows no.
También hay una limitación que conviene señalar de forma explícita: el archivo tar solo es válido
cuando tar termina con el estado 0, y eso solo se sabe después de que se ha enviado el último
byte. Una vez que los bytes se han vaciado, el estado de la respuesta ya va en camino y no se puede
retractar, de modo que el fallo se propaga a quien hizo la llamada y se registra en el servidor,
mientras que el cliente ve un archivo tar truncado que tiene que detectar por su cuenta. Si
necesitas que el fallo sea inequívoco, prepara primero el archivo tar en un archivo temporal y
sírvelo solo después, cambiando memoria constante por espacio en disco.
Archiva registros de CI/CD en tiempo real
Los servidores de integración continua (CI/CD) suelen generar numerosos archivos de registro
pequeños, buenos candidatos para el archivado en tiempo real con streaming en PHP. Los registros de
compilación son confidenciales, así que todo lo que sigue asume que se ejecuta después de la
autenticación existente de tu framework y de la autorización por proyecto: la ruta ya debe haber
establecido quién es quien llama y que puede leer los registros de este proyecto. Aquí no hay
ninguna descarga pública de registros, y este DevTip no pretende mostrarte un sistema de
autorización. La tarea propia del fragmento de código es más acotada: asignar un identificador opaco
a una ruta, confirmar que esa ruta está dentro de la raíz permitida y transmitirla. Guarda el primer
ejemplo como tar-streaming.php y cárgalo con require_once antes de usar cualquiera de los siguientes ejemplos;
todos comparten sus funciones auxiliares.
<?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";
}
}
Informa el progreso con eventos enviados por el servidor
Al archivar miles de archivos, la retroalimentación de progreso mejora la experiencia. Dado que
tar -v imprime cada nombre de archivo en stderr a medida que lo procesa, esas líneas se pueden
transmitir como eventos enviados por el servidor (Server-Sent Events, SSE).
Ten claro de qué se trata: una segunda ejecución independiente de tar cuya salida de archivo
tar se descarta. Informa el progreso de un archivo tar equivalente, no de la descarga de la sección
anterior. Son dos procesos separados sin estado compartido, y correlacionarlos requeriría un ID de
trabajo más un almacén de progreso al que puedan llegar ambas solicitudes, algo que este ejemplo no
implementa. Úsalo como una estimación previa, no como una barra de progreso para una descarga en
curso.
<?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();
}
El cliente consume este stream mediante la API EventSource de JavaScript. Trata cada valor file como
una línea de progreso opaca: stderr también contiene diagnósticos, y GNU tar y bsdtar formatean las
entradas de manera distinta. El cliente debe tratar error y complete como mutuamente
excluyentes: llega exactamente uno de los dos, y solo complete significa que el archivo tar habría sido
válido.
Refuerza las llamadas a proc_open()
Llamar a utilidades del shell desde PHP siempre abre la puerta a errores de inyección de comandos si no se maneja con cuidado. Ten presentes estas reglas:
- Valida y sanea las entradas: valida siempre cualquier dato proporcionado por el usuario. Para
las rutas de archivo, resuélvelas con
realpath()para obtener la ruta absoluta canónica y comprobar que existe. - Usa una lista de permitidos de directorios: mantén una lista estricta de directorios desde los que se permiten las operaciones. Rechaza cualquier ruta que no se ajuste a esa lista.
- Escapa los argumentos del shell: es fundamental que escapes cada argumento que pases a los
comandos del shell con
escapeshellarg()oescapeshellcmd(), según corresponda. Por lo general se prefiereescapeshellarg()para los argumentos individuales. Nunca concatenes entrada sin procesar directamente en una cadena de comando del shell. - Principio de mínimo privilegio: ejecuta el proceso de PHP (y del servidor web) con los
privilegios mínimos necesarios. Evita ejecutarlo como
root. El usuario solo debe tener acceso de lectura a los directorios que se archivan y permiso de ejecución para la utilidadtar. - Comprueba los códigos de salida y elige una sola estrategia para stderr: usa
proc_close()para esperar a que termine y recoger el código de salida, como se muestra aquí. Si añades sondeo conproc_get_status(), ten en cuenta la versión de PHP: antes de PHP 8.3 solo su primera llamada tras la salida devolvía el código real; las llamadas posteriores devolvían-1. PHP 8.3 añadió el almacenamiento en caché del código de salida. En el caso destderr, o bien lo descartas (como hace la función auxiliar de descarga) o bien lo drenas de forma concurrente con un límite de tamaño estricto. Leerlo solo después de la transferencia significa que un comando locuaz llena el búfer de la tubería de 64 KiB y se bloquea para siempre, y almacenarlo por completo en búfer te devuelve el consumo de memoria ilimitado que querías evitar al pasar al streaming. - Manejo de errores: implementa un manejo de errores robusto alrededor de las llamadas a
proc_open()para gestionar escenarios como que no se encuentre el comando, que no consiga iniciarse o que termine con un error.
El ejemplo de la clase SecureTarStreamer muestra un buen enfoque al encapsular la validación de rutas
y las comprobaciones de la lista de permitidos:
<?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.
Compara los enfoques
Las características de rendimiento varían según el caso de uso:
PharData: óptimo para manipular archivos tar (leer o escribir archivos individuales dentro de un archivo tar) cuando los límites de memoria no son un problema, o para archivos tar más pequeños.- streaming con
proc_open+tar: la mejor opción para crear archivos tar grandes con un uso mínimo de memoria de PHP (streaming de archivos tar en PHP), sobre todo en escenarios de escritura única como copias de seguridad o archivado de registros. Es un método tar con un uso de memoria muy eficiente. - Robot de Transloadit: ideal para un uso escalable en producción en el que quieres delegar todo el proceso. Ofrece optimización automática, admite varios formatos (tar, zip, etc.) y se integra con el almacenamiento en la nube.
Elige el método que mejor se ajuste a tu carga de trabajo: acceso aleatorio a archivos, streaming en tus propios servidores o compresión en la nube totalmente gestionada.
¿Y las descargas interrumpidas?
El formato tar es secuencial, así que las reanudaciones reales por rango de bytes no se admiten de forma nativa en una descarga HTTP sencilla sin herramientas de terceros o lógica del lado del servidor capaz de indexar el stream. En la práctica, es posible que los clientes no siempre manejen con elegancia las interrupciones de descargas de varios gigabytes. Si la reanudación es crítica para tu aplicación:
- Divide los datos: considera dividir los datos en varios archivos tar más pequeños. Así los clientes pueden descargarlos de forma individual, y un fallo solo afecta a una parte.
- Protocolos de transporte reanudables: usa un mecanismo de transporte reanudable. Una vez
producido el archivo tar (aunque primero se transmita a un archivo local temporal o se canalice
directamente), puedes servirlo o subirlo con protocolos como
tus.ioo aprovechar funciones como las subidas multiparte de S3 si el destino es almacenamiento en la nube. - Deja que Transloadit se encargue: la plataforma de Transloadit está diseñada para un procesamiento y una entrega de archivos robustos, y gestiona de forma inherente muchas de las complejidades de las transferencias de archivos grandes.
Conclusión
Usar proc_open() junto con la utilidad tar del sistema es un patrón sencillo pero potente para
crear archivos tar grandes en PHP con un consumo de memoria casi nulo dentro del propio proceso de
PHP. Esta técnica resulta especialmente eficaz para tareas como archivar registros de CI/CD con PHP,
crear copias de seguridad nocturnas o manejar cualquier conjunto de datos grande que deba escribirse
una vez y transmitirse de forma eficiente. Es una forma estupenda de lograr tar sin límites de
memoria en PHP.
¿Necesitas algo aún más automático? El Robot 🤖 /file/compress
de Transloadit puede crear archivos .tar (con gzip opcional) por ti. Una
Assembly mínima se ve así:
{
"steps": {
"compressed": {
"robot": "/file/compress",
"use": ":original",
"format": "tar",
"gzip": true
}
}
}
El Robot admite tanto el formato tar como el zip, con compresión gzip opcional.
Pruébalo y deja que nuestra infraestructura se preocupe por los límites de memoria, la concurrencia
y el manejo de casos límite mientras tú te concentras en crear funcionalidades.
