使用 Docker 让部署 Django 项目更加轻松

6,936 阅读13分钟
作者: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 的镜像。

线上环境

镜像文件

我们先来在 production\django 目录下编排博客项目线上环境的镜像文件,镜像文件以 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 的镜像,这个镜像文件放到 compose\production\nginx 目录下:

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。

开发环境的镜像文件,放到 compose\local 下:

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

完美!至此,我们的博客已经稳定运行于线上,陆陆续续会有更多的人来访问我们的博客,让我们来继续完善它的功能吧!

『讲解开源项目系列』——让对开源项目感兴趣的人不再畏惧、让开源项目的发起者不再孤单。跟着我们的文章,你会发现编程的乐趣、使用和发现参与开源项目如此简单。欢迎留言联系我们、加入我们,让更多人爱上开源、贡献开源~