# Builds and copies library artifacts into target dotnet sdk image
ARG BUILD_BASE_IMAGE=mcr.microsoft.com/dotnet-buildtools/prereqs:centos-7-f39df28-20191023143754
-ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/sdk:5.0-buster-slim
+ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/nightly/sdk:5.0-buster-slim
FROM $BUILD_BASE_IMAGE as corefxbuild
# escape=`
# Simple Dockerfile which copies library build artifacts into target dotnet sdk image
-ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/sdk:5.0-nanoserver-1809
+ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/nightly/sdk:5.0-nanoserver-1809
FROM $SDK_BASE_IMAGE as target
ARG TESTHOST_LOCATION=".\\artifacts\\bin\\testhost"
# Builds and copies library artifacts into target dotnet sdk image
ARG BUILD_BASE_IMAGE=mcr.microsoft.com/dotnet-buildtools/prereqs:centos-7-f39df28-20191023143754
-ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/sdk:5.0-buster-slim
+ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/nightly/sdk:5.0-buster-slim
FROM $BUILD_BASE_IMAGE as corefxbuild
# escape=`
# Simple Dockerfile which copies clr and library build artifacts into target dotnet sdk image
-ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/sdk:5.0-nanoserver-1809
+ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/nightly/sdk:5.0-nanoserver-1809
FROM $SDK_BASE_IMAGE as target
ARG TESTHOST_LOCATION=".\\artifacts\\bin\\testhost"
jobs:
- job: linux
displayName: Docker Linux
- timeoutInMinutes: 120
+ timeoutInMinutes: 150
pool:
name: NetCorePublic-Pool
queue: BuildPool.Ubuntu.1604.Amd64.Open
- job: windows
displayName: Docker NanoServer
- timeoutInMinutes: 120
+ timeoutInMinutes: 150
pool:
name: NetCorePublic-Pool
queue: BuildPool.Server.Amd64.VS2019.Open
ctx.PopulateWithRandomHeaders(req.Headers);
ulong expectedChecksum = CRC.CalculateHeaderCrc(req.Headers.Select(x => (x.Key, x.Value)));
- using HttpResponseMessage res = await ctx.SendAsync(req);
+ using HttpResponseMessage m = await ctx.SendAsync(req);
- ValidateStatusCode(res);
+ ValidateStatusCode(m);
- await res.Content.ReadAsStringAsync();
+ await m.Content.ReadAsStringAsync();
- bool isValidChecksum = ValidateServerChecksum(res.Headers, expectedChecksum);
+ bool isValidChecksum = ValidateServerChecksum(m.Headers, expectedChecksum);
string failureDetails = isValidChecksum ? "server checksum matches client checksum" : "server checksum mismatch";
// Validate that request headers are being echoed
foreach (KeyValuePair<string, IEnumerable<string>> reqHeader in req.Headers)
{
- if (!res.Headers.TryGetValues(reqHeader.Key, out IEnumerable<string>? values))
+ if (!m.Headers.TryGetValues(reqHeader.Key, out IEnumerable<string>? values))
{
throw new Exception($"Expected response header name {reqHeader.Key} missing. {failureDetails}");
}
}
// Validate trailing headers are being echoed
- if (res.TrailingHeaders.Count() > 0)
+ if (m.TrailingHeaders.Count() > 0)
{
foreach (KeyValuePair<string, IEnumerable<string>> reqHeader in req.Headers)
{
- if (!res.TrailingHeaders.TryGetValues(reqHeader.Key + "-trailer", out IEnumerable<string>? values))
+ if (!m.TrailingHeaders.TryGetValues(reqHeader.Key + "-trailer", out IEnumerable<string>? values))
{
throw new Exception($"Expected trailing header name {reqHeader.Key}-trailer missing. {failureDetails}");
}
using HttpResponseMessage m = await ctx.SendAsync(req);
ValidateStatusCode(m);
+
string checksumMessage = ValidateServerChecksum(m.Headers, checksum) ? "server checksum matches client checksum" : "server checksum mismatch";
ValidateContent(content, await m.Content.ReadAsStringAsync(), checksumMessage);
}),
using HttpResponseMessage m = await ctx.SendAsync(req);
ValidateStatusCode(m);
+
string checksumMessage = ValidateServerChecksum(m.Headers, checksum) ? "server checksum matches client checksum" : "server checksum mismatch";
ValidateContent(formData.expected, await m.Content.ReadAsStringAsync(), checksumMessage);
}),
string response = await m.Content.ReadAsStringAsync();
string checksumMessage = ValidateServerChecksum(m.TrailingHeaders, checksum, required: false) ? "server checksum matches client checksum" : "server checksum mismatch";
- ValidateContent(content, await m.Content.ReadAsStringAsync(), checksumMessage);
+ ValidateContent(content, response, checksumMessage);
}),
("POST Duplex Slow",
using HttpResponseMessage m = await ctx.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
ValidateStatusCode(m);
+
string checksumMessage = ValidateServerChecksum(m.Headers, checksum) ? "server checksum matches client checksum" : "server checksum mismatch";
ValidateContent(content, await m.Content.ReadAsStringAsync(), checksumMessage);
}),
{
throw new Exception($"Expected {expectedLength}, got {m.Content.Headers.ContentLength}");
}
- string r = await m.Content.ReadAsStringAsync();
- if (r.Length > 0) throw new Exception($"Got unexpected response: {r}");
+ string response = await m.Content.ReadAsStringAsync();
+ if (response.Length > 0) throw new Exception($"Got unexpected response: {response}");
}),
("PUT",
ValidateStatusCode(m);
- string r = await m.Content.ReadAsStringAsync();
- if (r != "") throw new Exception($"Got unexpected response: {r}");
+ string response = await m.Content.ReadAsStringAsync();
+ if (response != "") throw new Exception($"Got unexpected response: {response}");
}),
("PUT Slow",
ValidateStatusCode(m);
- string r = await m.Content.ReadAsStringAsync();
- if (r != "") throw new Exception($"Got unexpected response: {r}");
+ string response = await m.Content.ReadAsStringAsync();
+ if (response != "") throw new Exception($"Got unexpected response: {response}");
}),
("GET Slow",
public double CancellationProbability { get; set; }
public bool UseHttpSys { get; set; }
- public string? LogPath { get; set; }
public bool LogAspNet { get; set; }
+ public bool Trace { get; set; }
public int? ServerMaxConcurrentStreams { get; set; }
public int? ServerMaxFrameSize { get; set; }
public int? ServerInitialConnectionWindowSize { get; set; }
-ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/sdk:5.0-buster-slim
+ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/nightly/sdk:5.0-buster-slim
FROM $SDK_BASE_IMAGE
RUN echo "DOTNET_SDK_VERSION="$DOTNET_SDK_VERSION
using System;
using System.Diagnostics.Tracing;
+using System.Threading.Channels;
using System.Text;
using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
namespace HttpStress
{
public sealed class LogHttpEventListener : EventListener
{
- private readonly StreamWriter _log;
+ private int _lastLogNumber = 0;
+ private StreamWriter _log;
+ private Channel<string> _messagesChannel = Channel.CreateUnbounded<string>();
+ private Task _processMessages;
+ private CancellationTokenSource _stopProcessing;
- public LogHttpEventListener(string logPath)
+ public LogHttpEventListener()
{
- _log = new StreamWriter(logPath, true) { AutoFlush = true };
+ foreach (var filename in Directory.GetFiles(".", "client*.log"))
+ {
+ try
+ {
+ File.Delete(filename);
+ } catch {}
+ }
+ _log = new StreamWriter("client.log", false) { AutoFlush = true };
+
+ _messagesChannel = Channel.CreateUnbounded<string>();
+ _processMessages = ProcessMessagesAsync();
+ _stopProcessing = new CancellationTokenSource();
}
protected override void OnEventSourceCreated(EventSource eventSource)
}
}
- protected override void OnEventWritten(EventWrittenEventArgs eventData)
+ private async Task ProcessMessagesAsync()
{
- lock (_log)
+ await Task.Yield();
+
+ try
{
- var sb = new StringBuilder().Append($"{eventData.TimeStamp:HH:mm:ss.fffffff}[{eventData.EventName}] ");
- for (int i = 0; i < eventData.Payload?.Count; i++)
+ int i = 0;
+ await foreach (string message in _messagesChannel.Reader.ReadAllAsync(_stopProcessing.Token))
{
- if (i > 0)
+ if ((++i % 10_000) == 0)
{
- sb.Append(", ");
+ RotateFiles();
}
- sb.Append(eventData.PayloadNames?[i]).Append(": ").Append(eventData.Payload[i]);
+
+ _log.WriteLine(message);
}
- _log.WriteLine(sb.ToString());
}
- }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
- public override void Dispose()
- {
- _log.Dispose();
- base.Dispose();
+ void RotateFiles()
+ {
+ // Rotate the log if it reaches 50 MB size.
+ if (_log.BaseStream.Length > (50 << 20))
+ {
+ _log.Close();
+ _log = new StreamWriter($"client_{++_lastLogNumber:000}.log", false) { AutoFlush = true };
+ }
+ }
}
- }
-
- public sealed class ConsoleHttpEventListener : EventListener
- {
- public ConsoleHttpEventListener()
- { }
- protected override void OnEventSourceCreated(EventSource eventSource)
+ protected override async void OnEventWritten(EventWrittenEventArgs eventData)
{
- if (eventSource.Name == "Private.InternalDiagnostics.System.Net.Http")
+ var sb = new StringBuilder().Append($"{eventData.TimeStamp:HH:mm:ss.fffffff}[{eventData.EventName}] ");
+ for (int i = 0; i < eventData.Payload?.Count; i++)
{
- EnableEvents(eventSource, EventLevel.LogAlways);
+ if (i > 0)
+ {
+ sb.Append(", ");
+ }
+ sb.Append(eventData.PayloadNames?[i]).Append(": ").Append(eventData.Payload[i]);
}
+ await _messagesChannel.Writer.WriteAsync(sb.ToString());
}
- protected override void OnEventWritten(EventWrittenEventArgs eventData)
+ public override void Dispose()
{
- lock (Console.Out)
+ base.Dispose();
+
+ if (!_processMessages.Wait(TimeSpan.FromSeconds(30)))
{
- Console.ForegroundColor = ConsoleColor.DarkYellow;
- Console.Write($"{eventData.TimeStamp:HH:mm:ss.fffffff}[{eventData.EventName}] ");
- Console.ResetColor();
- for (int i = 0; i < eventData.Payload?.Count; i++)
- {
- if (i > 0)
- {
- Console.Write(", ");
- }
- Console.ForegroundColor = ConsoleColor.DarkGray;
- Console.Write(eventData.PayloadNames?[i] + ": ");
- Console.ResetColor();
- Console.Write(eventData.Payload[i]);
- }
- Console.WriteLine();
+ _stopProcessing.Cancel();
+ _processMessages.Wait();
}
+ _log.Dispose();
}
}
}
</PropertyGroup>
<ItemGroup>
+ <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
+ <PackageReference Include="Serilog.Extensions.Logging.File" Version="2.0.0" />
+ <PackageReference Include="Serilog.Sinks.File" Version="4.1.0" />
<PackageReference Include="System.CommandLine.Experimental" Version="0.3.0-alpha.19577.1" />
<PackageReference Include="System.Net.Http.WinHttpHandler" Version="4.5.4" />
</ItemGroup>
/// </summary>
public static class Program
{
-
public enum ExitCode { Success = 0, StressError = 1, CliError = 2 };
public static async Task<int> Main(string[] args)
cmd.AddOption(new Option("-connectionLifetime", "Max connection lifetime length (milliseconds).") { Argument = new Argument<int?>("connectionLifetime", null) });
cmd.AddOption(new Option("-ops", "Indices of the operations to use") { Argument = new Argument<int[]?>("space-delimited indices", null) });
cmd.AddOption(new Option("-xops", "Indices of the operations to exclude") { Argument = new Argument<int[]?>("space-delimited indices", null) });
- cmd.AddOption(new Option("-trace", "Enable System.Net.Http.InternalDiagnostics tracing.") { Argument = new Argument<string>("\"console\" or path") });
+ cmd.AddOption(new Option("-trace", "Enable System.Net.Http.InternalDiagnostics (client) and/or ASP.NET dignostics (server) tracing.") { Argument = new Argument<bool>("enable", false) });
cmd.AddOption(new Option("-aspnetlog", "Enable ASP.NET warning and error logging.") { Argument = new Argument<bool>("enable", false) });
cmd.AddOption(new Option("-listOps", "List available options.") { Argument = new Argument<bool>("enable", false) });
cmd.AddOption(new Option("-seed", "Seed for generating pseudo-random parameters for a given -n argument.") { Argument = new Argument<int?>("seed", null) });
UseHttpSys = cmdline.ValueForOption<bool>("-httpSys"),
LogAspNet = cmdline.ValueForOption<bool>("-aspnetlog"),
- LogPath = cmdline.HasOption("-trace") ? cmdline.ValueForOption<string>("-trace") : null,
+ Trace = cmdline.ValueForOption<bool>("-trace"),
ServerMaxConcurrentStreams = cmdline.ValueForOption<int?>("-serverMaxConcurrentStreams"),
ServerMaxFrameSize = cmdline.ValueForOption<int?>("-serverMaxFrameSize"),
ServerInitialConnectionWindowSize = cmdline.ValueForOption<int?>("-serverInitialConnectionWindowSize"),
Console.WriteLine(" System.Net.Http: " + GetAssemblyInfo(typeof(System.Net.Http.HttpClient).Assembly));
Console.WriteLine(" Server: " + (config.UseHttpSys ? "http.sys" : "Kestrel"));
Console.WriteLine(" Server URL: " + config.ServerUri);
- Console.WriteLine(" Tracing: " + (config.LogPath == null ? (object)false : config.LogPath.Length == 0 ? (object)true : config.LogPath));
+ Console.WriteLine(" Client Tracing: " + (config.Trace && config.RunMode.HasFlag(RunMode.client) ? "ON (client.log)" : "OFF"));
+ Console.WriteLine(" Server Tracing: " + (config.Trace && config.RunMode.HasFlag(RunMode.server) ? "ON (server.log)" : "OFF"));
Console.WriteLine(" ASP.NET Log: " + config.LogAspNet);
Console.WriteLine(" Concurrency: " + config.ConcurrentRequests);
Console.WriteLine(" Content Length: " + config.MaxContentLength);
- Console.WriteLine(" HTTP2 Version: " + config.HttpVersion);
+ Console.WriteLine(" HTTP Version: " + config.HttpVersion);
Console.WriteLine(" Lifetime: " + (config.ConnectionLifetime.HasValue ? $"{config.ConnectionLifetime.Value.TotalMilliseconds}ms" : "(infinite)"));
Console.WriteLine(" Operations: " + string.Join(", ", usedClientOperations.Select(o => o.name)));
Console.WriteLine(" Random Seed: " + config.RandomSeed);
{
public class StressClient : IDisposable
{
- private const string UNENCRYPTED_HTTP2_ENV_VAR = "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP2UNENCRYPTEDSUPPORT";
-
private readonly (string name, Func<RequestContext, Task> operation)[] _clientOperations;
private readonly Uri _baseAddress;
private readonly Configuration _config;
_aggregator = new StressResultAggregator(clientOperations);
// Handle command-line arguments.
- _eventListener =
- configuration.LogPath == null ?
- null :
- (configuration.LogPath == "console" ?
- (EventListener)new ConsoleHttpEventListener() :
- (EventListener)new LogHttpEventListener(configuration.LogPath));
+ _eventListener = configuration.Trace ? new LogHttpEventListener() : null;
+ }
+
+ private HttpClient CreateHttpClient()
+ {
+ HttpMessageHandler CreateHttpHandler()
+ {
+ if (_config.UseWinHttpHandler)
+ {
+ return new System.Net.Http.WinHttpHandler()
+ {
+ ServerCertificateValidationCallback = delegate { return true; }
+ };
+ }
+ else
+ {
+ return new SocketsHttpHandler()
+ {
+ PooledConnectionLifetime = _config.ConnectionLifetime.GetValueOrDefault(Timeout.InfiniteTimeSpan),
+ SslOptions = new SslClientAuthenticationOptions
+ {
+ RemoteCertificateValidationCallback = delegate { return true; }
+ }
+ };
+ }
+ }
+
+ return new HttpClient(CreateHttpHandler())
+ {
+ BaseAddress = _baseAddress,
+ Timeout = _config.DefaultTimeout,
+ DefaultRequestVersion = _config.HttpVersion,
+ DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
+ };
}
public void Start()
throw new InvalidOperationException("Stress client already running");
}
+ InitializeClient().Wait();
_stopwatch.Start();
_clientTask = StartCore();
}
public void Stop()
{
_cts.Cancel();
- _clientTask?.Wait();
+ for (int i = 0; i < 60; ++i)
+ {
+ if (_clientTask == null || _clientTask.Wait(TimeSpan.FromSeconds(1)))
+ {
+ break;
+ }
+ Console.WriteLine("Client is stopping ...");
+ }
_stopwatch.Stop();
_cts.Dispose();
}
public void PrintFinalReport()
{
- lock(Console.Out)
+ lock (Console.Out)
{
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("HttpStress Run Final Report");
_eventListener?.Dispose();
}
- private async Task StartCore()
+ private async Task InitializeClient()
{
- if (_baseAddress.Scheme == "http")
- {
- Environment.SetEnvironmentVariable(UNENCRYPTED_HTTP2_ENV_VAR, "1");
- }
+ Console.WriteLine($"Trying connect to the server {_baseAddress}.");
- HttpMessageHandler CreateHttpHandler()
+ // Before starting the full-blown test, make sure can communicate with the server
+ // Needed for scenaria where we're deploying server & client in separate containers, simultaneously.
+ await SendTestRequestToServer(maxRetries: 10);
+
+ Console.WriteLine($"Connected successfully.");
+
+ async Task SendTestRequestToServer(int maxRetries)
{
- if (_config.UseWinHttpHandler)
+ using HttpClient client = CreateHttpClient();
+ client.Timeout = TimeSpan.FromSeconds(5);
+ for (int remainingRetries = maxRetries; ; remainingRetries--)
{
- return new System.Net.Http.WinHttpHandler()
+ var sw = Stopwatch.StartNew();
+ try
{
- ServerCertificateValidationCallback = delegate { return true; }
- };
- }
- else
- {
- return new SocketsHttpHandler()
+ await client.GetAsync("/");
+ break;
+ }
+ catch (HttpRequestException) when (remainingRetries > 0)
{
- PooledConnectionLifetime = _config.ConnectionLifetime.GetValueOrDefault(Timeout.InfiniteTimeSpan),
- SslOptions = new SslClientAuthenticationOptions
+ Console.WriteLine($"Stress client could not connect to host {_baseAddress}, {remainingRetries} attempts remaining");
+ var delay = TimeSpan.FromSeconds(1) - sw.Elapsed;
+ if (delay > TimeSpan.Zero)
{
- RemoteCertificateValidationCallback = delegate { return true; }
+ await Task.Delay(delay);
}
- };
+ }
}
}
+ }
- HttpClient CreateHttpClient() =>
- new HttpClient(CreateHttpHandler())
- {
- BaseAddress = _baseAddress,
- Timeout = _config.DefaultTimeout,
- DefaultRequestVersion = _config.HttpVersion,
- };
-
+ private async Task StartCore()
+ {
using HttpClient client = CreateHttpClient();
- Console.WriteLine($"Trying connect to the server {_baseAddress}.");
-
- // Before starting the full-blown test, make sure can communicate with the server
- // Needed for scenaria where we're deploying server & client in separate containers, simultaneously.
- await SendTestRequestToServer(maxRetries: 10);
-
- Console.WriteLine($"Connected succesfully.");
-
// Spin up a thread dedicated to outputting stats for each defined interval
new Thread(() =>
{
return ((int)rol5 + h1) ^ h2;
}
}
-
- async Task SendTestRequestToServer(int maxRetries)
- {
- using HttpClient client = CreateHttpClient();
- for (int remainingRetries = maxRetries; ; remainingRetries--)
- {
- try
- {
- await client.GetAsync("/");
- break;
- }
- catch (HttpRequestException) when (remainingRetries > 0)
- {
- Console.WriteLine($"Stress client could not connect to host {_baseAddress}, {remainingRetries} attempts remaining");
- await Task.Delay(millisecondsDelay: 1000);
- }
- }
- }
}
/// <summary>Aggregate view of a particular stress failure type</summary>
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Serilog;
namespace HttpStress
{
// Header indicating expected response content length to be returned by the server
public const string ExpectedResponseContentLength = "Expected-Response-Content-Length";
- private EventListener? _eventListener;
private readonly IWebHost _webHost;
public string ServerUri { get; }
});
};
- // Output only warnings and errors from Kestrel
+ LoggerConfiguration loggerConfiguration = new LoggerConfiguration();
+ if (configuration.Trace)
+ {
+ // Clear existing logs first.
+ foreach (var filename in Directory.GetFiles(".", "server*.log"))
+ {
+ try
+ {
+ File.Delete(filename);
+ } catch {}
+ }
+
+ loggerConfiguration = loggerConfiguration
+ // Output diagnostics to the file
+ .WriteTo.File("server.log", fileSizeLimitBytes: 50 << 20, rollOnFileSizeLimit: true)
+ .MinimumLevel.Debug();
+ }
+ if (configuration.LogAspNet)
+ {
+ loggerConfiguration = loggerConfiguration
+ // Output only warnings and errors
+ .WriteTo.Console(Serilog.Events.LogEventLevel.Warning);
+ }
+ Log.Logger = loggerConfiguration.CreateLogger();
+
host = host
- .ConfigureLogging(log => log.AddFilter("Microsoft.AspNetCore", level => configuration.LogAspNet ? level >= LogLevel.Warning : false))
+ .UseSerilog()
// Set up how each request should be handled by the server.
.Configure(app =>
{
app.UseEndpoints(MapRoutes);
});
- // Handle command-line arguments.
- _eventListener =
- configuration.LogPath == null ?
- null :
- (configuration.LogPath == "console" ?
- (EventListener)new ConsoleHttpEventListener() :
- (EventListener)new LogHttpEventListener(configuration.LogPath));
-
- SetUpJustInTimeLogging();
-
_webHost = host.Build();
_webHost.Start();
}
private static void MapRoutes(IEndpointRouteBuilder endpoints)
{
+ var loggerFactory = endpoints.ServiceProvider.GetService<ILoggerFactory>();
+ var logger = loggerFactory.CreateLogger<StressServer>();
var head = new[] { "HEAD" };
endpoints.MapGet("/", async context =>
public void Dispose()
{
_webHost.Dispose();
- _eventListener?.Dispose();
- }
-
- private void SetUpJustInTimeLogging()
- {
- if (_eventListener == null && !Console.IsInputRedirected)
- {
- // If no command-line requested logging, enable the user to press 'L' to enable logging to the console
- // during execution, so that it can be done just-in-time when something goes awry.
- new Thread(() =>
- {
- while (true)
- {
- if (Console.ReadKey(intercept: true).Key == ConsoleKey.L)
- {
- Console.WriteLine("Enabling console event logger");
- _eventListener = new ConsoleHttpEventListener();
- break;
- }
- }
- })
- { IsBackground = true }.Start();
- }
}
private static (string scheme, string hostname, int port) ParseServerUri(string serverUri)
# escape=`
-ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/sdk:5.0-nanoserver-1809
+ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/nightly/sdk:5.0-nanoserver-1809
FROM $SDK_BASE_IMAGE
# Use powershell as the default shell