Repository navigation
Stream zip file to S3 using multi-part upload #402
|
Hello, I have been banging my head all week with a limitation of the S3 compatible storages. I have to generate a zip file containing thousands of files already stored in an S3 bucket. My code looks a lot like the one shown by Chris White in this wonderful post, which I copy here as reference (hope is okay by him): <?php
require './vendor/autoload.php';
use Aws\S3\S3Client;
use ZipStream\ZipStream;
use ZipStream\CompressionMethod;
$s3 = new S3Client([
'region' => 'ca-central-1',
'version' => '2006-03-01',
]);
// We need to call this method to allow PHP streams to work with the `s3://` protocol.
$s3->registerStreamWrapper();
// We define our list of files that we want to export into a ZIP file. Real production
// code would define this dynamically, we're just hard-coding it for an example.
$files = [
new class {
public string $name = 'test_photo.jpeg';
public string $path = 's3://zipstream-testing/test_photo.jpeg';
},
new class {
public string $name = 'test_photo2.png';
public string $path = 's3://zipstream-testing/test_photo2.png';
},
new class {
public string $name = 'test_video.mov';
public string $path = 's3://zipstream-testing/test_video.mov';
},
new class {
public string $name = 'test_video2.mov';
public string $path = 's3://zipstream-testing/test_video2.mov';
},
];
// We open a stream to the destination location of the ZIP export. In this case we're
// just placing it into the same bucket alongside the files we're exporting, but this
// could go to another S3 bucket, or anywhere else that you can fopen().
$archiveDestination = fopen('s3://zipstream-testing/archive.zip', 'w');
$zip = new ZipStream(
// Configures the library to stream its output to our destination archive.
outputStream: $archiveDestination,
// We turn off ZIP compression to make the export faster. Omit this parameter
// entirely if you do want to compress the resulting ZIP file.
defaultCompressionMethod: CompressionMethod::STORE,
// This parameter is required to work with remote streams.
defaultEnableZeroHeader: true,
// We're not sending the ZIP output to the user's browser, but back to S3.
sendHttpHeaders: false,
);
// Iterate over each of our files, fopen() them, add them to the ZIP, then close the open stream.
foreach ($files as $file) {
$fileStream = fopen($file->path, 'r');
$zip->addFileFromStream($file->name, $fileStream);
fclose($fileStream);
}
// Finally, finish the ZIP file which will write the required footer data and close the stream
// to our destination archive.
$zip->finish();
fclose($archiveDestination);
echo sprintf("Peak memory usage: %d MiB", (memory_get_peak_usage(true)/1024/1024));As it turns out, any storage that I tried, i.e., Wasabi and Cloudflare's R2, have a limitation of 5 GiB when using single upload. AWS S3's has this limitation too. The thing is, the compressed files I generated using ZipStream are larger than 20 GiB. My commands work, but the file is silently discarded by the storage and never shown, due to the limitation. However, when using Cloudflare's R2 multi-part the file size limit is of 4.995 TiB. In S3 the limit is of 48.8 TiB. Therefore, my question is if there exists a way to use a multi-part upload with ZipStream to compress the files directly in the storage, without touching my server. Right now, my solution has been to split the zip files in batches that not go over 5 GiB, but I would like to have only one zip, if possible. Thank you! |
Replies: 2 comments 9 replies
|
@rubenrubiob This anwer is very theoretical, I’m not aware of anyone ever trying this. Theoretically, you can use any php resource or psr stream as a target for the archive. This should allow you to create an output stream which starts a multipart upload, then chunk the outputs by the cunk sizes, upload them individually and finish the multipart upload once the zip is finished. I would love to have an example for this in the docs as well. |
|
Well, it looks like I got it. Thanks to your help @maennchen it was easier than I thought it would be. I found an AWS low level multipart upload example in the official documentation and that helped me to know how to use the required commands. Following your advice, I implemented a <?php
declare(strict_types=1);
use Aws\S3\S3ClientInterface;
use Psr\Http\Message\StreamInterface;
use function strlen;
use function substr;
final class MultipartUploadBufferStream implements StreamInterface
{
private const int PART_SIZE = 5242880; // 5 MiB in bytes
private string $buffer = '';
private int $bufferSize = 0;
private int $partNumber = 1;
private array $parts = [];
private string $uploadId;
public function __construct(
private readonly string $destinationFileName,
private readonly string $bucket,
private readonly S3ClientInterface $client
) {
$result = $this->client->createMultipartUpload(
[
'Bucket' => $bucket,
'Key' => $destinationFileName,
'StorageClass' => 'REDUCED_REDUNDANCY',
]
);
$this->uploadId = $result['UploadId'];
$this->parts['Parts'] = [];
}
public function write($string): int
{
$chunkSize = strlen($string);
$this->buffer .= $string;
$this->bufferSize += $chunkSize;
if ($this->bufferSize >= self::PART_SIZE) {
$this->uploadPart();
}
return $chunkSize;
}
public function close(): void
{
// Upload remaining closing bytes from zip
$this->uploadPart();
$this->client->completeMultipartUpload([
'Bucket' => $this->bucket,
'Key' => $this->destinationFileName,
'UploadId' => $this->uploadId,
'MultipartUpload' => $this->parts,
]);
$this->buffer = '';
$this->bufferSize = 0;
}
private function uploadPart(): void
{
$result = $this->client->uploadPart([
'Bucket' => $this->bucket,
'Key' => $this->destinationFileName,
'UploadId' => $this->uploadId,
'PartNumber' => $this->partNumber,
'Body' => $this->buffer,
]);
$this->buffer = '';
$this->bufferSize = 0;
$this->parts['Parts'][$this->partNumber] = [
'PartNumber' => $this->partNumber,
'ETag' => $result['ETag'],
];
$this->partNumber++;
}
public function __toString(): string
{
return $this->getContents();
}
public function getContents(): string
{
$buffer = $this->buffer;
$this->buffer = '';
$this->bufferSize = 0;
return $buffer;
}
public function detach(): null
{
$this->close();
return null;
}
public function getSize(): int
{
return $this->bufferSize;
}
public function isReadable(): bool
{
return true;
}
public function isWritable(): bool
{
return true;
}
public function isSeekable(): bool
{
return false;
}
public function rewind(): void
{
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
{
throw new \RuntimeException('Cannot seek a BufferStream');
}
public function eof(): bool
{
return $this->bufferSize === 0;
}
public function tell(): int
{
throw new \RuntimeException('Cannot determine the position of a BufferStream');
}
public function read($length): string
{
$currentLength = $this->bufferSize;
if ($length >= $currentLength) {
// No need to slice the buffer because we don't have enough data.
$result = $this->buffer;
$this->buffer = '';
$this->bufferSize = 0;
} else {
// Slice up the result to provide a subset of the buffer.
$result = substr($this->buffer, 0, $length);
$this->buffer = substr($this->buffer, $length);
$this->bufferSize -= $length;
}
return $result;
}
public function getMetadata($key = null)
{
return $key ? null : [];
}
}The implementation is based on Guzzle's BufferStream. Key points are:
This implementation is tightly coupled to S3. I have not thought about decoupling it, but it might be okay, as it is a concrete implementation. I have not yet checked for exceptions and retries, but it is something that should be done. With this implementation in place, we can use it with <?php
declare(strict_types=1);
$client = new S3Client([
'region' => 'ca-central-1',
'version' => '2006-03-01',
]);
$bucketName = 'zip-example';
$this->client->registerStreamWrapper();
$files = return [
[
'name' => 'dummy-01.dat',
'path' => 's3://zip-example/zip-test/dummy-01.dat',
],
[
'name' => 'dummy-02.dat',
'path' => 's3://zip-example/zip-test/dummy-02.dat',
],
[
'name' => 'dummy-03.dat',
'path' => 's3://zip-example/zip-test/dummy-03.dat',
],
[
'name' => 'dummy-04.dat',
'path' => 's3://zip-example/zip-test/dummy-04.dat',
],
[
'name' => 'dummy-05.dat',
'path' => 's3://zip-example/zip-test/dummy-05.dat',
],
[
'name' => 'dummy-06.dat',
'path' => 's3://zip-example/zip-test/dummy-06.dat',
],
[
'name' => 'dummy-07.dat',
'path' => 's3://zip-example/zip-test/dummy-07.dat',
],
];
$destination = new MultipartUploadBufferStream(
'zip-test/archive-multipart.zip',
$bucketName,
$client,
);
$zip = new ZipStream(
outputStream: $destination,
defaultCompressionMethod: CompressionMethod::STORE,
defaultEnableZeroHeader: true,
sendHttpHeaders: false,
);
foreach ($this->files() as $file) {
$fileStream = fopen($file['path'], 'r');
$zip->addFileFromStream($file['name'], $fileStream);
fclose($fileStream);
}
$zip->finish();
$destination->close();
echo sprintf("Peak memory usage: %d MiB", (memory_get_peak_usage(true) / 1024 / 1024));My understanding was that I did a test. I generated 7 dummy files of 1 GiB and compressed them into one, using Wasabi as a storage. It worked perfectly. However, the peak memory usage was of @maennchen Do you think this would work as an example for the documentation? I could try to generate something more adequate, if you want to. In any case, I thank you for your help and for your work in this library! |

Well, it looks like I got it. Thanks to your help @maennchen it was easier than I thought it would be.
I found an AWS low level multipart upload example in the official documentation and that helped me to know how to use the required commands.
Following your advice, I implemented a
StreamInterfacethat, whenever it reaches a limit, uploads a part within that multipart upload. Here is the code: