作者:HelloGitHub-追夢人物
之前一系列繁瑣的部署步驟讓我們感到痛苦。這些痛苦包括:
- 要去服務器上執行 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 的官方文檔[2]。
首先安裝必要依賴:
$ 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
完美!至此,我們的博客已經穩定運行于線上,陸陸續續會有更多的人來訪問我們的博客,讓我們來繼續完善它的功能吧!
參考資料
[1]HelloGitHub-Team 倉庫: https://github.com/HelloGitHub-Team/HelloDjango-blog-tutorial
[2]Docker 的官方文檔: https://docs.docker.com/install/