作为开发者,我们都已经很熟悉本地开发环境了,但如何将一个本地应用部署到线上,或许还是触及到了你的一些知识盲区。
本文将一步步的带你部署一个包含独立前后端的完整应用到Azure,希望可以帮助你理解部署应用到线上的大致思路。
本文所涉及的应用以及workflow的源代码:fullstack-demo
在开始之前,介绍一下本文的主角们:
好,停一下,看到这里其实不难看出此次教程的大致思路,让我拉一张图给你感受一下:
可以看出,我们的pipeline以容器技术为核心,如果你对docker一类的容器技术还不熟悉,建议先去自行了解下。
另外,该流程绝非线上部署的最佳实践,只是提供一种基于容器的部署思路,且该流程也缺乏很多production环境应该考虑的部分,还请大家仅作学习参考。
最后,如果你不熟悉以上的某个具体的服务,也不要紧,因为其中任何一块都可以被其他类似的服务替代。比如:
好,我们接下来就开始一步步的实现上面的部署流程!
在想着如何部署线上之前,我们先要对应用有一个大致的了解:
本次要部署的应用是一个BBS,用户可以在上面完成发帖,顶帖,踩贴这样非常基础的功能。
这个应用由三部分组成:
是非常典型的前后端分离应用。
我们先看客户端的部分,它有以下特征:
它本地启动的方式,在package.json
中可以看到:
"start": "webpack-dev-server --mode development"
webpack-dev-server
从名字就可以看出是用于本地开发的服务,虽然我们不会在线上使用它,但是有必要看看它的相关配置,于是我们就点开了webpack.config.ts
,寻找其中的devServer
字段:
devServer: {
port: 3000,
historyApiFallback: true,
proxy: {
"/api": {
target: "http://localhost:3000",
router: () => "http://localhost:8000",
// remove path
pathRewrite: { "^/api": "" },
},
},
}
这里有两个重要信息,在本地开发时:
/api
部分被移除了不难猜测,8000端口暴露的就是我们本地的服务端应用。
所以客户端在本地是这样工作的:
服务端是一个Node.js应用,更准去的说是Nest.js应用(一个基于Node.js的框架),我们看下它在本地是如何启动的:
"start": "nest start"
不是很有营养,既然这里没有额外的参数,那我们就看看Nestjs的入口文件main.ts
:
await app.listen(8000);
所以服务端是在本地8000端口运行的,符合我们上面的推测。
最后我们看一下数据库,数据库的启动非常简单,我们可以在docker-compose
中看到,这里使用了postgres官方镜像,并传入了一些必要的参数用于本地启动。可以看出数据库服务启动在5432端口。
# Postgres container
postgres:
image: postgres:15
restart: always
environment:
- POSTGRES_USER=myuser
- POSTGRES_PASSWORD=mypassword
volumes:
- postgres:/var/lib/postgresql/data
ports:
- '5432:5432'
除了上述的命令外,我们还需要额外做两件事:
DATABASE_URL
,让服务端能够访问数据库完整的步骤可以参考示例代码的README。
我们的应用在本地是如此运行的,其实并不复杂:
让我们再次回顾一下这张图,将代码同步到Github想必不会难倒你,那剩下要做的事情大致还有这么几件:
这部分就不过多叙述了,因为如何为你的服务编写dockerfile可以说是一个case by case的事,这里可以参考我的dockerfile,相信并不难理解。
总的来说我们只需要分别准备客户端和服务端的docker image,而线上数据库服务我们将会直接使用Azure的Azure Database for PostgreSQL flexible server
。
# 以client的dockerfile为例
# stage 1 - build react app first
FROM node:18 AS build
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build
# stage 2 - serve the react app on nginx
FROM nginx:latest
USER root
COPY /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
现在我们开始准备workflow,首先我们先构建docker image,并将它们上传到docker hub:
# 以构建并上传client image为例
name: Build and Push Client Docker Image
on:
push:
branches:
- dev
paths:
- "client/**" # Monitor changes in the client folder
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
environment: dev
steps:
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }}
- name: Publish Docker image to Docker Hub
uses: docker/build-push-action@v5
with:
context: "{{defaultContext}}:client" # the path where dockerfile located
push: true
tags: "${{ vars.CLIENT_IMAGE }}:latest"
这里用到了两个docker官方action,具体使用方法可以参考它们的文档
如果workflow可以成功跑起来,你应该能在docker hub中看见自己的image
在继续进行之前,我们先把线上的数据库准备好。这里我选用了Azure官方的Azure Database for PostgreSQL server
。需要注意数据库是会产生费用的。
数据库创建时,验证手段我选用了仅PostgreSQL验证(PostgreSQL authentication only),请记住你的username和password。
在创建成功后,你的数据库应该有一个独特的Server Name为:<your-database-name>.postgres.database.azure.com
。
另外你应该去Networking
中为数据库设置防火墙规则,仅允许你当前的客户端ip访问。
数据库准备好后,你可以尝试用pgAdmin一类的工具去连接它,确保配置没有问题。
数据库准备好后。让我们分别为客户端和服务端创建App Services。如何创建一个App Service这里也不做赘述,注意以下几点:
Web App
每个app service都会有一个default domain
,格式为<your-web-app-name>.azurewebsites.net
,这个地址默认是可以在公网访问的。我现在分别为客户端和服务端创建了app service:
还记得我们在本地启动服务端应用的时候,服务端用到了一个环境变量么DATABASE_URL
?这个环境变量告诉服务端,Prisma如何连接数据库。在本地的时候,环境变量的值是:
DATABASE_URL="postgresql://myuser:mypassword@localhost:5432/mydb?schema=public"
这个值的结构是:postgresql://<username>:<password>@<server-domain>:<server-port>/<database-name>?schema=public
假设我们的Azure Database的信息是这样:
那线上的DATABASE_URL
就是postgresql://testuser:HolyPostgres9876@test.postgres.database.azure.com:5432/mydb?schema=public
那么在哪里设置这个值呢?由于这个值现在是服务端的App Service运行container所需要的,我们应该去对应的App Service的Environment variables
去设置它:
# 以部署client image为例
name: Deploy Client Image to Azure App Service
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to Azure Web App
id: deploy-to-webapp
uses: azure/webapps-deploy@v2
with:
app-name: "test-fullstack-client"
slot-name: "production"
publish-profile: ${{ secrets.AZURE_CLIENT_PUBLISH_PROFILE }}
images: "index.docker.io/${{ vars.CLIENT_IMAGE }}:latest"
这里用到了一个Azure的官方action:(Azure/webapps-deploy)[https://github.com/Azure/webapps-deploy/tree/v2],可以直接把对应的image部署到你指定的azure app service。
试着运行下你的workflow,如果成功的话,刷新Azure后你应该能看到Container Image
变为了你自己的Image:
对于线上数据库,我们同样也需要Migrate。
在本地运行项目的时候,我们运行了yarn migrate
,它实际上调用了npx prisma migrate dev
,prisma会读取本地的.env
,连接到本地数据库,并进行migrate。
尽管我们这不能算一个production案例,但总的来说还是不推荐使用这种方式去操作线上数据库。因此依照Prisma文档的建议,我们可以在Deploy Service Image的时候去Migrate线上数据库:
name: Deploy Server Image to Azure App Service
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
environment: dev
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install Dependencies
working-directory: ./server
run: yarn
# 重点在这里!
- name: Apply all pending migrations to the database
working-directory: ./server
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Deploy to Azure Web App
id: deploy-to-webapp
uses: azure/webapps-deploy@v2
with:
app-name: "test-fullstack-server"
slot-name: "production"
publish-profile: ${{ secrets.AZURE_SERVER_PUBLISH_PROFILE }}
images: "index.docker.io/${{ vars.SERVER_IMAGE }}:latest"
现在我们在部署服务端镜像时,数据库结构也能保持更新。
此时我们可以分别访问客户端与服务端的默认域名。
客户端域名此时应该可以加载出页面,尽管请求都404——非常合理,因为请求目前都没有命中到后端服务:
而服务端域名此时应该可以返回一些接口响应了:
在本地开发时,我们使用了Webpack Dev Server提供的proxy能力,连接起了前后端。能够做到类似事情的常见服务还有一个,那就是Nginx!
那么如何在线上运行这样一个Nginx服务?聪明的你一定想到了,再打一个镜像不就行了!
我们先依葫芦画瓢新建一个App Service供Nginx使用,Default Domain为test-fullstack.azurewebsites.net
然后在代码新建一个nginx配置文件:
# nginx.conf
events {}
http {
server {
server_name <your-nginx-app-service-name>.azurewebsites.net;
location / {
proxy_pass <your-client-app-service-name>.azurewebsites.net;
}
location /api {
rewrite /api/(.*) /$1 break;
proxy_pass <your-server-app-service-name>.azurewebsites.net;
}
}
}
一目了然,我们的nginx服务会分别将路由指向对应的服务,和webpack-dev-server的proxy作用几乎一模一样。
接下来我们只要仿照前面的步骤去将这个镜像构建并推送到docker hub,并进行部署。
这一步完成后,直接访问你的Nginx的Default Domain,我们的服务应该就可以正常在线上运行了!
现在我们可以看一下这个应用的线上架构,其实非常简单,而且与本地服务结构类似:
本文的方案非常的单纯,核心就是把本地应用都分别镜像化,然后部署到线上而已。但我们目前的应用有一个显然的问题:客户端服务的域名和服务端服务的域名都是可以在公网被访问的!考虑到我们希望使用Nginx服务作为公网流量的入口,将客户端和服务端的域名限制在内网才更为合理。
要做到这一点,我们可以请出两个关键的服务:
通过将前后端服务限制在Virtual Networks中,并通过Application Gateway访问,可以让我们的服务更加安全。而且显然Application Gateway还提供了许多其他功能(负载均衡、自动扩容等等)。只不过它有点贵,我小试了一下一天将近50块(货币单位为日元):
但如果你对这部分内容感兴趣,可以看看这个油管视频:App Service Application Gateway Configuration。
最后,本文作者在云服务方面完完全全是个新手,如果你阅读本文后发现有错误或者可以优化的地方,欢迎指正,感谢🙏