1. 为什么选 Docker 来跑 NetCore 服务
1.1 从一台干净服务器说起
手头拿到一台刚装好系统的 Linux 机器,要往上放一个 NetCore 写的后端服务,最原始的做法是装运行时、配环境变量、传文件、写守护脚本,一套流程走下来少说半小时,换台机器还得重来一遍。我最早做部署那会儿就是这么干的,结果同一个服务在三台机器上跑出了三种不同的报错,排查到最后发现是运行时小版本不一致导致的。后来改用 Docker,同样的服务打包成镜像,换到哪台机器上跑起来行为都一样,这件事才算彻底消停。
这篇要聊的就是在 Linux 环境下用 Docker 部署 NetCore 服务的完整思路和实操细节。核心关键词就三个:Linux、Docker、NetCore。适合谁看?如果你手上有 NetCore 项目要上线,或者正在从传统部署方式往容器化迁移,又或者只是想搞清楚 Docker 到底解决了什么问题,这篇都能给你一条能直接照着走的路径。我会把镜像怎么选、Dockerfile 怎么写、端口和挂载怎么配、日志怎么看、出问题怎么查这些环节全部拆开讲,尽量做到你看完就能在自己机器上复现。
1.2 容器化到底解决了哪些实际痛点
先说清楚 Docker 在这里的价值,不然容易变成为了用而用。NetCore 本身是跨平台的,理论上直接扔到 Linux 上也能跑,那为什么还要套一层容器?
第一个痛点是环境一致性。NetCore 服务依赖运行时,运行时又依赖一堆系统库。开发机上跑得好好的,到了服务器上可能因为 glibc 版本、ICU 库缺失之类的问题直接起不来。容器把运行时和依赖一起打包,镜像里是什么环境,跑起来就是什么环境,跟宿主机解耦。
第二个痛点是部署效率。传统方式要手动装运行时、配 systemd 服务、处理权限,容器化之后一条docker run或者一个 compose 文件就搞定,回滚也就是换个镜像标签的事。
第三个痛点是资源隔离和编排。多个服务跑在一台机器上,端口冲突、依赖冲突是常事。容器各自独立,配合 compose 还能一键拉起整套依赖,比如服务加数据库加缓存。
注意:容器不是虚拟机,它共享宿主机内核,所以镜像里不需要装完整操作系统,这也是它比虚拟机轻量的根本原因。理解这一点,后面选基础镜像的时候就不会犯迷糊。
2. 动手前的环境准备与镜像选型
2.1 Linux 侧 Docker 环境确认
在开始之前,先把宿主机这边的底子摸清楚。我一般会按顺序确认这几件事:
- 系统架构是 x86_64 还是 arm64,这直接决定后面拉哪个架构的镜像。用
uname -m看一眼,输出x86_64就是常见的 64 位 Intel/AMD,输出aarch64就是 ARM。 - Docker 是否已经装好并且服务在跑。
docker version能同时看到客户端和服务端信息,如果只显示客户端,说明服务端没起来或者当前用户没权限。 - 当前用户是否在 docker 用户组里。不在的话每条命令都要加
sudo,很烦。可以把自己加进组里,重新登录生效。
uname -m docker version docker infodocker info这个命令信息量很大,能看到存储驱动、容器数量、镜像数量、可用的 CPU 和内存。我习惯在部署前跑一次,心里有个数,尤其是内存,NetCore 服务虽然不算吃内存,但镜像构建阶段可能会比较耗。
2.2 NetCore 基础镜像怎么挑
镜像选型是这一步最容易被忽略、但影响最大的决策。官方提供的 NetCore 镜像大致分几类,我按用途给你捋一遍。
| 镜像类型 | 典型标签 | 体积量级 | 适用场景 |
|---|---|---|---|
| SDK 镜像 | sdk:8.0 | 较大 | 构建、编译、发布阶段 |
| ASP.NET 运行时 | aspnet:8.0 | 中等 | 需要跑 Web 服务的运行阶段 |
| 运行时镜像 | runtime:8.0 | 中等偏小 | 控制台程序、后台服务 |
| 运行时精简版 | runtime-deps:8.0 | 小 | 自包含发布(self-contained)的程序 |
| Alpine 变体 | 带 -alpine 后缀 | 很小 | 追求镜像体积、能接受 musl 差异 |
选型的逻辑是这样的:构建和运行要分开。构建阶段需要 SDK 来编译代码,运行阶段只需要运行时。如果构建和运行用同一个镜像,最终镜像里会塞进一堆编译工具,体积白白大出好几百 MB。所以标准做法是多阶段构建,构建用 SDK 镜像,运行用运行时镜像。
至于要不要用 Alpine 变体,我的经验是:如果你的项目依赖里有原生库(比如某些图像处理、加密相关的包),Alpine 用的 musl libc 和常见的 glibc 不兼容,可能会踩坑。追求体积可以用,但一定要在测试环境验证过再上生产。新手我建议先用默认的 Debian 系镜像,稳。
提示:镜像标签尽量写具体版本,比如
8.0而不是latest。latest会随着官方更新而变,今天构建的镜像和明天构建的可能不是同一个东西,这在排查问题时是灾难。
2.3 项目发布产物的准备
Docker 构建 NetCore 镜像有两条路:一条是在容器里编译,把源码拷进去让 SDK 镜像去 build;另一条是在宿主机或者 CI 上先 publish 出产物,再把产物拷进运行时镜像。两条路各有取舍。
容器内编译的好处是构建环境完全一致,不依赖宿主机的 SDK 版本,CI 上很省心。坏处是每次构建都要重新还原 NuGet 包,慢,而且镜像层缓存如果没设计好,改一行代码就要重来一遍。
宿主机先发布的好处是构建快,产物明确,镜像构建只做拷贝。坏处是要求构建机器上有对应版本的 SDK。
我个人的习惯是:本地开发调试用宿主机发布,CI 流水线用容器内多阶段构建。这样两边的好处都能占到。发布命令很简单:
dotnet publish -c Release -o ./publish-c Release指定发布配置,-o指定输出目录。发布完./publish目录里就是可以直接运行的产物,包含 dll、依赖和配置文件。
3. Dockerfile 编写与镜像构建实操
3.1 多阶段 Dockerfile 逐行拆解
下面这份 Dockerfile 是我用了很久的模板,多阶段构建,构建和运行分离。我把它拆开逐段讲。
# 构建阶段 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY *.csproj ./ RUN dotnet restore COPY . ./ RUN dotnet publish -c Release -o /app/publish # 运行阶段 FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/publish ./ EXPOSE 8080 ENTRYPOINT ["dotnet", "YourApp.dll"]第一段FROM ... AS build声明构建阶段,给它起个名字叫 build,后面运行阶段要引用它。WORKDIR /src设置工作目录,后续命令都在这个目录下执行。
关键在COPY *.csproj ./加RUN dotnet restore这两行。为什么先只拷 csproj 文件而不是整个项目?这是利用 Docker 的层缓存机制。只要 csproj 没变,dotnet restore这一层就会被缓存,改代码不会触发重新还原包。如果一上来就COPY . ./,那任何文件改动都会让缓存失效,每次构建都要重新还原,慢得让人抓狂。这个技巧是我踩过好几次坑之后才养成的习惯。
COPY . ./把剩下的源码拷进来,然后dotnet publish发布。注意这里发布到/app/publish,运行阶段会从这里拷。
运行阶段用 aspnet 镜像,因为这是 Web 服务,需要 ASP.NET Core 运行时。如果是控制台程序,换成 runtime 镜像即可。COPY --from=build从构建阶段把发布产物拷过来,这一步只拷贝产物,编译工具链全部留在构建阶段,最终镜像干净。
EXPOSE 8080是声明容器监听的端口,注意它只是声明,实际映射还要在 run 的时候指定。NetCore 8 默认监听 8080,早期版本默认是 5000,这个差异要注意。
ENTRYPOINT用数组形式写,避免 shell 解析带来的信号传递问题。用 shell 形式的话,容器里的 1 号进程是 shell 而不是 dotnet,停止容器时信号传不到应用,可能导致优雅关闭失效。
3.2 构建命令与镜像标签管理
Dockerfile 写好后,构建命令本身不复杂,但标签管理有讲究。
docker build -t myapp:1.0.0 -t myapp:latest .-t可以打多个标签,我习惯同时打一个具体版本号和一个 latest。版本号用于回滚和追溯,latest 用于日常引用。生产环境我强烈建议只用具体版本号,别用 latest,不然哪天重新拉镜像发现行为变了都不知道为什么。
构建的时候如果网络环境一般,还原 NuGet 包可能会慢或者失败。可以在构建时传入镜像源参数,或者提前在宿主机配好 NuGet 配置再拷进去。这个后面排查章节会细说。
构建完成后用docker images看一眼镜像大小。一个正常的 ASP.NET Core 服务镜像,用默认 Debian 系基础镜像,大概在 200MB 出头。如果发现有好几个 G,那多半是构建和运行没分离,把 SDK 也打进去了。
3.3 镜像分层与缓存优化思路
Docker 镜像是一层一层叠起来的,每条会修改文件系统的指令都会产生一层。理解分层对优化构建速度和镜像体积很关键。
把变化频率低的内容放在 Dockerfile 前面,变化频率高的放后面。这就是为什么先拷 csproj 再拷源码。同理,如果项目里有前端资源需要构建,也应该把 package.json 单独先拷进去装依赖,再拷源码。
还有一个细节:RUN命令尽量合并。比如装系统包的时候,apt-get update和apt-get install写在一个 RUN 里,中间用&&连接,最后清理缓存。分开写的话,update 那一层会被缓存,install 时可能用到过期的包索引,而且清理不掉中间层。
RUN apt-get update && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*--no-install-recommends避免装一堆推荐包,rm -rf清理 apt 缓存,这两招能省不少体积。
4. 容器运行、端口映射与数据挂载
4.1 运行容器的核心参数
镜像构建好,接下来是把它跑起来。最基础的运行命令长这样:
docker run -d --name myapp -p 8080:8080 myapp:1.0.0-d后台运行,--name给容器起个名字方便管理,-p做端口映射,格式是宿主机端口:容器端口。这里把宿主机的 8080 映射到容器的 8080。
端口映射这里有个常见误区:容器内的端口是应用监听的端口,宿主机端口是你对外暴露的端口,两者可以不一样。比如-p 80:8080就是把宿主机 80 映射到容器 8080。但要注意,容器内应用监听的端口得和映射的容器端口一致,否则连不上。
NetCore 应用监听端口可以通过环境变量控制,ASPNETCORE_URLS或者ASPNETCORE_HTTP_PORTS。如果不想改代码,运行时传环境变量最方便:
docker run -d --name myapp \ -p 8080:8080 \ -e ASPNETCORE_ENVIRONMENT=Production \ -e ASPNETCORE_URLS=http://+:8080 \ myapp:1.0.0ASPNETCORE_ENVIRONMENT控制环境,生产环境设成 Production,这样 appsettings.Production.json 会被加载。这个变量不设的话默认是 Production,但显式写出来更清楚。
4.2 配置文件与日志的挂载策略
容器里的文件是临时的,容器一删就没了。配置文件、日志、上传的文件这些需要持久化的东西,必须挂载到宿主机。
docker run -d --name myapp \ -p 8080:8080 \ -v /data/myapp/logs:/app/logs \ -v /data/myapp/config:/app/config \ myapp:1.0.0-v做目录挂载,格式宿主机路径:容器路径。日志挂出来方便排查,配置挂出来方便改配置不用重新构建镜像。
这里有个坑要提醒:挂载目录的权限问题。容器里应用以什么用户运行,决定了它能不能写挂载出来的目录。官方 aspnet 镜像默认有个非 root 用户,但很多人直接用 root 跑。如果挂载目录权限不对,应用写日志会报权限拒绝。解决办法要么调整宿主机目录权限,要么在 Dockerfile 里指定运行用户并确保目录归属正确。
注意:挂载配置文件的时候,如果宿主机目录是空的,容器里对应目录的内容会被覆盖成空。所以第一次挂载前,先把容器里的默认配置拷出来,或者确保宿主机目录里已经有正确的配置文件。
4.3 用环境变量覆盖配置的实践
NetCore 的配置系统支持环境变量覆盖,这个特性和容器结合得特别好。配置里的层级用双下划线分隔,比如ConnectionStrings__Default对应配置里的ConnectionStrings:Default。
docker run -d --name myapp \ -p 8080:8080 \ -e ConnectionStrings__Default="Server=db;Database=app;Uid=user;Pwd=pass;" \ -e Logging__LogLevel__Default=Warning \ myapp:1.0.0这样做的好处是敏感信息不用写进镜像,镜像可以放心推到镜像仓库。数据库连接串、密钥这些通过环境变量在运行时注入,不同环境用不同的值,同一个镜像到处跑。
不过环境变量多了之后,命令行会变得很长很难维护。这时候就该上 compose 了。
4.4 docker compose 编排多服务
实际项目很少只有一个服务,通常还有数据库、缓存。用 compose 把这些组织起来,一个文件描述整套环境。
version: "3.8" services: app: image: myapp:1.0.0 ports: - "8080:8080" environment: - ASPNETCORE_ENVIRONMENT=Production - ConnectionStrings__Default=Server=db;Database=app;Uid=user;Pwd=pass; depends_on: - db volumes: - ./logs:/app/logs db: image: postgres:16 environment: - POSTGRES_PASSWORD=pass - POSTGRES_DB=app volumes: - dbdata:/var/lib/postgresql/data volumes: dbdata:depends_on控制启动顺序,保证 db 先起来。但要注意,depends_on只保证容器启动顺序,不保证 db 里的服务已经就绪。应用启动太快连不上数据库的情况很常见,解决办法是在应用里做重试,或者用健康检查配合depends_on的条件形式。
compose 里服务之间可以用服务名互相访问,比如应用连数据库直接用db作为主机名,这是 compose 内置的网络能力,不用记 IP。
5. 部署后的验证与常见问题排查
5.1 服务是否真的起来了
容器跑起来不代表服务正常。我一般按这个顺序验证:
docker ps docker logs myapp curl http://localhost:8080/healthdocker ps看容器状态,Up 表示在跑,Exited 表示挂了。docker logs看应用输出,启动日志里能看到监听地址、环境、有没有报错。curl打一下健康检查接口,确认服务真的能响应。
如果docker ps里容器状态是 Up 但 curl 不通,先确认端口映射对不对,再确认应用监听的端口和映射的容器端口是否一致。我遇到过应用监听 5000 但映射的是 8080,结果怎么都连不上,日志里其实写得很清楚,只是没仔细看。
5.2 常见问题速查表
下面这张表是我这些年踩坑攒下来的,基本覆盖了部署 NetCore 到 Docker 时八成以上的问题。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 容器启动后立即退出 | 应用启动报错 | docker logs看异常堆栈 |
| 容器 Up 但访问不通 | 端口映射错误或应用监听端口不符 | 检查-p参数和ASPNETCORE_URLS |
| 连不上数据库 | 网络不通或连接串错误 | 确认服务名、端口、凭据 |
| 写日志报权限拒绝 | 挂载目录权限不对 | 检查目录归属和运行用户 |
| 构建时还原包失败 | 网络问题或源配置 | 配置镜像源或代理参数 |
| 镜像体积异常大 | 构建运行未分离 | 检查是否多阶段构建 |
| 中文乱码 | 基础镜像缺少字体或 locale | 安装对应字体包或设置 locale |
| 时间不对 | 容器时区默认 UTC | 挂载时区文件或设 TZ 变量 |
时区这个问题特别隐蔽。容器默认是 UTC,应用里DateTime.Now拿到的比北京时间少 8 小时,日志时间对不上,排查问题时容易误导。解决办法是运行时设TZ=Asia/Shanghai环境变量,或者把宿主机的时区文件挂载进去。
5.3 日志与调试的实用技巧
容器里的日志默认走标准输出,docker logs能看。但生产环境日志量大,docker logs看不过来,而且容器重启日志就没了。所以生产环境一定要把日志落到挂载出来的目录,或者接入集中式日志系统。
调试的时候,如果容器起不来,可以用覆盖入口的方式进容器看看:
docker run -it --entrypoint /bin/bash myapp:1.0.0这样会以 bash 启动容器,你可以手动执行dotnet YourApp.dll看具体报什么错。这个技巧在排查启动阶段问题时特别好用,比干看日志直观。
还有一个技巧是docker exec进正在运行的容器:
docker exec -it myapp /bin/bash进去之后可以看文件、测网络、查进程,相当于在一个精简的 Linux 环境里操作。不过要注意,官方运行时镜像为了精简,可能没装 curl、ping 这些工具,需要的话得自己在 Dockerfile 里装。
5.4 资源限制与稳定性考量
容器默认可以用宿主机的全部资源,一个服务出问题可能把整台机器拖垮。生产环境建议加上资源限制:
docker run -d --name myapp \ --memory=512m \ --cpus=1.0 \ -p 8080:8080 \ myapp:1.0.0--memory限制内存,--cpus限制 CPU 核数。NetCore 的垃圾回收器对容器内存限制是感知的,设了内存限制后 GC 会相应调整策略,所以这个限制不只是防拖垮,对应用本身的行为也有影响。
另外建议配置重启策略,容器意外退出时自动拉起:
docker run -d --name myapp --restart unless-stopped -p 8080:8080 myapp:1.0.0unless-stopped表示除非手动停止,否则总是重启。这样机器重启或者应用崩溃后,服务能自动恢复。
6. 从单机部署到可持续维护
6.1 镜像仓库与版本管理
单机部署玩熟了,下一步就是把镜像管起来。本地构建的镜像只在这台机器上有,换台机器就得重新构建。把镜像推到镜像仓库,任何机器都能拉下来跑。
镜像标签策略我建议用语义化版本,比如1.2.3,配合 git 的 tag。每次发布打个 tag,构建对应版本的镜像推上去。回滚的时候直接拉旧版本镜像重新跑,几秒钟的事。这比传统部署方式里改代码、重新编译、重新传文件快太多了。
6.2 更新部署的平滑操作
更新服务的时候,直接停旧容器起新容器会有短暂的服务中断。如果对可用性有要求,可以用滚动更新的思路:先起新容器,确认健康后再停旧容器。单机上端口会冲突,所以要么用不同的宿主机端口,前面挂个反向代理做切换,要么用 compose 的 scale 配合负载均衡。
简单场景下,我的做法是:新版本镜像拉下来,起一个新容器在另一个端口,curl 健康检查通过后,改反向代理指向新端口,再停掉旧容器。整个过程服务不中断,回滚也就是把代理指回去。
6.3 我踩过的几个印象深刻的坑
第一个坑是镜像标签用了 latest,结果某次官方更新了基础镜像,重新构建后应用行为变了,排查了半天才发现是基础镜像的问题。从那以后所有基础镜像都锁具体版本。
第二个坑是挂载日志目录时权限没设对,应用写日志一直失败,但应用本身不报错,只是日志丢了。后来在 Dockerfile 里显式创建目录并 chown 给运行用户,问题才解决。
第三个坑是容器时区,前面提过,日志时间差 8 小时,排查一个定时任务问题时被误导了很久。现在我的模板里 TZ 变量是标配。
第四个坑是构建缓存。有次改了 csproj 里的包版本,但构建时缓存没失效,用的还是旧包,运行时报方法找不到。后来养成习惯,改依赖后加--no-cache重新构建一次确认。
6.4 后续可以扩展的方向
单机 Docker 部署是容器化的起点,往后走有几个方向。一是上 compose 把整套依赖编排起来,本地和测试环境一键拉起。二是接入 CI/CD,代码提交自动构建镜像、推仓库、部署到目标机器。三是往容器编排平台迁移,多机器、自动扩缩容、滚动更新这些能力单机给不了。
不过我的建议是别急着上复杂的编排,先把单机 Docker 这套玩透。镜像怎么构建、容器怎么跑、日志怎么看、问题怎么查,这些基本功扎实了,后面上什么平台都是水到渠成的事。我见过太多人一上来就搞编排,结果连容器起不来都排查不了,反而绕了远路。
这套流程我在好几台机器上反复跑过,从最初的磕磕绊绊到现在基本一次成型,靠的就是把每个环节的为什么搞清楚,而不是照抄命令。你把上面这些细节吃透,部署 NetCore 到 Docker 这件事,基本就不会再给你添堵了。