使用 Docker 讓部署 Django 項目更加輕鬆

  • 2019 年 10 月 11 日
  • 筆記

作者:HelloGitHub-追夢人物

文中涉及的示例程式碼,已同步更新到 HelloGitHub-Team 倉庫

之前一系列繁瑣的部署步驟讓我們感到痛苦。這些痛苦包括:

  • 要去伺服器上執行 n 條命令
  • 本地環境和伺服器環境不一致,明明本地運行沒問題,一部署伺服器上就掛掛,死活啟動不起來
  • 如果上面的情況發生了,又要去伺服器上執行 n 條命令以解決問題
  • 本地更新了程式碼,部署上線後,上述歷史又重演一遍,想死的心都有了

那麼我們有沒有辦法,讓本地開發環境和線上環境保持一致?這樣我們在部署上線前,就可以在本地進行驗證,只要驗證沒問題,我們就有 99% 的把握保證部署上線後也沒有問題(1%保留給程式玄學)。

這個辦法就是使用 Docker。

Docker 是一種容器技術,可以為我們提供一個隔離的運行環境。要使用 Docker,首先我們需要編排一個鏡像,鏡像就是用來描述這個隔離環境應該是什麼樣子的,它需要安裝哪些依賴,需要運行什麼應用等,可以把它類比成一搜貨輪的製造圖。

有了鏡像,就可以在系統中構建出一個實際隔離的環境,這個環境被稱為容器,就好比根據設計圖,工廠製造了一條船。工廠也可以製造無數條這樣的船。

容器造好了,只要啟動它,隔離環境便運行了起來。由於事先編排好了鏡像,因此無論是在本地還是線上,運行的容器內部環境都一樣,所以保證了本地和線上環境的一致性,大大減少了因為環境差異導致的各種問題。

所以,我們首先來編排 Docker 鏡像。

類似於分離 settings.py 文件為 local.py 和 production.py,我們首先建立如下的目錄結構,分別用於存放開發環境的鏡像和線上環境的鏡像:

HelloDjango-blog-tutorial        blog        ...        compose              local              production                    django                    nginx      ...

local 目錄下存放開發環境的 Docker 鏡像文件,production 下的 django 文件夾存放基於本項目編排的鏡像,由於線上環境還要用到 Nginx,所以 nginx 目錄下存放 Nginx 的鏡像。

線上環境

鏡像文件

我們先來在 productiondjango 目錄下編排部落格項目線上環境的鏡像文件,鏡像文件以 Dockerfile 命名:

FROM python:3.6-alpine    ENV PYTHONUNBUFFERED 1    RUN apk update     # Pillow dependencies    && apk add jpeg-dev zlib-dev freetype-dev lcms2-dev openjpeg-dev tiff-dev tk-dev tcl-dev    WORKDIR /app    RUN pip install pipenv -i https://pypi.douban.com/simple    COPY Pipfile /app/Pipfile  COPY Pipfile.lock /app/Pipfile.lock  RUN pipenv install --system --deploy --ignore-pipfile    COPY . /app    COPY ./compose/production/django/start.sh /start.sh  RUN sed -i 's/r//' /start.sh  RUN chmod +x /start.sh

首先我們在鏡像文件開頭使用 FROM python:3.6-alpine 聲明此鏡像基於 python:3.6-alpine 基礎鏡像構建。alpine 是一個 Linux 系統發行版,主打小巧、輕量、安全。我們程式運行需要 Python 環境,因此使用這個小巧但包含完整 Python 環境的基礎鏡像來構建我們的應用鏡像。

ENV PYTHONUNBUFFERED 1 設置環境變數 PYTHONUNBUFFERED=1

接下來的一條 RUN 命令安裝影像處理包 Pilliow 的依賴,因為如果使用 django 處理圖片時,會使用到 Pillow 這個Python 庫。

接著使用 WORKDIR /app 設置工作目錄,以後在基於此鏡像啟動的 Docker 容器中執行的命令,都會以這個目錄為當前工作目錄。

然後我們使用命令 RUN pip install pipenv 安裝 pipenv,-i 參數指定 pypi 源,中國一般指定為豆瓣源,這樣下載 pipenv 安裝包時更快,國外網路可以省略 -i 參數,使用官方的 pypi 源即可。

然後我們將項目依賴文件 Pipfile 和 Pipfile.lock copy 到容器里,運行 pipenv install 安裝依賴。指定 --system 參數後 pipenv 不會創建虛擬環境,而是將依賴安裝到容器的 Python 環境里。因為容器本身就是個虛擬環境了,所以沒必要再創建虛擬環境。

接著將這個項目的文件 copy 到容器的 /app 目錄下(當然有些文件對於程式運行是不必要的,所以一會兒我們會設置一個 dockerignore 文件,裡面指定的文件不會被 copy 到容器里)。

然後我們還將 start.sh 文件複製到容器的 / 目錄下,去掉回車符(windows 專用,容器中是 linux 系統),並賦予了可執行許可權。

start.sh 中就是啟動 Gunicorn 服務的命令:

#!/bin/sh    python manage.py migrate  python manage.py collectstatic --noinput  gunicorn blogproject.wsgi:application -w 4 -k gthread -b 0.0.0.0:8000 --chdir=/app

我們會讓容器啟動時去執行此命令,這樣就啟動了我們的 django 應用。--chdir=/app 表明以 /app 為根目錄,這樣才能找到 blogproject.wsgi:application。

在項目根目錄下建立 .dockerignore 文件,指定不 copy 到容器的文件:

.*  _credentials.py  fabfile.py  *.sqlite3

線上環境使用 Nginx,同樣來編排 Nginx 的鏡像,這個鏡像文件放到 composeproductionnginx 目錄下:

FROM nginx:1.17.1    # 替換為中國源  RUN mv /etc/apt/sources.list /etc/apt/sources.list.bak  COPY ./compose/production/nginx/sources.list /etc/apt/  RUN apt-get update && apt-get install -y --allow-unauthenticated certbot python-certbot-nginx    RUN rm /etc/nginx/conf.d/default.conf  COPY ./compose/production/nginx/HelloDjango-blog-tutorial.conf /etc/nginx/conf.d/HelloDjango-blog-tutorial.conf

這個鏡像基於 nginx:1.17.1 基礎鏡像構建,然後我們更新系統並安裝 certbot 用於配置 https 證書。由於要安裝大量依賴, nginx:1.17.1 鏡像基於 ubuntu,所以安裝會比較慢,我們將軟體源替換為中國源,這樣稍微提高一下安裝速度。

最後就是把應用的 nginx 配置複製到容器中 nginx 的 conf.d 目錄下。裡面的內容和直接在系統中配置 nginx 是一樣的。

upstream hellodjango_blog_tutorial  {      server hellodjango_blog_tutorial:8000;  }    server {      server_name  hellodjango-blog-tutorial-demo.zmrenwu.com;        location /static {          alias /apps/hellodjango_blog_tutorial/static;      }        location / {          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-Proto $scheme;            proxy_pass http://hellodjango_blog_tutorial;      }        listen 80;  }

相比之前直接在宿主機配置 Nginx,這裡使用了 Nginx 的 upstream 模組,實際上就是做一個請求轉發。Nginx 將所有請求轉發給上游 hellodjango_blog_tutorial 模組處理,而 hellodjango_blog_tutorial 這個模組的服務實際就是運行 django 應用的容器 hellodjango_blog_tutorial(接下來會運行這個容器)。

鏡像編排完畢,接下來就可以通過鏡像構建容器並運行容器了。但是先等一等,我們有兩個鏡像,一個是 django 應用的,一個是 Nginx 的,這意味著我們需要構建 2 次容器,並且啟動容器 2 次,這會比較麻煩。有沒有辦法一次構建,一條命令運行呢?答案就是使用 docker-compose。

docker-compose 將各個容器的鏡像,以及構建和運行容器鏡像時的參數等編寫在一個 ymal 文件里。這樣我們只需要一條 build 命令就可以構建多個容器,使用一條命令 up 就可以啟動多個容器。

我們在項目根目錄建一個 production.yml 文件來編排 django 容器和 nginx 容器。

version: '3'    volumes:    static:    database:    services:    hellodjango_blog_tutorial:      build:        context: .        dockerfile: compose/production/django/Dockerfile      image: hellodjango_blog_tutorial      container_name: hellodjango_blog_tutorial      working_dir: /app      volumes:        - database:/app/database        - static:/app/static      env_file:        - .envs/.production      ports:        - "8000:8000"      command: /start.sh      nginx:      build:        context: .        dockerfile: compose/production/nginx/Dockerfile      image: hellodjango_blog_tutorial_nginx      container_name: hellodjango_blog_tutorial_nginx      volumes:        - static:/apps/hellodjango_blog_tutorial/static      ports:        - "80:80"        - "443:443"

version: '3' 聲明 docker-compose 為第三代版本的語法

volumes:    static:    database:

聲明了 2 個命名數據卷,分別為 static 和 database。數據卷是用來幹嘛的呢?由於 docker 容器是一個隔離環境,一旦容器被刪除,容器內的文件就會一併刪除。試想,如果我們啟動了部落格應用的容器並運行,一段時間後,容器中的資料庫就會產生數據。後來我們更新了程式碼或者修改了容器的鏡像,這個時候就要刪除舊容器,然後重新構建新的容器並運行,那麼舊容器中的資料庫就會連同容器一併刪除,我們辛苦寫的部落格文章付之一炬。

所以我們使用 docker 的數據捲來管理需要持久存儲的數據,只要數據被 docker 的數據卷管理起來了,那麼新的容器啟動時,就可以從數據卷取數據,從而恢復被刪除容器里的數據。

我們有 2 個數據需要被數據卷管理,一個是資料庫文件,一個是應用的靜態文件。資料庫文件容易理解,那麼為什麼靜態文件也要數據卷管理呢?啟動新的容器後使用 python manage.py collectstatic 命令重新收集不就好了?

答案是不行,數據卷不僅有持久保存數據的功能,還有跨容器共享文件的功能。要知道,容器不僅和宿主機隔離,而且容器之間也是互相隔離的。Nginx 運行於獨立容器,那麼它處理的靜態文件從哪裡來呢?應用的靜態文件存放於應用容器,Nginx 容器是訪問不到的,所以這些文件也通過數據卷管理,nginx 容器從數據卷中取靜態文件映射到自己的容器內部。

接下來定義了 2 個 services,一個是應用服務 hellodjango_blog_tutorial,一個是 nginx 服務。

build:        context: .        dockerfile: compose/production/django/Dockerfile

告訴 docker-compose,構建容器是基於當前目錄(yml 文件所在的目錄),且使用的鏡像是 dockerfile 指定路徑下的鏡像文件。

image 和 container_name 分別給構建的鏡像和容器取個名字。

working_dir 指定工作目錄。

  • volumes:    - database:/app/database    - static:/app/static

    同時這裡要注意,數據卷只能映射文件夾而不能映射單一的文件,所以對我們應用的資料庫來說,db.sqlite3 文件我們把它挪到了 database 目錄下。因此我們要改一下 django 的配置文件中資料庫的配置,讓它正確地將資料庫文件生成在項目根目錄下的 database 文件夾下:

    DATABASES = {      'default': {          'ENGINE': 'django.db.backends.sqlite3',        'NAME': os.path.join(BASE_DIR, 'database', 'db.sqlite3'),      }  }
  • env_file:      - .envs/.production

    容器啟動時讀取 .envs/.production文件中的內容,將其注入環境變數。

    我們創建一下這個文件,把 secret_key 寫進去。

    DJANGO_SECRET_KEY=2pe8eih8oah2_2z1=7f84bzme7^bwuto7y&f(#@rgd9ux9mp-3

    注意將這些包含敏感資訊的文件加入版本控制工具的忽略列表裡,防止一不小心推送到公開倉庫供大眾觀光。

  • ports:    - "8000:8000"

    暴露容器內的 8000 埠並且和宿主機的 8000 埠綁定,於是我們就可以通過宿主機的 8000 埠訪問容器。

command: /start.sh 容器啟動時將執行 start.sh,從而啟動 django應用。

nginx 服務容器也類似,只是注意它從數據卷 static 中取靜態文件並映射到 nginx 容器內的 /apps/hellodjango_blog_tutorial/static,所以我們在 nginx 的配置中:

location /static {      alias /apps/hellodjango_blog_tutorial/static;  }

這樣可以正確代理靜態文件。

萬事具備,在本地執行一下下面的兩條命令來構建容器和啟動容器。

docker-compose -f production.yml build  docker-compose -f production.yml up

此時我們可以通過域名來訪問容器內的應用,當然,由於 Nginx 在本地環境的容器內運行,需要修改一下 本地 hosts 文件,讓域名解析為本地 ip 即可。

如果本地訪問沒有問題了,那麼就可以直接在伺服器上執行上面兩條命令以同樣的方式啟動容器,django 應用就順利地在服務上部署了。

開發環境

既然線上環境都使用 Docker 了,不妨開發環境也一併使用 Docker 進行開發。開發環境的鏡像和 docker-compose 文件比線上環境簡單一點,因為不用使用 nginx。

開發環境的鏡像文件,放到 composelocal 下:

FROM python:3.6-alpine    ENV PYTHONUNBUFFERED 1    RUN apk update     # Pillow dependencies    && apk add jpeg-dev zlib-dev freetype-dev lcms2-dev openjpeg-dev tiff-dev tk-dev tcl-dev    WORKDIR /app    RUN pip install pipenv -i https://pypi.douban.com/simple    COPY Pipfile /app/Pipfile  COPY Pipfile.lock /app/Pipfile.lock  RUN pipenv install --system --deploy --ignore-pipfile    COPY ./compose/local/start.sh /start.sh  RUN sed -i 's/r//' /start.sh  RUN chmod +x /start.sh

要注意和線上環境不同的是,我們沒有把整個程式碼 copy 到容器里。線上環境程式碼一般比較穩定,而對於開發環境,由於需要頻繁修改和調試程式碼,如果我們把程式碼 copy 到容器,那麼容器外做的程式碼修改,容器內部是無法感知的,這樣容器內運行的應用就沒法同步我們的修改了。所以我們會把程式碼通過 Docker 的數據捲來管理。

start.sh 不再啟動 gunicorn,而是使用 runserver 啟動開發伺服器。

#!/bin/sh    python manage.py migrate  python manage.py runserver 0.0.0.0:8000

然後創建一個 docker-compose 文件 local.yml(和 production.yml 同級),用於管理開發容器。

version: '3'    services:    djang_blog_tutorial_v2_local:      build:        context: .        dockerfile: ./compose/local/Dockerfile      image: django_blog_tutorial_v2_local      container_name: django_blog_tutorial_v2_local      working_dir: /app      volumes:        - .:/app      ports:        - "8000:8000"      command: /start.sh

注意我們將整個項目根目錄下的文件掛載到了 /app 目錄下,這樣就能容器內就能實時反映程式碼的修改了。

線上部署

如果容器在本地運行沒有問題了,線上環境的容器運行也沒有問題,因為理論上,我們在線上伺服器也會構建和本地測試用的容器一模一樣的環境,所以幾乎可以肯定,只要我們伺服器有 Docker,那麼我們的應用就可以成功運行。

首先在服務安裝 Docker,安裝方式因系統而異,方式非常簡單,我們以 CentOS 7 為例,其它系統請參考 Docker 的官方文檔。

首先安裝必要依賴:

$ sudo yum install -y yum-utils     device-mapper-persistent-data     lvm2

然後添加倉庫源:

$ sudo yum-config-manager       --add-repo       https://download.docker.com/linux/centos/docker-ce.repo

最後安裝 Docker:

$ sudo yum install docker-ce docker-ce-cli containerd.io

啟動 Docker:

$ sudo systemctl start docker

(境外伺服器忽略)設置 Docker 源加速(使用 daocloud 提供的鏡像源),否則拉取鏡像時會非常慢

curl -sSL https://get.daocloud.io/daotools/set_mirror.sh | sh -s http://f1361db2.m.daocloud.io

在 docker 中運行一個 hello world,確認 docker 安裝成功:

$ sudo docker run hello-world

docker 安裝成功了,還要安裝一下 docker-compose。其實是一個 python 包,我們直接通過 pip 安裝就可以了:

$ pip install docker-compose

為了避免運行一些 docker 命令時可能產生的許可權問題,我們把系統當前用戶加入到 docker 組裡:

$ sudo usermod -aG docker ${USER}

添加組後要重啟一下 shell(ssh 連接的話就斷開重連)。

萬事俱備,只欠東風了!

開始準備讓我們的應用在 docker 容器里運行。由於之前我們把應用部署在宿主機上,首先來把相關的服務停掉:

# 停掉 nginx,因為我們將在容器中運行 nginx  $ sudo systemctl stop nginx    # 停掉部落格應用  $ supervisorctl stop hellodjango-blog-tutorial -c ~/etc/supervisord.conf

接下來拉取最新的程式碼到伺服器,進入項目根目錄下,創建線上環境需要的環境變數文件:

$ mkdir .envs  $ cd .envs  $ vi .production

將線上環境的 secret key 寫入 .production 環境變數文件,

DJANGO_SECRET_KEY=2pe8eih8oah2_2z1=7f84bzme7^bwuto7y&f(#@rgd9ux9mp-3

保存並退出。

回到項目根目錄,運行 build 命令構建鏡像:

$ docker-compose -f prodcution.yml build

然後我們可以開始啟動根據構建好的鏡像啟動 docker 容器,不過為了方便,我們的 docker 進程仍然由 supervisor 來管理,我們修改一下部落格應用的配置,讓它啟動時啟動 docker 容器。

打開 ~/etc/supervisor/conf.d/hellodjango-blog-tutorial.ini,修改為如下內容:

[program:hellodjango-blog-tutorial]  command=docker-compose -f production.yml up --build  directory=/home/yangxg/apps/HelloDjango-blog-tutorial  autostart=true  autorestart=unexpected  user=yangxg  stdout_logfile=/home/yangxg/etc/supervisor/var/log/hellodjango-blog-tutorial-stdout.log  stderr_logfile=/home/yangxg/etc/supervisor/var/log/hellodjango-blog-tutorial-stderr.log

主要就是把之前的使用 Gunicorn 來啟動服務換成了啟動 docker。

修改 ini 配置 要記得 reread 使配置生效:

$ supervisorctl -c ~/etc/supervisord.conf  > reread  > start 

docker 容器順利啟動,訪問我們的部落格網站。拋掉鏡像編排的準備工作,相當於我們只執行了一條構建容器並啟動容器的命令就部署了我們的部落格應用。如果換台伺服器,也只要再執行一下鏡像構建和啟動容器的命令,服務就又可以起來!這就是 docker 的好處。

由於開發 django 用的最多的 IDE Pycharm 也能很好地集成 Docker,我現在開發工作已經全面擁抱 Docker 了,前所未有的體驗,前所未有的方便和穩定,一定要學著用起來!

HTTPS

最後,由於 Nginx 在新的容器里運行,所以需要重新申請和配置 https 證書,這和之前是一樣,只是此前 Nginx 在宿主機上,這次我們在容器里運行 certbot 命令。編排 nginx 鏡像時已經安裝了 certbot,直接執行命令即可,在 docker 容器內執行命令如下:

我們首先通過 docker ps 命令查看正在運行的容器,記住 nginx 容器的名字,然後使用 docker exec -it 容器名 命令的格式在指定容器內執行命令,所以我們執行:

$ docker exec -it nginx certbot --nginx

根據提示輸入資訊即可,過程和上一節在宿主機上部署一模一樣,這裡不再重複。

自動化部署

fabric 無需修改,來嘗試本地執行一下:

pipenv run fab -H server_ip --prompt-for-login-password -p deploy

完美!至此,我們的部落格已經穩定運行於線上,陸陸續續會有更多的人來訪問我們的部落格,讓我們來繼續完善它的功能吧!


『講解開源項目系列』——讓對開源項目感興趣的人不再畏懼、讓開源項目的發起者不再孤單。跟著我們的文章,你會發現編程的樂趣、使用和發現參與開源項目如此簡單。歡迎留言聯繫我們、加入我們,讓更多人愛上開源、貢獻開源~

Tags: