前几天帮朋友排查一个 .NET 项目的部署问题,他说「我本地跑得好好的,一上服务器就崩」。

翻了一下他的 Dockerfile,问题挺典型的——单阶段构建、镜像 800MB、端口没映射对、Nginx 配置抄了份三年前的老模板

今天把这一套从头到尾理一遍,从 Dockerfile 到 Compose 再到 Nginx 反向代理,每一步讲清楚「为什么这么写」,而不是只给一段能跑的代码。

1. 先搞懂:你到底需要几层构建

很多人写 .NET 的 Dockerfile,上来就 FROM mcr.microsoft.com/dotnet/sdk:8.0,然后 build + run 全在一个镜像里。

结果镜像动不动七八百 MB,部署拉取慢得要死。

正确的做法是多阶段构建——用 SDK 镜像编译,用 ASP.NET Core 运行时镜像跑。

打个比方,你盖房子的时候需要起重机(SDK),但住进去的时候不需要把起重机也留在家里。

核心思路: 构建阶段用 SDK 镜像(大而全),运行阶段用运行时镜像(小而精),最终只保留运行时那一层。

来看一份标准的 ASP.NET Core Dockerfile:

# 第一阶段:构建(publish)
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# 先拷贝 .csproj,利用 Docker 缓存
COPY *.csproj .
RUN dotnet restore

# 再拷贝全部源码
COPY . .
RUN dotnet publish -c Release -o /app/publish

# 第二阶段:运行(runtime)
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .

EXPOSE 8080
ENTRYPOINT ["dotnet", "YourApp.dll"]

这里有两个细节很多人踩坑。

第一个,为什么先拷贝 .csproj 再拷贝全部代码?

因为 Docker 是分层缓存的。如果每次改一行代码就要重新 restore 一次 NuGet 包,那构建速度会慢到怀疑人生。

先拷贝 .csproj → restore → 再拷贝源码 → publish,这样只要 .csproj 没变,restore 那一层就能直接用缓存。

第二个,端口到底是 80 还是 8080?

这是个经典坑。.NET 8 开始,官方 ASP.NET Core 镜像的默认端口从 80 改成了 8080。

如果你还照着老教程写 EXPOSE 80,容器跑起来之后你会发现怎么都访问不到——不是服务没启动,是端口不对。

2. Docker Compose:把多服务串起来

如果你的项目只有一个 Web 应用,docker run 一下就够了。

但现实场景里,通常不止一个服务——Web 应用 + 数据库 + Redis + Nginx,四五个容器是常态。

这时候 Docker Compose 就是标配。

来看一份典型的 docker-compose.yml:

version: '3.8'

services:
  webapp:
    build:
      context: ./YourApp
      dockerfile: Dockerfile
    ports:
      - "5000:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__Default=Host=db;Database=appdb;Username=postgres;Password=secret
    depends_on:
      - db
      - redis
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=appdb
      - POSTGRES_PASSWORD=secret
    volumes:
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    restart: unless-stopped

volumes:
  postgres_data:
  redis_data:

说几个容易忽略的点。

depends_on 只保证启动顺序,不保证服务就绪。

很多人以为加了 depends_on,Web 就会等数据库准备好再启动。其实不是,它只等容器启动,不等服务就绪。

PostgreSQL 容器起来了,但数据库初始化还没完成,这时候 Web 去连就会报错。

解决办法有两种:一是在程序里加重试逻辑(推荐用 Polly),二是用 healthcheck + condition。

# 在 db 服务里加健康检查
healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres"]
  interval: 5s
  timeout: 5s
  retries: 5

# 在 webapp 的 depends_on 里指定条件
depends_on:
  db:
    condition: service_healthy
  redis:
    condition: service_started

数据一定要挂 volume。

数据库的数据、Redis 的持久化文件、用户上传的文件,这些都不能放在容器里。

容器一删数据就没了,到时候哭都来不及。

3. Nginx 反向代理:为什么需要它

有人会问,Kestrel 本身就是 Web 服务器,直接暴露端口不行吗,为什么还要在前面加一层 Nginx?

这个问题问得好。

Kestrel 确实是个不错的服务器,但它的设计目标是处理动态请求。静态文件、负载均衡、SSL 终止、限速、压缩这些事,交给 Nginx 效率高得多。

可以这么理解——Kestrel 是做菜的厨师,Nginx 是前厅的服务员。厨师也能端菜,但专业的事交给专业的人来做,整体效率更高。

来看一份完整的 Nginx 反向代理配置:

# /etc/nginx/conf.d/your-app.conf

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    # 静态文件直接由 Nginx 处理
    location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff2?)$ {
        root /var/www/your-app/wwwroot;
        expires 30d;
        access_log off;
    }

    # 动态请求转发给 Kestrel
    location / {
        proxy_pass http://webapp:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection keep-alive;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_read_timeout 100s;
    }
}

这段配置看起来长,但核心就两件事:静态文件自己处理,动态请求转发给后端

重点说几个 header 的作用,很多人抄了配置但不知道为什么要加。

X-Forwarded-For 和 X-Forwarded-Proto——告诉后端用户的真实 IP 和请求协议(http 还是 https)。没有这两个头,你的 .NET 程序拿到的 IP 永远是 Nginx 容器的 IP,而且会以为所有请求都是 http 的。

Host——保留原始域名。不然重定向的时候会跳转到内部地址,用户就懵了。

Upgrade 和 Connection keep-alive——支持 WebSocket 和长连接。如果你的应用用了 SignalR,这两行必须有。

光配置 Nginx 还不够,.NET 这边也要配合一下。在 Program.cs 里加上转发头中间件:

// Program.cs 里,在 builder.Build() 之前加
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor |
        ForwardedHeaders.XForwardedProto;
    options.KnownNetworks.Clear();
    options.KnownProxies.Clear();
});

// 在 app.UseHttpsRedirection() 之前加
app.UseForwardedHeaders();

KnownNetworks 和 KnownProxies 清空是因为 Docker 环境里代理 IP 不固定,不清除的话转发头会被忽略——这个坑我踩过两次。

4. 把 Nginx 也放进 Compose 里

既然所有服务都容器化了,Nginx 当然也不应该单独装在宿主机上。

把 Nginx 加到 docker-compose.yml 里,整个部署就完整了:

services:
  webapp:
    build: ./YourApp
    expose:
      - "8080"
    # 注意:这里用 expose 而不是 ports
    # 因为只需要被 Nginx 访问,不需要暴露到宿主机
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./nginx/ssl:/etc/nginx/ssl
      - ./nginx/logs:/var/log/nginx
    depends_on:
      - webapp
    restart: unless-stopped

这里有个细节:webapp 用 expose,不用 ports

expose 只是声明容器内部用哪个端口,不会映射到宿主机。所有外部请求都走 Nginx,webapp 不直接对外——安全上更干净。

而且在同一个 Compose 网络里,服务之间可以直接用服务名通信,所以 Nginx 配置里 proxy_pass http://webapp:8080 直接就能通,不需要查 IP。

5. 几个踩过的坑

最后说几个我自己踩过、也见别人踩过的坑,帮大家省点时间。

坑一:HTTPS 重定向死循环

Nginx 做了 SSL 终止,和后端之间走的是 HTTP。但 .NET 里开了 app.UseHttpsRedirection(),它一看请求是 HTTP,就重定向到 HTTPS。

结果就是用户请求到 Nginx(HTTPS)→ Nginx 转发给后端(HTTP)→ 后端说你应该用 HTTPS → 重定向 → 又到 Nginx → 无限循环。

解决办法:确保 UseForwardedHeaders 在 UseHttpsRedirection 之前调用,这样 .NET 就能通过 X-Forwarded-Proto 头知道原始请求是 HTTPS 的,就不会瞎重定向了。

坑二:时区不对

Docker 容器默认时区是 UTC,国内用户会发现时间差了 8 小时。日志时间不对、定时任务不准,都是这个原因。

在 docker-compose.yml 里给服务加上环境变量就行:

environment:
  - TZ=Asia/Shanghai

坑三:SignalR 连接不上

如果用了 SignalR 或者 Blazor Server,Nginx 配置里必须加上 WebSocket 支持的那几行 header,而且 proxy_buffering 要关掉。

还有个容易忽略的点:proxy_read_timeout 默认是 60 秒,WebSocket 连接如果 60 秒没有数据传输就会被断开。建议调到 100 秒以上,或者在应用层加心跳。

坑四:文件上传大小限制

Nginx 默认请求体大小只有 1MB,传个稍微大一点的文件就 413 报错。

在 server 块里加一行:

client_max_body_size 50M;

同时 .NET 那边也要调 Program.cs 里的 MaxRequestBodySize,两边都要放开才行。

6. 收尾

把这一套串起来之后,部署就变成了一件很简单的事。

代码提交了,服务器上 docker compose up -d --build 一下,完事。

我一直觉得,部署这件事的理想状态是「boring」——不需要什么花活,稳定、可重复、出问题能快速回滚。

一句话总结: Dockerfile 做多阶段构建控体积,Compose 做多服务编排管依赖,Nginx 做反向代理扛流量——三件套各司其职,部署就稳了。

这篇里提到的所有配置文件都是我在生产环境用过的,可以直接拿去改改用。

如果有什么我没覆盖到的场景,评论区聊。