Skip to content

Commit 300d88b

Browse files
pauld-msftCopilot
andauthored
Pauldorsch/reduce linux scan time (#1826)
* initial attempts at speeding up linux detection * allow multiple pulls at once * clarity and sorting binds * add debug logging * add some tests * pr feedback * pr feedback: clear caches once tasks finish * null check * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 79782b8 commit 300d88b

5 files changed

Lines changed: 615 additions & 3 deletions

File tree

src/Microsoft.ComponentDetection.Common/DockerService.cs

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
namespace Microsoft.ComponentDetection.Common;
33

44
using System;
5+
using System.Collections.Concurrent;
56
using System.Collections.Generic;
6-
using System.IO;
77
using System.Linq;
88
using System.Text.Json;
99
using System.Threading;
@@ -23,6 +23,13 @@ internal class DockerService : IDockerService
2323
private const string BaseImageDigestAnnotation = "image.base.digest";
2424

2525
private static readonly DockerClient Client = new DockerClientConfiguration().CreateClient();
26+
27+
/// <summary>
28+
/// Tracks in-flight image pulls so each image is pulled at most once concurrently.
29+
/// Concurrent callers for the same image await the same task.
30+
/// </summary>
31+
private static readonly ConcurrentDictionary<string, Task<bool>> PullCache = new();
32+
2633
private static int incrementingContainerId;
2734

2835
private readonly ILogger logger;
@@ -77,11 +84,13 @@ public async Task<bool> ImageExistsLocallyAsync(string image, CancellationToken
7784
{
7885
var imageInspectResponse = await this.InspectImageAndSanitizeVarsAsync(image, cancellationToken);
7986
record.ImageInspectResponse = JsonSerializer.Serialize(imageInspectResponse);
87+
this.logger.LogDebug("Image {Image} found locally", image);
8088
return true;
8189
}
8290
catch (Exception e)
8391
{
8492
record.ExceptionMessage = e.Message;
93+
this.logger.LogDebug("Image {Image} not found locally", image);
8594
cancellationToken.ThrowIfCancellationRequested();
8695
return false;
8796
}
@@ -95,6 +104,48 @@ private async Task<ImageInspectResponse> InspectImageAndSanitizeVarsAsync(string
95104
}
96105

97106
public async Task<bool> TryPullImageAsync(string image, CancellationToken cancellationToken = default)
107+
{
108+
// Check if already available locally before attempting a pull
109+
if (await this.ImageExistsLocallyAsync(image, cancellationToken))
110+
{
111+
return true;
112+
}
113+
114+
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
115+
var existingTask = PullCache.GetOrAdd(image, tcs.Task);
116+
117+
if (existingTask != tcs.Task)
118+
{
119+
// Another caller is already pulling this image — await their result
120+
this.logger.LogDebug("Image {Image} is already being pulled by another caller, waiting", image);
121+
return await existingTask.WaitAsync(cancellationToken);
122+
}
123+
124+
// We own this cache entry — perform the actual pull.
125+
try
126+
{
127+
this.logger.LogDebug("Pulling image {Image}...", image);
128+
var result = await this.PullImageCoreAsync(image, cancellationToken);
129+
this.logger.LogDebug("Pull of image {Image} completed (success={Success})", image, result);
130+
tcs.SetResult(result);
131+
return result;
132+
}
133+
catch (Exception ex)
134+
{
135+
this.logger.LogDebug(ex, "Pull of image {Image} failed", image);
136+
tcs.SetException(ex);
137+
throw;
138+
}
139+
finally
140+
{
141+
// Remove the entry once complete. The cache only deduplicates concurrent
142+
// in-flight pulls — subsequent callers will hit ImageExistsLocallyAsync
143+
// for images that were already pulled successfully.
144+
PullCache.TryRemove(image, out _);
145+
}
146+
}
147+
148+
private async Task<bool> PullImageCoreAsync(string image, CancellationToken cancellationToken)
98149
{
99150
using var record = new DockerServiceTryPullImageTelemetryRecord
100151
{
@@ -213,7 +264,7 @@ public async Task<ContainerDetails> InspectImageAsync(string image, Cancellation
213264
var stream = await AttachContainerAsync(container.ID, cancellationToken);
214265
await StartContainerAsync(container.ID, cancellationToken);
215266

216-
this.logger.LogInformation("Container {ContainerId} started for image {Image}, reading output...", container.ID, image);
267+
this.logger.LogInformation("Container {ContainerId} started with image {Image} to execute {Command}, reading output...", container.ID, image, commandJson);
217268

218269
// Flush telemetry before the long-running ReadOutput so we get mid-scan
219270
// data in App Insights even if the process hangs during the read.
@@ -353,7 +404,6 @@ private static async Task<CreateContainerResponse> CreateContainerAsync(
353404
{
354405
var binds = new List<string>
355406
{
356-
$"{Path.GetTempPath()}:/tmp",
357407
"/var/run/docker.sock:/var/run/docker.sock",
358408
};
359409

src/Microsoft.ComponentDetection.Detectors/linux/LinuxContainerDetector.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,24 @@ await this.dockerService.ImageExistsLocallyAsync(refWithDigest, cancellationToke
662662
refWithDigest,
663663
cancellationToken
664664
);
665+
666+
if (baseImageDetails == null)
667+
{
668+
record.BaseImageLayerMessage = "Failed to inspect base image after pull";
669+
this.logger.LogInformation(
670+
"Failed to inspect base image {BaseImage} after pull. Results will not be mapped to base image layers",
671+
refWithDigest
672+
);
673+
return 0;
674+
}
675+
676+
this.logger.LogDebug(
677+
"Base image {BaseImage} resolved for {ContainerImage} with {LayerCount} layers",
678+
refWithDigest,
679+
image,
680+
baseImageDetails.Layers.Count()
681+
);
682+
665683
if (!ValidateBaseImageLayers(scannedImageDetails, baseImageDetails))
666684
{
667685
record.BaseImageLayerMessage =

src/Microsoft.ComponentDetection.Detectors/linux/LinuxScanner.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
namespace Microsoft.ComponentDetection.Detectors.Linux;
22

33
using System;
4+
using System.Collections.Concurrent;
45
using System.Collections.Generic;
56
using System.Linq;
67
using System.Text.Json;
@@ -31,6 +32,13 @@ internal class LinuxScanner : ILinuxScanner
3132

3233
private static readonly SemaphoreSlim ContainerSemaphore = new SemaphoreSlim(2);
3334

35+
/// <summary>
36+
/// Caches in-flight syft runs.
37+
/// When multiple detectors scan the same image concurrently, the second
38+
/// caller awaits the already-running task instead of launching a new container.
39+
/// </summary>
40+
private static readonly ConcurrentDictionary<(string Source, LinuxScannerScope Scope, string Binds), Task<string>> SyftRunCache = new();
41+
3442
private static readonly int SemaphoreTimeout = Convert.ToInt32(
3543
TimeSpan.FromHours(1).TotalMilliseconds
3644
);
@@ -253,6 +261,7 @@ private IEnumerable<LayerMappedLinuxComponents> ProcessSyftOutputWithTelemetry(
253261

254262
/// <summary>
255263
/// Runs the Syft scanner container and returns the stdout output.
264+
/// Results are cached so that callers with identical parameters share a single container run.
256265
/// </summary>
257266
private async Task<string> RunSyftAsync(
258267
string syftSource,
@@ -261,6 +270,51 @@ private async Task<string> RunSyftAsync(
261270
LinuxScannerTelemetryRecord record,
262271
LinuxScannerSyftTelemetryRecord syftTelemetryRecord,
263272
CancellationToken cancellationToken)
273+
{
274+
var bindsKey = string.Join(";", (additionalBinds ?? []).OrderBy(b => b, StringComparer.Ordinal));
275+
var cacheKey = (syftSource, scope, bindsKey);
276+
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
277+
var existingTask = SyftRunCache.GetOrAdd(cacheKey, tcs.Task);
278+
279+
if (existingTask != tcs.Task)
280+
{
281+
// Another caller is already running syft for this image+scope — await their result,
282+
// but allow this caller's cancellation token to abort the wait.
283+
this.logger.LogDebug("Syft run for {SyftSource} (scope={Scope}) is already in-flight, reusing existing result", syftSource, scope);
284+
return await existingTask.WaitAsync(cancellationToken);
285+
}
286+
287+
// We own this cache entry — run syft and propagate the result.
288+
try
289+
{
290+
var result = await this.RunSyftCoreAsync(syftSource, scope, additionalBinds ?? [], record, syftTelemetryRecord, cancellationToken);
291+
tcs.SetResult(result);
292+
return result;
293+
}
294+
catch (Exception ex)
295+
{
296+
tcs.SetException(ex);
297+
throw;
298+
}
299+
finally
300+
{
301+
// Remove the entry once complete. The cache only deduplicates concurrent
302+
// in-flight calls — keeping completed entries would leak memory for the
303+
// lifetime of the process.
304+
SyftRunCache.TryRemove(cacheKey, out _);
305+
}
306+
}
307+
308+
/// <summary>
309+
/// Executes the Syft scanner container and returns the stdout output.
310+
/// </summary>
311+
private async Task<string> RunSyftCoreAsync(
312+
string syftSource,
313+
LinuxScannerScope scope,
314+
IList<string> additionalBinds,
315+
LinuxScannerTelemetryRecord record,
316+
LinuxScannerSyftTelemetryRecord syftTelemetryRecord,
317+
CancellationToken cancellationToken)
264318
{
265319
var acquired = false;
266320
var stdout = string.Empty;
@@ -357,4 +411,9 @@ HashSet<IArtifactComponentFactory> enabledFactories
357411
var layerIds = artifact.Locations?.Select(location => location.LayerId).Distinct() ?? [];
358412
return (component, layerIds);
359413
}
414+
415+
/// <summary>
416+
/// Clears the syft run cache. Intended for test isolation only.
417+
/// </summary>
418+
internal static void ResetCache() => SyftRunCache.Clear();
360419
}

test/Microsoft.ComponentDetection.Detectors.Tests/LinuxContainerDetectorTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,4 +1495,50 @@ public async Task ExecuteDetectorAsync_ScanThrowsOce_OtherImageStillScanned()
14951495
// The first image should have produced components
14961496
componentRecorder.GetDetectedComponents().Should().NotBeEmpty();
14971497
}
1498+
1499+
[TestMethod]
1500+
public async Task TestLinuxContainerDetector_DuplicateImageReferences_ScansOnlyOnceAsync()
1501+
{
1502+
var componentRecorder = new ComponentRecorder();
1503+
1504+
// Pass the exact same image reference twice.
1505+
var scanRequest = new ScanRequest(
1506+
new DirectoryInfo(Path.GetTempPath()),
1507+
(_, __) => false,
1508+
this.mockLogger.Object,
1509+
null,
1510+
[NodeLatestImage, NodeLatestImage],
1511+
componentRecorder
1512+
);
1513+
1514+
var linuxContainerDetector = new LinuxContainerDetector(
1515+
this.mockSyftLinuxScanner.Object,
1516+
this.mockDockerService.Object,
1517+
this.mockLinuxContainerDetectorLogger.Object
1518+
);
1519+
1520+
var scanResult = await linuxContainerDetector.ExecuteDetectorAsync(scanRequest);
1521+
1522+
scanResult.ResultCode.Should().Be(ProcessingResultCode.Success);
1523+
scanResult.ContainerDetails.Should().ContainSingle();
1524+
1525+
var detectedComponents = componentRecorder.GetDetectedComponents().ToList();
1526+
detectedComponents.Should().ContainSingle();
1527+
detectedComponents.First().Component.Id.Should().Be(BashPackageId);
1528+
1529+
// Both references resolve to the same ImageId via InspectImageAsync,
1530+
// so the ConcurrentDictionary in ProcessImagesAsync deduplicates them.
1531+
this.mockSyftLinuxScanner.Verify(
1532+
scanner =>
1533+
scanner.ScanLinuxAsync(
1534+
It.IsAny<string>(),
1535+
It.IsAny<IEnumerable<DockerLayer>>(),
1536+
It.IsAny<int>(),
1537+
It.IsAny<ISet<ComponentType>>(),
1538+
It.IsAny<LinuxScannerScope>(),
1539+
It.IsAny<CancellationToken>()
1540+
),
1541+
Times.Once
1542+
);
1543+
}
14981544
}

0 commit comments

Comments
 (0)