如何用Docker将你的应用程序与操作系统解耦?

73 阅读4分钟

我们定期更新和改进我们在卡克图的升级和可持续发展服务,最近为一个客户的升级催生了一个解决方案,我觉得值得分享。在卡克图,解决升级和可持续发展的首选方法是随着时间的推移对项目进行增量更新,尽量让Django和服务器本身保持在长期支持的版本上。例如,这些是Django和Ubuntu的精选版本,它们的支持期通常比其他版本长得多,也就是说,它们很适合那些你需要在未来很长时间内继续维护的应用程序。

我们继续为客户托管早在2010年就开始的大型项目,在过去的十年中,我们一直应用这种方法来保持系统的更新和降低风险。一个这样的项目目前包括多达40台服务器(横跨几个环境),都是用一个为此目的而建立的工具自动配置的。这个客户还要求这些系统在我们也帮助管理的物理硬件上运行,而不是在云端。

升级Python但不升级操作系统

最近,我们想升级用于该应用的Python版本,但还没有准备好升级操作系统(它的支持周期还有很多时间),但我们不想把自己绑在使用第三方Ubuntu软件包上,因为这些软件包不能保证在出现安全问题时能及时更新

因此,问题出现了。

"我们怎样才能在Ubuntu的支持版本上获得一个支持的Python版本,而不是Ubuntu中包含的默认Python版本?"

使用Docker与supervisord

输入Docker + supervisord。如果你在谷歌上搜索这两个词,你会看到很多关于在docker容器内运行supervisord的方法,这允许你在一个容器内运行多个进程。这通常不被推荐,也不是我想在这篇文章中讨论的内容。

还有一种似乎不太常见的方法来搭配这两者。在主机操作系统上使用supervisord来运行多个Docker容器,就像你在过去几年中使用supervisord来直接从Python虚拟环境中运行Gunicorn、uWSGI、Celery和其他此类进程一样。我应该清楚:我不推荐这种方法,除非是在已经使用supervisord的现有项目中,而且你需要进一步将它运行的一些进程与主机操作系统隔离。如果你要从头开始,Kubernetes或docker-compose可能更适合。但这是一个方便的方法,可以将旧的、"前Docker "的项目逐步转移到新的世界,并将干扰和风险降到最低。

更新部署

经过一些实验,我们发现通过supervisord运行Docker容器(而不是直接运行Python进程)实际上效果很好,只需对我们的部署过程做一些改变。

  1. 与其在服务器上建立一个虚拟环境并直接安装需求,你可以建立Docker容器(也是在服务器本地)。你可以选择把它移到一个专门的注册中心,但我们决定把它留到以后的步骤。这里有一个命令,你可能会发现对构建和标记Docker镜像有帮助。

    docker build --pull -t my_project:latest -t my_project:$(git rev-parse --short HEAD) .
    
    

    这条命令建立了一个带有最新标签的Docker镜像,其标签等于你的Git repo的短提交sha。

  2. 接下来,你需要用容器运行所需的变量来填充一个env文件。我们的部署栈已经使用了Jinja2模板,所以我们想出的模板看起来像这样。

    {# IMPORTANT: This is a Docker env file and cannot include 'export' nor quotes around variable values. #}
    DJANGO_SETTINGS_MODULE={{ settings }}
    {% for key, val in django_secrets.items() -%}
    {{ key }}={{ val }}
    {% endfor -%}
    {% for key, val in django_env.items() -%}
    {{ key }}={{ val }}
    {% endfor -%}
    
    
  3. 最后,你要创建一个名为docker_run.sh(或其他你选择的名字)的shell脚本,你可以用这个env文件调用docker run,用于当前标记的版本,其方式(大部分)模仿你过去可能直接运行Python进程的方式。

    #!/bin/sh
    exec /usr/bin/docker run --init --rm -i --env-file={{ secrets_env_file_path }} --network=host --mount type=bind,source={{ public_dir_path }},target=/public/ ${DOCKER_RUN_ARGS} my_project:{{ current_git_sha }} $@
    
    

    让我们把它分解一下。

    • exec确保docker运行进程接管这个脚本的PID。
    • --init使Docker运行docker-init进程,这可以帮助容器内的信号处理
    • --rm在退出时删除容器(对短命的命令特别有用)。
    • --env-file只是将docker指向我们在前一步中创建的文件。
    • --network=host意味着容器将没有自己的网络堆栈,所以进程可以像以前一样监听主机上的端口,而且容器还可以访问任何/etc/hosts的定制内容。(这是可有可无的,我们打算以后删除它,但在第一步可能会有帮助)。
    • --mount将一个目录(在我们的例子中,包括静态文件和上传的媒体)挂载到容器中。(同样,这是可选的,但如果你还没有或不能将静态文件和上传的媒体转移到S3这样的对象存储中,这可能有助于简化迁移。)
    • ${docker_run_args}(如果设置了),允许你提供一些参数,比如说:"你是谁?(如果设置)允许你为docker_run提供其他参数。
    • my_project:{{current_git_sha }}确保我们运行的是上一步构建的相同版本的代码。(像这样标记和运行容器是Docker的最佳实践,而且更容易尽早养成这种习惯)。
    • $@将传递给这个脚本的所有参数传入要运行的容器中
  4. 在我们的例子中,一旦我们有了这三样东西,升级到Docker(和较新版本的Python)只需要在我们的部署基础设施中找到任何对Python/virtualenv进程的调用,并在它们前面加上docker_run.sh脚本。例如,这里是我们的gunicorn进程的supervisord配置的样子。

    [program:gunicorn]
    process_name=%(program_name)s
    command=/path/to/my_project/docker_run.sh gunicorn my_project.wsgi:application --bind=0.0.0.0:8000 --workers=2
    user=root  # will be dropped by container
    autostart=true
    autorestart=true
    stdout_logfile=/path/to/log/%(program_name)s.log
    redirect_stderr=true
    startsecs=1
    ; Need to wait for currently executing tasks to finish at shutdown.
    ; Increase this if you have very long running tasks.
    stopwaitsecs=60
    
    

不要从头开始

如果这不是你的项目的确切配方,我希望这能帮助你思考新的方法,你可以升级和维护旧系统,使它们逐渐进入未来,而不是像Joel Spolsky所说的那样,"任何公司都可能犯的最糟糕的战略错误:......从头开始重写代码。"

如果你喜欢这篇文章并且还没有Dockerfile,你可以参考我的生产就绪的Django Dockerfile帖子

如果你遇到任何升级或可持续发展方面的挑战,请随时在下面评论或联系我们。我期待着你的反馈!