last update
2026-04-16 13:21

受限环境下配置 Windows 开发工具:WSL2 / Pwsh / Windows Terminal / Docker / Aspire

在受限的 Windows 10 机器上做 Aspire 本地开发时,最先遇到的不是代码问题,而是基础设施问题:

最后我采用的是一条比较朴素、但稳定的路线:手动安装 WSL / Ubuntu,再在 WSL 里装 Docker Engine,Windows 侧只保留一个很薄的 docker.exe 转发器。这样既避开 Docker Desktop,也不需要改应用代码。

下面按实际搭建顺序来:

  1. 启用 WSL2
  2. 手动安装 Ubuntu 24.04
  3. 安装 Windows Terminal(可选)
  4. 在 WSL 中安装 Docker Engine
  5. 在 Windows 侧创建 docker.exe 桥接程序
  6. 配置 Aspire
  7. 处理代理(按需)

环境约束

目标

预期结果

配置完成后,你在 Windows 侧执行 aspire doctordocker versiondocker pull,都应该能按预期工作;后续使用 Aspire 时不需要重复执行初始化步骤。

0. 启用 WSL2 基础组件

以管理员身份打开 PowerShell,执行:

dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart

然后重启系统。

安装 WSL 2 内核更新包:

  1. 下载 https://aka.ms/wsl2kernel
  2. 运行安装包
  3. 执行:
wsl --set-default-version 2

验证:

wsl --status

1. 手动安装 Ubuntu 24.04

如果机器不能直接用 Microsoft Store 或 winget,优先使用 .wsl 安装包。这类包更适合受限环境,也更接近当前 WSL 的原生分发方式。

推荐方式:安装 .wsl

假设你拿到的文件名类似:

ubuntu-24.04.4-wsl-amd64.wsl

以管理员身份打开 PowerShell,执行:

wsl --import Ubuntu-24.04 C:\WSL\Ubuntu-24.04 C:\Downloads\ubuntu-24.04.4-wsl-amd64.wsl

说明:

如果你的 WSL 版本支持,也可以尝试:

wsl --install --from-file C:\Downloads\ubuntu-24.04.4-wsl-amd64.wsl

但在受限环境里,wsl --import 更稳,因为路径和发行版名称都可控。

兼容方式:安装 .appx.msixbundle

如果你拿到的不是 .wsl,而是 .appx.msixbundle,也可以继续安装:

Add-AppxPackage -Path "C:\Path\To\Ubuntu_24.04_LTS.appx"

如果你拿到的是 .msixbundle,命令一样,把路径换成对应文件即可。

首次启动

执行:

wsl -d Ubuntu-24.04

如果是第一次导入的 rootfs / .wsl 发行版,通常需要你自己创建默认用户;后续 Docker、代理、Aspire 相关操作都在这个发行版里做。

验证:

wsl -l -v

Important

可参考微软官方文档,了解如何在受限条件下安装 WSL 和 Linux 发行版。


2. 可选:手动安装 Windows Terminal

如果当前机器不能使用 winget 或 Microsoft Store,可以直接从 GitHub Release 安装 Windows Terminal。

下载安装包

  1. 打开 Windows Terminal 的 GitHub Releases
  2. 下载最新的 .msixbundle
  3. 如提示依赖缺失,先下载 Microsoft.VCLibs.x64.14.00.appx

安装

以管理员身份打开 PowerShell,执行:

Add-AppxPackage -Path "C:\Path\To\Microsoft.VCLibs.x64.14.00.appx"
Add-AppxPackage -Path "C:\Path\To\Microsoft.WindowsTerminal_<version>.msixbundle"

说明


3. 安装 Docker Engine

进入 WSL root:

wsl -d Ubuntu-24.04 -u root -- bash

安装 Docker:

export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y docker.io docker-buildx

将当前用户加入 docker 组:

usermod -aG docker dev

启用并启动服务:

systemctl enable --now docker

这里哪些是必装的

如果你希望 Windows 侧的 Aspire 能完整驱动 WSL 中的 Docker,下面这些能力是必须的:

  1. docker.io:提供 dockerd 和基础 docker CLI
  2. docker-buildx:提供 BuildKit / docker buildx 能力
  3. systemd 可用:用于 systemctl enable --now docker

其中 docker-buildx 这一项很关键。
Aspire 的 DCP 在启动时会构建自己的 tunnel proxy 镜像,实际会调用类似:

docker build --progress plain ...

如果 buildx/BuildKit 没装,这一步会报:

unknown flag: --progress

然后 Aspire 会卡在 dashboard 启动前,看起来像“正在启动仪表板”但没有继续。

验证

至少确认下面几条都能通过:

docker version
docker ps
docker buildx version
docker build --progress plain - <<'EOF'
FROM alpine:3.20
CMD ["echo","ok"]
EOF

4. 创建 Windows 侧 Docker 桥接程序

由于我们无法安装 Docker Desktop,要想在Windows命令行下使用Docker,就需要一个桥接程序把 docker.exe 调用转发到 WSL 中的 Docker CLI。

我们直接创建一个dotnet控制台应用,编译成 docker.exe,放在用户 PATH 中即可。

Program.cs

using System.Diagnostics;
using System.Text;
Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;
return await RunAsync(args);
static async Task<int> RunAsync(string[] arguments)
{
    var normalizedArguments = NormalizeArguments(arguments);
    var redirectInput = Console.IsInputRedirected;
    var redirectOutput = Console.IsOutputRedirected;
    var redirectError = Console.IsErrorRedirected;
    var psi = new ProcessStartInfo
    {
        FileName = "wsl.exe",
        UseShellExecute = false,
        RedirectStandardInput = redirectInput,
        RedirectStandardOutput = redirectOutput,
        RedirectStandardError = redirectError,
    };
    psi.ArgumentList.Add("-d");
    psi.ArgumentList.Add("Ubuntu-24.04");
    psi.ArgumentList.Add("-u");
    psi.ArgumentList.Add("dev");
    psi.ArgumentList.Add("--exec");
    psi.ArgumentList.Add("docker");
    foreach (var arg in normalizedArguments)
    {
        psi.ArgumentList.Add(arg);
    }
    using var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start wsl.exe.");
    Task copyStdOut = Task.CompletedTask;
    if (redirectOutput)
    {
        copyStdOut = process.StandardOutput.BaseStream.CopyToAsync(Console.OpenStandardOutput());
    }
    Task copyStdErr = Task.CompletedTask;
    if (redirectError)
    {
        copyStdErr = process.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError());
    }
    Task copyStdIn = Task.CompletedTask;
    if (redirectInput)
    {
        copyStdIn = Console.OpenStandardInput()
            .CopyToAsync(process.StandardInput.BaseStream)
            .ContinueWith(_ => process.StandardInput.Close(), TaskScheduler.Default);
    }
    await Task.WhenAll(copyStdOut, copyStdErr, copyStdIn, process.WaitForExitAsync());
    return process.ExitCode;
}
static string[] NormalizeArguments(string[] arguments)
{
    if (arguments.Length == 0)
    {
        return arguments;
    }
    var normalized = new string[arguments.Length];
    string? command = null;
    for (var i = 0; i < arguments.Length; i++)
    {
        var argument = arguments[i];
        if (command is null && !argument.StartsWith("-", StringComparison.Ordinal))
        {
            command = argument;
            normalized[i] = argument;
            continue;
        }
        var previous = i > 0 ? arguments[i - 1] : null;
        normalized[i] = NormalizeArgument(argument, previous, command);
    }
    return normalized;
}
static string NormalizeArgument(string argument, string? previousArgument, string? command)
{
    if (RequiresPathValue(previousArgument))
    {
        return ConvertWindowsPath(argument);
    }
    if (TryNormalizeFlagAssignment(argument, out var normalizedFlag))
    {
        return normalizedFlag;
    }
    if (TryNormalizeVolume(argument, previousArgument, out var normalizedVolume))
    {
        return normalizedVolume;
    }
    if (TryNormalizeMount(argument, out var normalizedMount))
    {
        return normalizedMount;
    }
    if (IsBuildContext(command, argument))
    {
        return ConvertWindowsPath(argument);
    }
    return ConvertWindowsPath(argument);
}
static bool RequiresPathValue(string? previousArgument) =>
    previousArgument is "-f" or "--file" or "--iidfile" or "--cidfile" or "--env-file" or "--label-file";
static bool TryNormalizeFlagAssignment(string argument, out string normalized)
{
    foreach (var flag in new[] { "--file=", "--iidfile=", "--cidfile=", "--env-file=", "--label-file=" })
    {
        if (argument.StartsWith(flag, StringComparison.Ordinal))
        {
            normalized = flag + ConvertWindowsPath(argument[flag.Length..]);
            return true;
        }
    }
    normalized = argument;
    return false;
}
static bool TryNormalizeVolume(string argument, string? previousArgument, out string normalized)
{
    if (previousArgument is not "-v" and not "--volume")
    {
        normalized = argument;
        return false;
    }
    normalized = NormalizeVolumeSpec(argument);
    return true;
}
static bool TryNormalizeMount(string argument, out string normalized)
{
    const string prefix = "--mount=";
    if (!argument.StartsWith(prefix, StringComparison.Ordinal))
    {
        normalized = argument;
        return false;
    }
    var spec = argument[prefix.Length..];
    var parts = spec.Split(',');
    for (var i = 0; i < parts.Length; i++)
    {
        var separatorIndex = parts[i].IndexOf('=');
        if (separatorIndex <= 0)
        {
            continue;
        }
        var key = parts[i][..separatorIndex];
        if (!key.Equals("src", StringComparison.OrdinalIgnoreCase) &&
            !key.Equals("source", StringComparison.OrdinalIgnoreCase))
        {
            continue;
        }
        var value = parts[i][(separatorIndex + 1)..];
        parts[i] = $"{key}={ConvertWindowsPath(value)}";
    }
    normalized = prefix + string.Join(",", parts);
    return true;
}
static bool IsBuildContext(string? command, string argument) =>
    (string.Equals(command, "build", StringComparison.Ordinal) ||
     string.Equals(command, "buildx", StringComparison.Ordinal)) &&
    IsWindowsPath(argument);
static string NormalizeVolumeSpec(string value)
{
    if (!TrySplitWindowsVolume(value, out var hostPath, out var remainder))
    {
        return value;
    }
    return $"{ConvertWindowsPath(hostPath)}:{remainder}";
}
static bool TrySplitWindowsVolume(string value, out string hostPath, out string remainder)
{
    hostPath = string.Empty;
    remainder = string.Empty;
    if (value.Length < 3 || !char.IsLetter(value[0]) || value[1] != ':' || (value[2] != '\\' && value[2] != '/'))
    {
        return false;
    }
    var separatorIndex = value.IndexOf(':', 3);
    if (separatorIndex < 0)
    {
        return false;
    }
    hostPath = value[..separatorIndex];
    remainder = value[(separatorIndex + 1)..];
    return true;
}
static string ConvertWindowsPath(string value)
{
    if (!IsWindowsPath(value))
    {
        return value;
    }
    var drive = char.ToLowerInvariant(value[0]);
    var path = value[2..].Replace('\\', '/');
    if (!path.StartsWith('/'))
    {
        path = "/" + path;
    }
    return $"/mnt/{drive}{path}";
}
static bool IsWindowsPath(string value) =>
    value.Length >= 3 &&
    char.IsLetter(value[0]) &&
    value[1] == ':' &&
    (value[2] == '\\' || value[2] == '/');

DockerBridge.csproj

<Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
     <OutputType>Exe</OutputType>
     <AssemblyName>docker</AssemblyName>
     <OutputName>docker</OutputName>
     <TargetFramework>net10.0</TargetFramework>
     <Nullable>enable</Nullable>
     <ImplicitUsings>enable</ImplicitUsings>
     <PublishAot>true</PublishAot>
     <InvariantGlobalization>true</InvariantGlobalization>
     <StripSymbols>true</StripSymbols>
     <OptimizationPreference>Size</OptimizationPreference>
   </PropertyGroup>
 </Project>

AOT 发布

该程序除了转发 docker 到 WSL 之外,还有一些额外处理:
把 Windows 侧传进来的 C:\... 路径转换成 WSL 可识别的 /mnt/c/...

这是 Aspire 必需的,因为 DCP 在构建内部镜像时会直接调用类似:

docker build -f C:\Users\<你>\AppData\Local\Temp\...\Dockerfile ...

如果桥接层不做路径转换,WSL 里的 Docker 会直接报 path not found

然后将其发布到用户环境变量路径下即可,例如:

dotnet publish -c Release -r win-x64 -o C:\Users\<你>\bin

然后在 Windows 命令行中验证:

docker version
docker buildx version

5. Aspire 初步验证

ok,现在我们来 验证一下 Aspire 能否识别这个 WSL 内的 Docker 运行时。

先使用aspire doctor,检查 Aspire 的环境依赖。

然后尝试在真实的项目中运行aspire start,看看能不能正常启动。


6. 代理配置

这里讨论的场景是:

当前使用的脚本在:

~\Configure-WslDockerProxy.ps1

它的职责只有一个:
把代理地址写入 WSL 内 Docker 服务的 HTTP_PROXY/HTTPS_PROXY 配置。

配置脚本

使用:

~\Configure-WslDockerProxy.ps1 -ProxyHost 192.168.1.10 -Verify

脚本行为:

  1. 验证 WSL 是否能访问指定的代理地址
  2. 配置 WSL 内 docker.serviceHTTP_PROXY/HTTPS_PROXY/NO_PROXY

-ProxyHost 应该填什么

-ProxyHost 要填的不是“我在 Windows 上习惯访问的地址”,而是:

WSL 里也能访问到的那个 Windows 代理地址

例如可以是:

如果你只是想先找出 WSL 看到的 Windows 宿主机地址,可以在 WSL 里执行:

ip route | awk '/default/ {print $3}'

这个地址经常是 172.x.x.1 之类的网关地址,但不要写死,以实际输出为准。

如果你在 Windows 上的代理软件只监听 127.0.0.1:7890,那它对 WSL 通常是不可见的;这时要先把代理软件改成监听一个 WSL 可达地址,再执行脚本。

也就是说,下面这种写法通常不成立

~\Configure-WslDockerProxy.ps1 -ProxyHost 127.0.0.1 -Verify

因为这里的 127.0.0.1 对 WSL 而言是 WSL 自己,不是 Windows 宿主机。

使用方式

一次配置即可,不需要每次启动 Aspire 前执行。

~\Configure-WslDockerProxy.ps1 -ProxyHost 192.168.1.10 -Verify

如果需要移除配置:

~\Configure-WslDockerProxy.ps1 -Remove

验证拉镜像

docker pull hello-world

7. 运行原则


8. 常见问题

1. Aspire 提示 No container runtime detected

检查:

2. docker pull 超时

检查:

3. Aspire 卡在“正在启动仪表板”

这类问题先不要只盯 dashboard,本质上往往是 DCP 初始化容器运行时失败

重点检查:

典型症状:

failed to build client proxy image
docker command 'BuildImage' returned with non-zero exit code 125
unknown flag: --progress

这通常不是缺 Docker Desktop,而是:

  1. WSL 内没有安装 docker-buildx
  2. Windows 侧桥接程序没有处理 C:\... 路径

4. 修改 PATH 后无效

重新打开 PowerShell 窗口,或者确认当前窗口里已经能执行:

Get-Command docker

Aspire 只会读取启动它的那个进程环境;如果这个窗口是在添加 C:\Users\<你>\bin 之前打开的,就会继续报找不到 Docker runtime。


9. 最终清单

必做

  1. 在 WSL 里安装并启动 Docker Engine。
  2. 在 WSL 里安装 docker-buildx
  3. 把 Windows 侧 docker.exe 桥接程序放到 C:\Users\<你>\bin\docker.exe
  4. C:\Users\<你>\bin 加进用户 PATH,然后重新打开终端。

需要时再做

  1. 如果 docker pull 需要走代理,运行 ~\Configure-WslDockerProxy.ps1 -ProxyHost <Windows 可达地址> -Verify
  2. 如果你的代理只监听在 Windows 127.0.0.1,先把代理改成 Windows LAN 地址或其他 WSL 可达地址。
  3. 如果后续你确实要让容器访问宿主机服务,再按 aspire doctor 的提示单独处理 container tunnel。

验证

Get-Command docker
docker version
docker buildx version
aspire doctor
aspire start --isolated --no-build --non-interactive -l Debug