LWDW!

Learn the work from doing the work🍺

从Dev到DevOps,借助Docker在Azure上部署你的第一个全栈应用
Posted on 2024-05-24

简介

作为开发者,我们都已经很熟悉本地开发环境了,但如何将一个本地应用部署到线上,或许还是触及到了你的一些知识盲区。

本地一切爽,线上火葬场

本文将一步步的带你部署一个包含独立前后端的完整应用到Azure,希望可以帮助你理解部署应用到线上的大致思路。

本文所涉及的应用以及workflow的源代码:fullstack-demo

主角登场

在开始之前,介绍一下本文的主角们:

  • 应用构成
    • 前端:React
    • 后端:Nodejs
    • ORM:Prisma
    • 数据库:PostgresQL
  • 代码托管:Github
  • CICD:Github Actions
  • 容器技术:Docker
  • 容器镜像托管:Docker Hub
  • 反向代理:Nginx
  • 云服务:Azure
    • App Service
    • 数据库服务:Azure Database for PostgreSQL flexible server

好,停一下,看到这里其实不难看出此次教程的大致思路,让我拉一张图给你感受一下:

pipeline最终效果

可以看出,我们的pipeline以容器技术为核心,如果你对docker一类的容器技术还不熟悉,建议先去自行了解下。

另外,该流程绝非线上部署的最佳实践,只是提供一种基于容器的部署思路,且该流程也缺乏很多production环境应该考虑的部分,还请大家仅作学习参考。

最后,如果你不熟悉以上的某个具体的服务,也不要紧,因为其中任何一块都可以被其他类似的服务替代。比如:

  • Azure => AWS
  • Docker Hub => Azure Container Registry
  • Github => Gitlab

好,我们接下来就开始一步步的实现上面的部署流程!

分析一下本次要部署的Web应用

在想着如何部署线上之前,我们先要对应用有一个大致的了解:

  1. 由哪些服务构成
  2. 这些服务在本地是如何启动的
  3. 确保能够在本地将应用完整的运行起来

本次要部署的应用是一个BBS,用户可以在上面完成发帖,顶帖,踩贴这样非常基础的功能。

这个应用由三部分组成:

  1. React搭建的客户端
  2. Nodejs搭建的服务端
  3. postgres数据库服务,服务端将使用Prisma与其交互

是非常典型的前后端分离应用。

客户端

我们先看客户端的部分,它有以下特征:

  • 使用webpack构建的React应用
  • 构建产物为纯静态资源

它本地启动的方式,在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": "" },
      },
    },
  }

这里有两个重要信息,在本地开发时:

  1. 客户端应用会被暴露在localhost:3000
  2. webpack的反向代理功能做了两件事:
    1. 将/api来源的请求指向了localhost:8000
    2. 重写了path,路径中的/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'

在本地将所有的服务跑起来

除了上述的命令外,我们还需要额外做两件事:

  1. 设置环境变量DATABASE_URL,让服务端能够访问数据库
  2. 使用Prisma Migrate数据库,否则本地数据库将无法同步我们的服务端将要访问的database,tables等等

完整的步骤可以参考示例代码的README

总结

我们的应用在本地是如此运行的,其实并不复杂:

how-client-works-full.png

将一切搬到线上

pipeline最终效果

让我们再次回顾一下这张图,将代码同步到Github想必不会难倒你,那剩下要做的事情大致还有这么几件:

  • 准备各个服务的docker image
  • 准备workflow
    • 构建各服务的docker image并推送到docker hub
    • 从docker hub拉取image并部署到Azure
  • 配置Azure相关的App services等

1. 准备docker image

这部分就不过多叙述了,因为如何为你的服务编写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 --from=build /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

2. 新增workflow,将image推送到docker hub

现在我们开始准备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

docker images

3. 新增Azure的数据库

在继续进行之前,我们先把线上的数据库准备好。这里我选用了Azure官方的Azure Database for PostgreSQL server。需要注意数据库是会产生费用的

数据库创建时,验证手段我选用了仅PostgreSQL验证(PostgreSQL authentication only),请记住你的username和password。

在创建成功后,你的数据库应该有一个独特的Server Name为:<your-database-name>.postgres.database.azure.com

postgreSQL serviec

另外你应该去Networking中为数据库设置防火墙规则,仅允许你当前的客户端ip访问。

firewall rule

数据库准备好后,你可以尝试用pgAdmin一类的工具去连接它,确保配置没有问题。

4. 配置Azure的App Services

数据库准备好后。让我们分别为客户端和服务端创建App Services。如何创建一个App Service这里也不做赘述,注意以下几点:

  • 创建一个Web App
  • 可以先随便部署一个默认镜像(比如Nginx)

每个app service都会有一个default domain,格式为<your-web-app-name>.azurewebsites.net,这个地址默认是可以在公网访问的。我现在分别为客户端和服务端创建了app service:

app services

5. 设置必要的环境变量

还记得我们在本地启动服务端应用的时候,服务端用到了一个环境变量么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的信息是这样:

  • Server Name:test.postgres.database.azure.com
  • 账号:testuser
  • 密码:HolyPostgres9876

那线上的DATABASE_URL就是postgresql://testuser:HolyPostgres9876@test.postgres.database.azure.com:5432/mydb?schema=public

那么在哪里设置这个值呢?由于这个值现在是服务端的App Service运行container所需要的,我们应该去对应的App Service的Environment variables去设置它:

Environment variables

6. 新增workflow,让Azure拉取我们的docker image并进行部署

# 以部署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:

container image updated

7. Migrate Azure数据库

对于线上数据库,我们同样也需要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"

现在我们在部署服务端镜像时,数据库结构也能保持更新。

8. 验证客户端与服务端的状态

此时我们可以分别访问客户端与服务端的默认域名。

客户端域名此时应该可以加载出页面,尽管请求都404——非常合理,因为请求目前都没有命中到后端服务:

request 404

而服务端域名此时应该可以返回一些接口响应了:

server

9. 配置Nginx

在本地开发时,我们使用了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,我们的服务应该就可以正常在线上运行了!

线上成功运行

10. 线上架构

现在我们可以看一下这个应用的线上架构,其实非常简单,而且与本地服务结构类似:

客户端线上架构

总结&提升

本文的方案非常的单纯,核心就是把本地应用都分别镜像化,然后部署到线上而已。但我们目前的应用有一个显然的问题:客户端服务的域名和服务端服务的域名都是可以在公网被访问的!考虑到我们希望使用Nginx服务作为公网流量的入口,将客户端和服务端的域名限制在内网才更为合理。

要做到这一点,我们可以请出两个关键的服务:

  • Applicatoin Gateway
  • Virtual networks

通过将前后端服务限制在Virtual Networks中,并通过Application Gateway访问,可以让我们的服务更加安全。而且显然Application Gateway还提供了许多其他功能(负载均衡、自动扩容等等)。只不过它有点贵,我小试了一下一天将近50块(货币单位为日元):

应用网关费用

但如果你对这部分内容感兴趣,可以看看这个油管视频:App Service Application Gateway Configuration

最后,本文作者在云服务方面完完全全是个新手,如果你阅读本文后发现有错误或者可以优化的地方,欢迎指正,感谢🙏