新版的Django Docker部署方案,多階段構建、自動處理前端依賴

程序设计实验室發表於2024-08-13

前言

前幾天的文章中,我們已經把使用 pdm 的專案用 docker 搞定了,那麼下一步就是把完整的 DjangoStarter v3 版本用 docker 部署。

現在不像之前那麼簡單直接一把梭了,因為專案用了 npm, gulp 之類的工具來管理前端依賴,又使用 pdm 管理 python 依賴,所以這波我用上了多階段構建(multi-stage build)

而且這次還把 uwsgi 給替換掉了。不過先別說 uwsgi 老歸老,效能還是不錯的,只不過現在已經是 asgi 時代了,wsgi 限制還是有點多,再加上我這次部署的專案用到了 channels ,所以就順理成章用上了它推薦的 daphne 伺服器,感覺還行,更多用法還在探索中,後續搞出來就寫文章來記錄。

在踩過很多坑之後,終於把這套玩意搞定了。

PS:折騰這玩意真是心累…感覺自己就是一個無情的網路搬運工,根據搜尋引擎和官方文件搜尋到的資料(現在還可以加上GPT),不斷排列組合,最終形成可以用的方案😂

本文記錄一下折騰的過程,同時新的 docker 部署方案很快就合併入 DjangoStarter 專案的 master 分支。

一些概念

深刻理解 docker 的工作原理有助於避免很多問題

每次遇到很多坑焦頭爛額之後,都會深深感覺自己還是太菜了

多階段構建

在Dockerfile中,使用多個 FROM 指令並透過 AS 關鍵字為每個 FROM 指定一個名稱的功能被稱為 “多階段構建”(multi-stage build)

多階段構建允許你在一個Dockerfile中使用多個基礎映象,並且可以在構建過程中選擇性地從某個階段複製構建結果到另一個階段。這樣做的好處是,你可以在前面的階段中使用一個較大的映象來進行構建工作(例如編譯程式碼),然後在最終階段中只保留必要的檔案,將它們放入一個更小的基礎映象中,以減少最終映象的大小。

多階段構建極大地簡化了構建複雜映象的流程,同時也有助於保持最終映象的體積較小。

有幾點要注意的:

  • 每個階段之間不需要指明依賴關係,build 的時候會同時進行構建,只有遇到 COPY --from=<階段名稱>COPY --from=<階段索引> 這個語句才會等待依賴的階段構建完。
  • 每個 FROM 指令都會啟動一個新的構建階段。這些階段是獨立的,一個階段的環境變數、檔案系統狀態等不會自動傳遞給下一個階段,每個階段可以選擇從任何之前的階段中複製構建成果。
  • 多階段構建中,Dockerfile中的指令是順序執行的,前面的階段不會自動傳遞檔案或狀態到後面的階段,除非明確使用 COPY --from=<階段> 指令。

Docker Volumes 機制

Docker volumes 是由 Docker 管理的資料卷,用於在容器之間以及容器和宿主機之間持久儲存和共享資料的機制。

  • 即使容器被刪除,儲存在 volumes 中的資料仍然存在
  • 可以將同一個 volume 掛載到多個容器上,實現資料的共享
  • volume 資料儲存在 Docker 主機的特定區域,容器只是透過掛載點訪問這些資料
  • 優先順序(很重要),容器執行時掛載的 volume 會覆蓋容器中相應路徑的內容。(我就是因為這個覆蓋的問題,導致 static-dist 裡的檔案一直無法更新)

所以在靜態檔案共享的場景下,如何保持資料一致性就很重要了。

dockerfile

直接來看看我最終搞完的 dockerfile 吧

ARG PYTHON_BASE=3.11
ARG NODE_BASE=18

# python 構建
FROM python:$PYTHON_BASE AS python_builder

# 設定 python 環境變數
ENV PYTHONUNBUFFERED=1
# 禁用更新檢查
ENV PDM_CHECK_UPDATE=false

# 設定國內源
RUN pip config set global.index-url https://mirrors.cloud.tencent.com/pypi/simple/ && \
    # 安裝 pdm
    pip install -U pdm && \
    # 配置映象
    pdm config pypi.url "https://mirrors.cloud.tencent.com/pypi/simple/"

# 複製檔案
COPY pyproject.toml pdm.lock README.md /project/

# 安裝依賴項和專案到本地包目錄
WORKDIR /project
RUN pdm install --check --prod --no-editable

# node 構建
FROM node:$NODE_BASE as node_builder

# 配置映象 && 安裝 pnpm
RUN npm config set registry https://registry.npmmirror.com && \
    npm install -g pnpm

# 複製依賴檔案
COPY package.json pnpm-lock.yaml /project/

# 安裝依賴
WORKDIR /project
RUN pnpm i


# gulp 構建
FROM node:$NODE_BASE as gulp_builder

# 配置映象 && 安裝 pnpm
RUN npm --registry https://registry.npmmirror.com install -g gulp-cli

# 複製依賴檔案
COPY gulpfile.js /project/

# 從構建階段獲取包
COPY --from=node_builder /project/node_modules/ /project/node_modules

# 複製依賴檔案
WORKDIR /project
RUN gulp move


# django 構建
FROM python:$PYTHON_BASE as django_builder

COPY . /project/

# 從構建階段獲取包
COPY --from=python_builder /project/.venv/ /project/.venv
COPY --from=gulp_builder /project/static/ /project/static

WORKDIR /project
ENV PATH="/project/.venv/bin:$PATH"
# 處理靜態資源資源
RUN python ./src/manage.py collectstatic


# 執行階段
FROM python:$PYTHON_BASE-slim-bookworm as final

# 從構建階段獲取包
COPY --from=django_builder /project/.venv/ /project/.venv
COPY --from=django_builder /project/static-dist/ /project/static-dist
ENV PATH="/project/.venv/bin:$PATH"
ENV DJANGO_SETTINGS_MODULE=config.settings
ENV PYTHONPATH=/project/src
ENV PYTHONUNBUFFERED=1
COPY src /project/src
WORKDIR /project

multi-stage build

這個 dockerfile 裡有這幾個構建階段,看名字可以可以看出個大概了

  • python_builder: 安裝 pdm 包管理器和 python 依賴
  • node_builder: 安裝前端依賴
  • gulp_builder: 使用 gulp 工具處理整合前端資源
  • django_builder: 將前面幾個容器的構建成果裡拿出 python 依賴和前端資源,然後執行 collectstatic 之類的工作(當前僅此項,以後可能會增加其他的)
  • final: 最終完成後用於執行和生成映象

要點

在除錯這個 dockerfile 的過程中,有一些要關鍵點

  • 構建階段不要使用 slim 映象,以免環境太簡陋遇到一些奇奇怪怪的問題
  • 從 django_builder 階段開始,涉及到 python 的執行了,必須將虛擬環境中的 python 路徑加入環境變數
  • 因為 DjangoStarter v3 開始使用新的專案結構,原始碼都放在根目錄的 src 目錄下,所以在 final 階段需要把這個目錄加入 PYTHONPATH 環境變數,不然會遇到奇怪的包匯入問題(我在用 uwsgi 時就遇到了)
  • 還是 final 階段,我還設定了 DJANGO_SETTINGS_MODULE, PYTHONUNBUFFERED 等環境變數,不管有沒有用,先保持跟開發環境一致避免遇到問題

nginx conf

差點忘了這個 nginx 配置了 (事實上發了公眾號的推文忘記加入這部分)

其實很簡單,根據 channels 提供的例子做一下簡單的修改即可

upstream channels-backend {
    server app:8000;
}

server {
    listen 8001;
    server_name localhost;

    charset utf-8;
    # 限制使用者上傳檔案大小
    client_max_body_size 100M;

    location / {
        try_files $uri @proxy_to_app;
    }

    location @proxy_to_app {
        proxy_pass http://channels-backend;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_redirect off;
        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-Host $server_name;
    }

    # 靜態資源路徑
    location /static {
        alias /www/static-dist;
    }

    # 媒體資源,使用者上傳檔案路徑
    location /media {
        alias /www/media;
    }
}

access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;

server_tokens off;

docker-compose

我在 compose 配置裡用了一些環境變數

避免了每個專案都要去修改專案名啥的

先上配置,後面再來介紹。

services:
  redis:
    image: redis
    restart: unless-stopped
    container_name: $APP_NAME-redis
    expose:
      - 6379
    networks:
      - default
  nginx:
    image: nginx:stable-alpine
    container_name: $APP_NAME-nginx
    restart: unless-stopped
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - ./media:/www/media:ro
      - static_volume:/www/static-dist:ro
    depends_on:
      - redis
      - app
    networks:
      - default
      - swag
  app:
    image: ${APP_IMAGE_NAME}:${APP_IMAGE_TAG}
    container_name: $APP_NAME-app
    build: .
    restart: always
    environment:
      - ENVIRONMENT=docker
      - URL_PREFIX=
      - DEBUG=true
    #    command: python src/manage.py runserver 0.0.0.0:8000
    command: >
      sh -c "
      echo 'Starting the application...' &&
      cp -r /project/static-dist/* /project/static-volume/ &&
      exec daphne -b 0.0.0.0 -p 8000 -v 3 --proxy-headers config.asgi:application
      "
    volumes:
      - ./media:/project/media
      - ./src:/project/src
      - ./db.sqlite3:/project/db.sqlite3
      - static_volume:/project/static-volume
    depends_on:
      - redis
    networks:
      - default

volumes:
  static_volume:

networks:
  default:
    name: $APP_NAME
  swag:
    external: true

幾個要點

nginx

這裡面我加入了 nginx 容器,用來提供 web 服務,因為之前一直使用 uwsgi ,而是要 uwsgi 的 socket 模式是沒有靜態檔案功能的,要讓 uwsgi 提供靜態檔案服務,除非使用 HTTP 模式,不過那樣又失去了 uwsgi 的優勢了。所以我一直是用 nginx 來提供靜態檔案訪問。

不過現在已經不直接在伺服器上安裝 nginx 而是改成了 swag 容器一把梭,所以必須得用在 compose 里加一個 web 伺服器,既然如此,就還是繼續 nginx 吧,用得比較熟了。關於這個問題,之前這篇文章(專案完成小結 - Django-React-Docker-Swag部署配置)也有討論到。

image name

這次 build 出來的映象終於加上名字了…

為接下來 push 到 docker hub 鋪路

資料共享

因為之前一直是在本地執行 collectstatic 然後再上傳到伺服器,所以不存在靜態檔案的問題,但一點也不優雅,對於 CICD 來說也很不友好

這次 DjangoStarter v3 也一併解決這個痛點,把前端依賴和資源管理都整合到 docker 的 build 階段裡面了,所以需要使用 docker volume 來為 app 和 nginx 容器共享這部分靜態資源

正如開頭說的 volume 優先順序更高,導致就算後面修改了一些 static 資源,build 後重啟也是用已經 mounted 到 volume 裡的舊版,所以這裡我把 app 容器的 /project/static-volume 掛載到共享的 static_volume ,然後再把 /project/static-dist 裡的檔案複製過去,而不是直接掛載到 /project/static-dist 裡,這樣會導致 static_volume 裡的舊資料把 collectstatic 出來的檔案覆蓋掉。

還有其他的方式,比如每次啟動前先把 static_volume 裡的檔案清理掉,然後再掛載,不過試了下有點折騰,我就放棄了。

關於應用伺服器的選擇

Django(或者說是 Python 系的後端框架)不像 .netcore, springboot 這類框架一樣內建 kestrel, tomcat 之類的伺服器,所以部署到生產環境只能藉助應用伺服器。

常見的 Django 應用伺服器有 uWSGI、Gunicorn、Daphne、Hypercorn、Uvicorn 等

之前一直使用 uWSGI ,這是個功能強大且高度可配置的 WSGI 伺服器,支援多執行緒、多程序、非同步工作模式,並且具有豐富的外掛支援。它的高效能和靈活性使其成為許多大型專案的首選。然而,uWSGI 的配置相對複雜,對於新手來說可能不太友好。uWSGI 的高複雜性在某些場景下可能導致配置錯誤或難以除錯。

然後在本文的例子裡,使用 uwsgi 部署一直出問題,因為之前有個專案用到了 channels ,所以這次使用了 Daphne,這是 Django Channels 專案的核心部分,是一個支援 HTTP 和 WebSocket 的 ASGI 伺服器。而且也支援 HTTP2 之類的新功能,其實也挺不錯的。

如果需要處理 WebSocket 連線或使用 Django Channels,Daphne 是一個理想的選擇。Daphne 可以與 Nginx 等反向代理伺服器結合使用,以處理靜態檔案和 SSL 終端。

接下來我還打算試試 Gunicorn 和基於 ASGI 的 Uvicorn,這個好像在 FastAPI 專案裡用得比較多,Django 自從3.0開始支援非同步功能(也就是 ASGI),所以其實可以放棄傳統的 WSGI 伺服器了?

(實際上 daphne 挺好用的,我都有點懶得嘗試其他伺服器了)

其他的幾個我複製一些介紹

Gunicorn (Green Unicorn) 是一個輕量級的 WSGI 伺服器,專為簡單易用而設計。Gunicorn 的配置簡單,預設情況下就能提供較好的效能,因此在開發和生產環境中都被廣泛使用。與 uWSGI 相比,Gunicorn 的學習曲線較低,適合大多數 Django 專案。

Hypercorn 是一個現代化的 ASGI 伺服器,支援 HTTP/2、WebSocket 和 HTTP/1.1 等多種協議。它可以執行在多種併發模式下,如 asyncio 和 trio。Hypercorn 適用於需要使用 Django Channels、WebSocket,或者希望在未來支援 HTTP/2 的專案。

Uvicorn 是一個基於 asyncio 的輕量級、高效能 ASGI 伺服器,專為速度和簡潔性而設計。它支援 HTTP/1.1 和 WebSocket,同時也是 HTTP/2 的早期支持者。Uvicorn 通常與 FastAPI 搭配使用,但它同樣適用於 Django,特別是當你使用 Django 的非同步特性時。Uvicorn 的配置相對簡單,且啟動速度非常快,適合開發環境和需要非同步處理的生產環境。

ASGI 已經是未來的趨勢了,所以接下來還是放棄 WSGI 吧…

小結

之前折騰的時候花了很多時間,實際總結下來也沒啥,就那幾個關鍵點。但因為對 docker, WSGI, ASGI, Python執行機制等理解不夠深刻,所以就導致踩了很多坑,最終靠排列組合完成了這套 docker 方案……😂

就這樣吧,接下來我會繼續完善 DjangoStarter v3 ,最近還有其他一些關於 Django 的開發經驗可以記錄的。

參考資料

  • 如何建立高效的 Python Docker 映象 - https://www.linuxmi.com/python-docker-images.html

相關文章