0

I built an ASP.NET MVC API hosted on IIS on Windows 10 Pro (VM on Azure - 4GB RAM, 2CPU). Within I call an .exe (wkhtmltopdf) that I want to convert an HTML page to image and save it locally. Everything works fine, except I noticed that after some calls to the API, the RAM goes crazy and while investigating the process with Task Manager I saw a process, called IIS Worker Process, that adds more RAM every time the API is called. Of course I wrapped my System.Diagnostics.Process instance usage inside a using statement to be disposed, because IDisposable is implemented, but it still consumes more and more RAM and after a while the server becomes laggy and unresponsive (it has only 4GB of RAM after all). I noticed that after some number of minutes (10-15-20 maybe) this IIS Worker Process calms down in terms of RAM usage... Here is my code, pretty straight forward:

  1. Gets base64 encoded url
  2. Decodes it
  3. Uses wkhtmltoimage.exe to convert it to image
  4. Saves it locally
  5. Reads the byte array
  6. Creates a blob in Azure with the image
  7. Returns json with the url

    public async Task<ActionResult> Index(string url)
    {
        object oJSON = new { url = string.Empty };
    
        if (!string.IsNullOrEmpty(value: url))
        {
            try
            {
                byte[] EncodedData = Convert.FromBase64String(s: url);
                string DecodedURL = Encoding.UTF8.GetString(bytes: EncodedData);
    
                using (Process proc = new Process())
                {
                    proc.StartInfo.FileName = wkhtmltopdfExecutablePath;
                    proc.StartInfo.Arguments = $"--encoding utf-8 \"{DecodedURL}\" {LocalImageFilePath}";
                    proc.Start();
                    proc.WaitForExit();
    
                    oJSON = new { procStatusCode = proc.ExitCode };
                }
    
                if (System.IO.File.Exists(path: LocalImageFilePath))
                {
                    byte[] pngBytes = System.IO.File.ReadAllBytes(path: LocalImageFilePath);
    
                    System.IO.File.Delete(path: LocalImageFilePath);
    
                    string ImageURL = await CreateBlob(blobName: $"{BlobName}.png", data: pngBytes);
    
                    oJSON = new { url = ImageURL };
                }
            }
            catch (Exception ex)
            {
                Debug.WriteLine(value: ex);
            }
        }
    
        return Json(data: oJSON, behavior: JsonRequestBehavior.AllowGet);
    }
    
    private async Task<string> CreateBlob(string blobName, byte[] data)
    {
        string ConnectionString = "DefaultEndpointsProtocol=https;AccountName=" + AzureStorrageAccountName + ";AccountKey=" + AzureStorageAccessKey + ";EndpointSuffix=core.windows.net";
        CloudStorageAccount cloudStorageAccount = CloudStorageAccount.Parse(connectionString: ConnectionString);
        CloudBlobClient cloudBlobClient = cloudStorageAccount.CreateCloudBlobClient();
        CloudBlobContainer cloudBlobContainer = cloudBlobClient.GetContainerReference(containerName: AzureBlobContainer);
    
        await cloudBlobContainer.CreateIfNotExistsAsync();
    
        BlobContainerPermissions blobContainerPermissions = await cloudBlobContainer.GetPermissionsAsync();
        blobContainerPermissions.PublicAccess = BlobContainerPublicAccessType.Container;
    
        await cloudBlobContainer.SetPermissionsAsync(permissions: blobContainerPermissions);
    
        CloudBlockBlob cloudBlockBlob = cloudBlobContainer.GetBlockBlobReference(blobName: blobName);
        cloudBlockBlob.Properties.ContentType = "image/png";
    
        using (Stream stream = new MemoryStream(buffer: data))
        {
            await cloudBlockBlob.UploadFromStreamAsync(source: stream);
        }
    
        return cloudBlockBlob.Uri.AbsoluteUri;
    }
    

Here are the resources I'm reading somehow related to this issue IMO, but are not helping much:

Investigating ASP.Net Memory Dumps for Idiots (like Me)

ASP.NET app eating memory. Application / Session objects the reason?

IIS Worker Process using a LOT of memory?

Run dispose method upon asp.net IIS app restart

IIS: Idle Timeout vs Recycle

UPDATE:

if (System.IO.File.Exists(path: LocalImageFilePath))
{
    string BlobName = Guid.NewGuid().ToString(format: "n");
    string ImageURL = string.Empty;

    using(FileStream fileStream = new FileStream(LocalImageFilePath, FileMode.Open)
    {
        ImageURL = await CreateBlob(blobName: $"{BlobName}.png", dataStream: fileStream);
    }

    System.IO.File.Delete(path: LocalImageFilePath);

    oJSON = new { url = ImageURL };
}
nmrlqa4
  • 659
  • 1
  • 9
  • 32
  • 1
    The only thing that can cause memory usage to go up is in `CreateBlob()`, which isn't shown. **You** need to analyze your code and memory usage, that's not something we can easily do. Starting a process does not cause the starting process's memory to go up, so that part of your question is a red herring. – CodeCaster Oct 30 '18 at 11:25
  • I updated the question with the CreatBlob method – nmrlqa4 Oct 30 '18 at 11:27
  • I mean that everytime I call the API (and it executes the wkhtmltoimage.exe) ~120mb of RAM are added to this IIS Worker Process and I expect when the exe conlcudes this RAM to be released by the IIS Worker Process, but it's not (at least not immediately) and it adds up every time – nmrlqa4 Oct 30 '18 at 11:38
  • 2
    This can also be a problem. (More so if the file is large) `byte[] pngBytes = System.IO.File.ReadAllBytes(path: LocalImageFilePath);` If this happens occasionaly, the process might receive an increasing amount of memory -even though it might not be in use-. As the others have said: Profile your app. – Silvermind Oct 30 '18 at 11:39
  • @mjwills, I use the UploadFromStreamAsync because this is the example I found initially (is this relevant to the problem?), the IIS process consumes the RAM, not wkhtmltoimage.exe. The latter only consumes a little CPU while executing, then calms down quickly when it finishes its job as seen in the Task Manager – nmrlqa4 Oct 30 '18 at 11:42
  • Depending on the size of some of the objects being allocated, you _may_ be advised to set `GCSettings.LargeObjectHeapCompactionMode` to `CompactOnce` at the end of your method. Generally it is best to stay out of the GC's way - but if profiling shows this method is allocating objects large enough to be in the Large Object Heap then setting that may give it some hints to clean itself up a little earlier. – mjwills Oct 30 '18 at 11:43
  • 1
    Does performance improve if you use a `FileStream` and pass that to `UploadFromStreamAsync` instead? Note this will require changing the `byte[]` parameter of `CreateBlob` to be a stream instead, and stopping using `ReadAllBytes`. – mjwills Oct 30 '18 at 11:47
  • 2
    You might consider offloading all that work, it is IMO not really appropriate for a request/response cycle anyway. You should be able to know the image url without waiting for the process to run so I would drop a message on a queue and have your process run in an Azure function or similar. – Crowcoder Oct 30 '18 at 11:51
  • @mjwillis Yes, I changed the "System.IO.File.ReadAllBytes" inside the Index AR to "using Stream fileStream = new FileStream(LocalImageFilePath, FileMode.Open))" and pass the fileStream to CreateBlob method, leaving the UploadFromStreamAsync call and I think the RAM is OK right now. The crazy adding up of ~100mb everytime is reduced significantly IMO. It once again inevitably adds more RAM but very little compared to what was before – nmrlqa4 Oct 30 '18 at 12:04

1 Answers1

1

The most likely cause of your pain is the allocation of large byte arrays:

byte[] pngBytes = System.IO.File.ReadAllBytes(path: LocalImageFilePath);

The easiest change to make, to try and encourage the GC to collect the Large Object Heap more often, is to set GCSettings.LargeObjectHeapCompactionMode to CompactOnce at the end of the method. That might help.

But, a better idea would be to remove the need for the large array altogether. To do this, change:

private async Task<string> CreateBlob(string blobName, byte[] data)

to instead be:

private async Task<string> CreateBlob(string blobName, FileStream data)

And then later use:

await cloudBlockBlob.UploadFromStreamAsync(source: data);

In the caller, you'll need to stop using ReadAllBytes, and instead use a FileStream to read the file instead.

mjwills
  • 23,389
  • 6
  • 40
  • 63