这篇文章我们来解决一下客户端调用接口404问题,这个问题是大多数刚接触docker微服务的人会遇到的常见困扰。在本地开发环境中,我们的API接口调用一切正常,应用程序能够顺利运行。但是当我们将应用部署到docker容器中后,却发现客户端无法正常访问API接口,总是返回404错误。这种情况的出现往往让开发人员感到困惑,因为代码明明在本地运行得好好的,为什么容器化后就出现了问题?在这篇文章中,我们将深入探讨这个问题的根源,并提供一个解决方案,帮助大家顺利完成微服务容器化。
一、问题分析
在排查这个问题时,我们可以首先排除代码本身存在bug的可能性。因为在本地开发环境中,所有的API调用都能正常工作,这说明代码逻辑本身是没有问题的。经过仔细分析,我们发现这个问题很可能与Docker容器的端口映射配置有关。具体来说,在编写各个微服务的Dockerfile文件时,我们统一将服务的内部监听端口和对外暴露的端口都设置为80,代码如下:
# more code ...
EXPOSE 80
EXPOSE 443
# more code ...
# 设置环境变量
ENV ASPNETCORE_URLS=http://+:80
# more code ...
然而,在实际部署过程中,我们在每个微服务的GitHub Actions工作流配置文件中都为不同的服务指定了独特的端口映射。这种配置方式意味着,尽管容器内部统一使用80端口作为服务端口,但在宿主机上,每个服务都会映射到一个不同的端口号上,比如将容器内的80端口映射到宿主机的8397端口,代码如下:
# more code ...
docker pull ${{ secrets.DOCKER_USERNAME }}/sp-identity-service:${{ github.event.inputs.tag || 'latest' }}
docker stop sp-identity-service || true
docker rm sp-identity-service || true
docker run -d --name sp-identity-service -p 8397:80 -e ASPNETCORE_ENVIRONMENT=${{ env.ENVIRONMENT }} ${{ secrets.DOCKER_USERNAME }}/sp-identity-service:${{ github.event.inputs.tag || 'latest' }} --urls http://0.0.0.0:80
表面上看,我们的Docker容器配置似乎是正确的,容器内部使用80端口提供服务,并通过端口映射将其暴露给宿主机的特定端口(如8397)。然而,这里存在一个微妙但关键的问题:当我们的微服务向Nacos注册中心注册自己时,它会使用容器内部的网络信息,即使用localhost(127.0.0.1)作为服务地址,并使用容器内部的80端口。这就导致了一个严重的服务发现问题。
当API网关需要转发请求时,它会从Nacos获取服务地址信息。由于服务注册时使用的是容器内部地址(127.0.0.1:80),网关就会尝试访问这个地址。但是在网关所在的网络环境中,这个地址指向的是网关容器自身,而不是目标服务容器。这就解释了为什么我们会遇到404错误,网关在错误的位置寻找服务,自然无法找到对应的API端点。
二、解决方案
要解决这个问题,我们需要确保服务注册时使用的是可以被其他服务访问到的正确网络地址和端口。这需要我们修改服务注册的配置,使其使用宿主机的IP地址和映射后的端口号,而不是容器内部的网络信息。我们要做的是修改github action文件,在构建docker容器时指定EXPOSE_PORT
和HOST_IP
,然后在我们的微服务项目中读取这个两个配置,之后在服务注册时使用这个配置。我们以SP.IdentityService
服务为例,来看一下如果修改。
2.1 修改github action文件
首先,打开SP.IdentityService
服务的github action文件,修改里面的构建docker容器的参数,修改后的代码如下:
name: Deploy Identity Service
on:
push:
branches: [ Microservices ]
paths:
- 'SP.IdentityService/**'
workflow_dispatch:
inputs:
environment:
description: '部署环境'
required: true
default: 'Development'
type: choice
options:
- Development
- Production
tag:
description: 'Docker 镜像标签'
required: false
default: 'latest'
type: string
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./SP.IdentityService/Dockerfile
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/sp-identity-service:${{ github.event.inputs.tag || 'latest' }}
- name: Set deployment environment
run: |
if [ "${{ github.event_name }}" == "push" ]; then
echo "ENVIRONMENT=Development" >> $GITHUB_ENV
else
echo "ENVIRONMENT=${{ github.event.inputs.environment }}" >> $GITHUB_ENV
fi
- name: Deploy to Server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
HOST_IP=$(curl -fsS https://api.ipify.org || curl -fsS https://ifconfig.me || curl -fsS https://ipinfo.io/ip)
if [ -z "$HOST_IP" ]; then HOST_IP=$(ip -o route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}'); fi
if [ -z "$HOST_IP" ]; then HOST_IP=$(hostname -I | awk '{print $1}'); fi
docker pull ${{ secrets.DOCKER_USERNAME }}/sp-identity-service:${{ github.event.inputs.tag || 'latest' }}
docker stop sp-identity-service || true
docker rm sp-identity-service || true
docker run -d --name sp-identity-service -p 8397:80 \
-e ASPNETCORE_ENVIRONMENT=${{ env.ENVIRONMENT }} \
-e HOST_IP=$HOST_IP \
-e EXPOSE_PORT=8397 \
${{ secrets.DOCKER_USERNAME }}/sp-identity-service:${{ github.event.inputs.tag || 'latest' }}
在 Deploy to Server 步骤的 script 部分中,我们实现了一个关键的配置过程。这个过程首先通过多重备选方案来获取服务器的真实IP地址,优先使用curl
命令访问外部IP查询服务(如ipify.org、ifconfig.me或ipinfo.io),如果这些服务都无法访问,则会回退到使用本地网络配置命令来获取IP地址。这种多层次的IP获取策略确保了我们能在各种网络环境下都能可靠地获取到服务器的IP地址。获取到的IP地址会被存储在HOST_IP
环境变量中,这个变量后续将被用于服务注册过程。在启动容器时,我们使用docker run
命令不仅启动了sp-identity-service
容器,还通过-e
参数向容器传入了两个至关重要的环境变量:HOST_IP
和EXPOSE_PORT
。
Tip:由于我们目前处于开发阶段,一个微服务在开发服务器上只有一个实例,因此暂时先将
EXPOSE_PORT
参数写死,后续我们在部署阶段这个参数会动态指定。
2.2 修改Program.cs文件
然后我们修改Program.cs
文件,在服务启动时读取构建docker容器时传入的HOST_IP
和EXPOSE_PORT
。代码如下:
// more code ...
// 覆盖 Nacos 注册的 IP/Port 为宿主机IP + 对外端口(通过环境变量或配置传入)
var hostIp = Environment.GetEnvironmentVariable("HOST_IP") ?? builder.Configuration["HOST_IP"];
var exposePort = Environment.GetEnvironmentVariable("EXPOSE_PORT") ?? builder.Configuration["EXPOSE_PORT"];
if (!string.IsNullOrWhiteSpace(hostIp) || !string.IsNullOrWhiteSpace(exposePort))
{
var overrides = new Dictionary<string, string?>();
if (!string.IsNullOrWhiteSpace(hostIp)) overrides["nacos:Ip"] = hostIp;
if (!string.IsNullOrWhiteSpace(exposePort)) overrides["nacos:Port"] = exposePort;
builder.Configuration.AddInMemoryCollection(overrides);
}
// more code ...
在上面的代码中,我们实现了一个灵活的服务注册配置机制。这个机制首先会尝试从环境变量中读取HOST_IP
和EXPOSE_PORT
这两个关键参数,这些参数通常是在Docker容器启动时通过环境变量注入的。如果在环境变量中没有找到这些配置,系统会退而求其次,转向应用程序的配置文件来获取这些值。这种多层次的配置获取策略确保了我们的服务能够在不同的部署环境中灵活适应。当这两个参数中的任何一个被成功获取到时,我们就会相应地更新Nacos的服务注册信息。具体来说,如果获取到了HOST_IP
,我们就用这个值来替换Nacos默认的服务IP地址,确保其他服务能够通过正确的宿主机IP地址访问到本服务;如果获取到了EXPOSE_PORT
,我们就用这个值来更新Nacos中的服务端口信息,使其反映服务在宿主机上实际对外暴露的端口号。如果这两个参数都没有被配置,系统会保持Nacos的默认注册行为,这在某些特定的部署场景下可能是需要的。
三、总结
通过本文的探讨,我们深入分析了在Docker微服务环境中常见的404接口调用问题,并提供了一个实用的解决方案。这个问题的核心在于服务注册时使用了容器内部的网络信息,导致其他服务无法正确访问。我们通过在GitHub Actions工作流中获取宿主机IP并通过环境变量传递给容器,再在服务启动时动态配置Nacos的注册信息,成功解决了这个问题。