skoerfgen/ACMECert

memory leak

Closed this issue · 3 comments

thk12 commented

i don't know where but there must be an memory leak when u try to get a lot of certificates in a loop.

code example test.php to reproduce,

require 'ACMECert/ACMECert.php';
use skoerfgen\ACMECert\ACMECert;

for($i=0;$i<100;$i++) {

	$ac=new ACMECert('https://acme-staging-v02.api.letsencrypt.org/directory');
	$ac->loadAccountKey('file://'.'account_key.pem');

	$hostname=substr(md5(uniqid().microtime()),0,5).'.hostname.tld';

	$private_host_key=$ac->generateECKey('P-384');
	file_put_contents("cert_private_key.$hostname.pem",$private_host_key);

	$domain_config=array(
		$hostname=>array('challenge'=>'http-01','docroot'=>'/var/www/vhosts/wildcard.hostname.tld/')
	);

	$handler=function($opts){
		$fn=$opts['config']['docroot'].$opts['key'];
		@mkdir(dirname($fn),0777,true);
		file_put_contents($fn,$opts['value']);
		return function($opts){
			unlink($opts['config']['docroot'].$opts['key']);
		};
	};

	$fullchain=$ac->getCertificateChain('file://'."cert_private_key.$hostname.pem",$domain_config,$handler);
	file_put_contents("fullchain.$hostname.pem",$fullchain);

	echo "$i: memory_get_usage=".(memory_get_usage()/1024)."kB\n";
	
}

example output of a cli php test.php | grep memory_get_usage using php8.2

memory_get_usage=579.96875kB
memory_get_usage=597.1328125kB
memory_get_usage=614.28125kB
memory_get_usage=631.4296875kB
memory_get_usage=648.578125kB
memory_get_usage=665.7265625kB
memory_get_usage=682.875kB
memory_get_usage=700.0234375kB
memory_get_usage=717.171875kB
memory_get_usage=734.3203125kB
memory_get_usage=751.46875kB
memory_get_usage=768.6171875kB
memory_get_usage=785.765625kB
memory_get_usage=802.9140625kB

i know the PHP GC cleans it up, but the script itself should do it

Thank you for reporting this issue!

At first I did not find the reason for this behaviour, but then I realized that the destructor of each ACMECert instance only gets called at the end of the script. (Instead of on each iteration of the for loop)

Here is the code I used:

require 'ACMECert/ACMECert.php';

//use skoerfgen\ACMECert\ACMECert;
class ACMECert extends skoerfgen\ACMECert\ACMECert {
	function __destruct(){
		parent::__destruct();
		echo '[DESTRUCT]',"\n";
	}
}

for($i=0;$i<3;$i++) {

	$ac=new ACMECert('https://acme-staging-v02.api.letsencrypt.org/directory');
	$ac->loadAccountKey('file://'.'account_key.pem');

	$hostname=substr(md5(uniqid().microtime()),0,5).'.hostname.tld';

	$private_host_key=$ac->generateECKey('P-384');
	file_put_contents("cert_private_key.$hostname.pem",$private_host_key);

	$domain_config=array(
		$hostname=>array('challenge'=>'http-01','docroot'=>'/var/www/vhosts/wildcard.hostname.tld/')
	);

	$handler=function($opts){
		$fn=$opts['config']['docroot'].$opts['key'];
		@mkdir(dirname($fn),0777,true);
		file_put_contents($fn,$opts['value']);
		return function($opts){
			unlink($opts['config']['docroot'].$opts['key']);
		};
	};

	$fullchain=$ac->getCertificateChain('file://'."cert_private_key.$hostname.pem",$domain_config,$handler);
	file_put_contents("fullchain.$hostname.pem",$fullchain);

	echo "$i: memory_get_usage=".(memory_get_usage()/1024)."kB\n";
	
}

relevant output:

0: memory_get_usage=593.90625kB
1: memory_get_usage=611.5859375kB
2: memory_get_usage=629.859375kB
[DESTRUCT]
[DESTRUCT]
[DESTRUCT]

However after some debugging I found the problem:

The function in ACMECert to get the HTTP headers holds a reference to the current ACMECert class, which prevents the garbage collector from reclaiming it.

From the PHP documentation:

When declared in the context of a class, the current class is automatically bound to it, making $this available inside of the function's scope. If this automatic binding of the current class is not wanted, then static anonymous functions may be used instead.

https://www.php.net/manual/en/functions.anonymous.php

So if I change the file src/ACMEv2.php on line 303:

from:

CURLOPT_HEADERFUNCTION=>function($ch,$header)use(&$headers){
	$headers[]=$header;
	return strlen($header);
}

to:

CURLOPT_HEADERFUNCTION=>static function($ch,$header)use(&$headers){
	$headers[]=$header;
	return strlen($header);
}

I get the following output:

0: memory_get_usage=593.90625kB
[DESTRUCT]
1: memory_get_usage=594.0078125kB
[DESTRUCT]
2: memory_get_usage=594.078125kB
[DESTRUCT]

This should solve the problem. Can you confirm this?

thk12 commented

can confirm! works like a charm with that fix! didn't know that either

ACMECert v3.2.2 has been released! It contains this fix.

https://github.com/skoerfgen/ACMECert/releases/tag/v3.2.2