Looking for PowerObjects? Don’t worry, you’re in the right place! We’ve been part of HCL for several years, and we’ve now taken the final step in our acquisition journey: moving our website to the HCL domain. Nothing else is changing – we are still fanatically focused on Microsoft Business Applications!

PowerObjects Blog 

for Microsoft Business Applications


Memory Consumption in Azure Functions

Post Author: Joe D365 |

The story behind this blogpost started from an exception we got when testing an interface - System.OutOfMemoryException. The goal of the interface was to take a ZIP file as an input, read the data from the archived files, and import customer payment journals into Dynamics 365 for Finance. It was using an Azure Function for unzipping an archive on Azure File Storage. Obviously, the issue only started to surface when testing with larger files. Let’s describe what happened… 

But it works on my machine 

Running the same function with the same file locally works just fine. That is normal because most development environments have enough RAM to unzip files. However, having enough computing resources is not a good enough excuse for writing sub-optimal code. It turns out that the function was using a library that was reading the ZIP file from Azure into a MemoryStream object and then also returning a MemoryStream object for each file.  

Azure Functions can only use 1.5GB RAM 

According to Microsoft, Azure Functions have a limit of one CPU and 1.5 GB of RAM per instance. That is much less than a typical DEV environment. We have put this to test using the code below. 

static long MemoryTest(ILogger log, bool touch = false) 
        { 
            byte[][] allData = new byte[30][]; 
 
            try 
            { 
                for (int i = 0; i < allData.Length; ++i) 
                { 
                    allData[i] = new byte[BUF_SIZE]; 
                    if (touch) 
                    { 
                        for (int j = 0; j < BUF_SIZE; ++j) 
                        { 
                            allData[i][j] = (byte)(j % 1024); 
                        } 
                    } 
 
                    log.LogInformation($"{i + 1}: Allocated 100MB"); 
                } 
 
            } catch (Exception ex) 
            { 
                log.LogError(ex.Message); 
                log.LogError(ex.StackTrace); 
            } 
 
            Process proc = Process.GetCurrentProcess(); 
             
            log.LogInformation($"Touch: {touch}"); 
            log.LogInformation($"Working set: {FormatBytesToGB(proc.WorkingSet64)} GB"); 
            log.LogInformation($"Virtual memory: {FormatBytesToGB(proc.VirtualMemorySize64)} GB"); 
            log.LogInformation($"Private memory: {FormatBytesToGB(proc.PrivateMemorySize64)} GB"); 
            
            return proc.WorkingSet64; 
        } 
  

We can see that, locally, we are able to allocate 30 chunks of 100MB and the maximum memory consumption is, as expected, more than 3GB. However, the environment used has 8GB of RAM available, much more than an instance of Azure Functions that run in containers with restricted resources. 

allocating memory

Figure 3. Just allocating memory - Running locally 

Figure 4. Allocating and touching memory - Running locally 

When running the same function in Azure, we can see that the memory allocation loop breaks after 14 iterations and throws the OutOfMemoryException. 

It is probably worthwhile stopping for a bit to discuss the importance of a non-functional requirement that is not always given the necessary importance – debuggability. Having applications that properly surface errors is extremely important for troubleshooting issues in production instances, as they cannot be debugged in any other way. A common anti-pattern is for applications to "swallow" exceptions, meaning that they are caught but not surfaced fully to Application Insights.  

Digression - types of memory 

Microsoft doesn't explicitly say what the 1.5GB of available memory for consumption-based Azure Functions is, but it seems to refer to the private memory, or memory that cannot be shared with other processes. That is different from the Working Set, which is what Task Manager shows, and refers to memory that resides in the physical RAM. Mark Russinovich does a deep dive into the various jargon used when discussing Windows memory. In the context of our .NET Core Azure Function, the Garbage Collector may also play a role, but the test code we've written deals only with memory for which we have active references, so what we allocate is surely not collected until the method returns. 

Locally, by just allocating memory, when the "touch" flag is false, the working set (seen in Task Manager) does not increase a lot. It does when also touching the memory by assigning a value to it.  

Regardless of whether the memory is "touched" or not, on Azure, the application always fails after about 13-14 allocations of 100MB, which is in line with the specifications. We have to bear in mind that the compiled code we write and some of the referenced DLLs are also loaded in the heap of the function process. It is also less confusing for developers as they know that the limit refers to the memory you have allocated, which is easier to track than memory accessed. 

Figure 1. Just allocating memory, no touching – In Azure 

Figure 2. Allocating and touching memory - In Azure 

Solution - using streams 

The original code, which cached the whole ZIP file in memory when extracting, may offer better performance if memory is not restricted, but in our scenario, using it from Azure Functions, we'll need to open the archive from a stream and also unzip each archive entry into a target Azure File by copying streams. 

The ZipArchive class is smart enough to be able to list the ZIP contents and extract files without loading the whole archive in memory. 

   static async Task UnzipWithStreams(ILogger log, CloudFileClient fileClient, string shareName, string fileName) 
        { 
            try 
            {  
                 
                CloudFileShare share = fileClient.GetShareReference(shareName); 
                CloudFileDirectory dir = share.GetRootDirectoryReference(); 
                CloudFile file = dir.GetFileReference(fileName); 
                            
                if (await file.ExistsAsync()) 
                {                 
                    ZipArchive archive = new ZipArchive(await file.OpenReadAsync(), ZipArchiveMode.Read); 
 
                    foreach (var e in archive.Entries) 
                    { 
                        log.LogInformation($"Extracting file: {e.FullName}, size: {e.Length:N0} bytes"); 
                        Stream compressedStream = e.Open(); 
                        CloudFile target = dir.GetFileReference(e.Name); 
                                  
                        Stream ts = e.Open(); 
                        CloudFileStream cfs = await target.OpenWriteAsync(e.Length); 
 
                        await compressedStream.CopyToAsync(cfs); 
                    } 
                } 
                else 
                { 
                    log.LogError($"File {fileName} not found in share {shareName}"); 
                } 
            } 
            catch (Exception ex) 
            { 
                log.LogError(ex.Message); 
                log.LogError(ex.StackTrace); 
            }  
        } 
 

Don't forget to subscribe to our blog and happy Dynamics 365'ing!

Joe CRM
By Joe D365
Joe D365 is a Microsoft Dynamics 365 superhero who runs on pure Dynamics adrenaline. As the face of PowerObjects, Joe D365’s mission is to reveal innovative ways to use Dynamics 365 and bring the application to more businesses and organizations around the world.

Leave a Reply

Your email address will not be published. Required fields are marked *

PowerObjects Recommends