博客迁移到 Docker

记一次博客迁移到 Docker 全过程。

本博客架构虽然简单,但是耐不住我经常的折腾,包括但不限于机房间的迁移、插件开发测试、自己新增 feature 测试等等。在这个过程中需要重复的构建整个系统,传统的构建方式就是拉代码、起 SQL、配 Nginx ……

作为一个坚持 DRY 原则的人,是不能容忍这样重复而且无技术含量的事情发生的。

刚开始编程的时候自己折腾最土的方式是把开发环境用虚拟机配置好,然后把整个虚拟机文件拷贝到 U 盘里面,人肉迁移;后来升级到 Vagrant;再后来了解到 Docker,从此我所有开发相关的环境都是基于 Docker 了。但是所有的生产环境依然是传统方式运作,这次刚好京东机房要到期了,决定从博客开刀,迁移到 Docker。

架构分析

博客架构比较简单,就是传统的 LNMP 架构,涉及到的服务也比较少,只是额外配套一个备份服务。

虽然博客信息不多,但是备份很重要!

目前还没考虑到数据层独立出来,所以架构上暂时不支持水平拓展,我这个博客估计也是用不到这个架构的。

博客传统架构

不要在一个容器中运行多个程序!不要将 Docker 当做虚拟机来使用!

Docker 最佳实践之一就是,一个容器功能应该是单一的应用程序,而不是一个大而全的服务,完整的服务应该通过编排来实现。

所以整个服务会通过 4 个容器来实现:

1、Web Rule。这一层包含了特定的 rewrite 规则和 web 服务配置,是整个博客服务的唯一出口。

2、Blog。这个是博客运行的 PHP 环境。

3、Data。这个是博客的数据库,并且只为博客服务。

4、Backup。这个是备份服务,只和 Data 层交互,用来备份数据库。

Web Server 是物理机上的,用来支持通用的 HTTP 协议处理,比如 SSL、HTTP 2.0,然后反向代理到 Web Rule。

最终服务编排如下:

博客 Docker 架构

编写镜像

Base

Alpine 应该是大势所趋,这是一个只有 5M 的 Docker 镜像,并且拥有完整的包管理工具。可能和传统 ubuntu 镜像有一些依赖上的区别,比如你安装 openssl-dev 可能会有问题:

ERROR: unsatisfiable constraints:
  openssl-dev-1.0.2o-r1:
    conflicts: libressl-dev-2.5.5-r0[pc:libcrypto=1.0.2k] libressl-dev-2.5.5-r0[pc:libssl=1.0.2k] libressl-dev-2.5.5-r0[pc:openssl=1.0.2k]
    satisfies: build-deps-0[openssl-dev]
  libressl-dev-2.6.5-r0:
    conflicts: openssl-dev-1.0.2k-r0[pc:libcrypto=2.5.5] openssl-dev-1.0.2k-r0[pc:libssl=2.5.5] openssl-dev-1.0.2k-r0[pc:openssl=2.5.5]
    satisfies: qt-dev-4.8.7-r6[libressl-dev]

因为大部分的软件是用 libressl-dev 来编译的,并且俩者的路径也一样,所以不能同时安装,按装 libressl-dev 即可。

但是随着 Alpine 越来越成熟,包也越来越多,比之前方便多了。比如我这里编译 PHP 镜像要安装 kafka 拓展 rdkafka ,之前还没有 librdkafka-dev 库,只能在镜像里面写编译逻辑,这次发现官方 package 里已经有现成的了。

Web Rule

这一层是 Nginx。可能是 Nginx 自身架构的原因,第三方模块不能完全实现自动加载,只能重新编译,官方镜像也没提供这个方法,相比官方的 PHP 镜像,后者简直良心至极。所以如果有第三方模块需要,只能自己基于 Alpine 镜像重新编译 Nginx,比如我这次需要的 ngx_http_lower_upper_case 模块。

Blog

这一层是 PHP。基于官方 PHP 镜像编译,添加了大部分常用的拓展,并默认不启用部分拓展,后期可以通过配置文件来启用,比如 Xdebug ……

然后官方镜像其实默认并没有启用 opcache、PDO ……

Data

这一层是 MariaDB。虽然官方 package 里面有 MariaDB,但是 MariaDB 官方并没有提供 Apline 版本,但是有很多大神已经提供了。其实任何 Alpine 版本的 MariaDB 核心都在于初始化脚本,这个脚本也就是 DB 初始化的关键步骤。

Backup

这一层是 PHP。基于 Blog 的镜像,新增了 mysqlclient,用于备份数据库。

容器编排

编排核心就是容器间的连接和依赖关系。

依赖

容器间的连接和依赖关系应该通过 network 来实现,官方已经不再推荐 links 实现了,并且在未来的版本中会被移除它,所以这次我也改用 network 来实现。

Warning: >The --link flag is a legacy feature of Docker. It may eventually be removed. Unless you absolutely need to continue using it, we recommend that you use user-defined networks to facilitate communication between two containers instead of using --link

network 作用就是申明容器加入某个网络中,并且可以自定义自己在网络中的别名,容器间通过别名就可以互相访问了。比如下面的编排中 php 容器内部直接使用 BLOG_MYSQL 就可以访问到 mysql 容器了,很简单。

所以用 network 替换 links 之后,把依赖关系从 docker-compose 中移除了,不需要再显式指定依赖关系和连接关系了,也避免了容器间循环依赖的问题。

下面这个是我博客服务的编排:

version: '3'
services:
  nginx:
    networks:
      blog-network:
  php:
    networks:
      blog-network:
        aliases:
          - BLOG_PHP
  mysql:
    networks:
      blog-network:
        aliases:
          - BLOG_MYSQL
networks:
  blog-network:
    driver: bridge

这里其实有另外一个问题就是依赖关系无法显示的管理了。以前 links 申明的依赖关系才可以通过别名访问,比如通过 links 就能知道 php 依赖了 mysql,但是现在 php 容器内部直接使用 BLOG_MYSQL 就可以访问到 mysql 容器了,但是依赖关系并没有体现在 docker-compose 文件中。

同理,我的备份服务只要加入到 blog-network 网络中,就可以访问到 mysql 执行备份流程了。

加入已有的网络中需要使用 external 选项来申明。

备份服务编排如下:

version: '3'
services:
  php:
    networks:
      cron-network:
networks:
  cron-network:
    external:
      name: deploy_blog-network

冲突

然后多项目可能会遇到一个问题就是孤儿容器的问题,本质上是冲突问题。

WARNING: Found orphan containers (deploy_blog_nginx_1, deploy_blog_php_1, deploy_blog_mysql_1) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up.

因为在默认情况下 docker-compose 会使用当前目录名作为项目的前缀名,也就是 COMPOSE_PROJECT_NAME

如果 2 个项目的 docker-compose 所在在目录名相同,就会遇到冲突,然后影响已经存在的容器,破坏其完整性,造成了孤儿容器。

并且 network 名也是会冲突的!

比如这样:

├── blog
│   └── deploy
│   	└── docker-compose.yml

├── backup
│   └── deploy
│   	└── docker-compose.yml

如果不显示指定 COMPOSE_PROJECT_NAME ,默认都会使用 deploy 作为 COMPOSE_PROJECT_NAME 的值,就会造成冲突。

关键是现在 docker-compose 并不支持在配置中显示设定 COMPOSE_PROJECT_NAME ,只能通过命令行 -p 选项来支持,很不优雅有没有?

关于支持在配置中显示设定 COMPOSE_PROJECT_NAME 的讨论在 14 年就已经有了,点这里可以参与大家的讨论

权限

在容器 run 起来之后,可能需要执行一些特定的脚本来完成服务的初始化。

version: '3'
services:
  php:
	command: sh /www/init.sh
    networks:
      cron-network:
networks:
  cron-network:
    external:
      name: deploy_blog-network

但是经常会遇到一个权限的问题:

permission denied 

可能因为脚本是运行时候被挂载进去的,并不是在 Dockerfile 编译的时候就放进去的,所以有可能没有执行权限。这里推荐使用 sh /www/init.sh ,就不用再使用 chmod 了。

业务问题

DB

博客的 DB 之前使用的是 utf8 编码类型的,后面为了支持 Emoji 🤷‍♂️,我更新成了 utf8mb4。

这样就会有坑了,因为编码不一样,索引长度也不一样了,初始化 DB 的时候就报错了:

Specified key was too long; max key length is 767 bytes
SQLSTATE[42000]: Syntax error or access violation: 1071 Specified key was too long; max key length is 767 bytes

原因和解决方案都比较简单:

1. 更改索引长度,这里有个高票回答,很赞。

2. 更新 MariaDB 版本,我目前使用的是 10.1.18,10.2 已经提高了这个上限。

Cron

定时任务比较特殊,我有见过在 docker 里面跑 cron 服务的。

但是我个人还是推荐在物理机上跑 Crontab 然后使用 docker 来完成任务,不建议在 docker 里面跑 cron jobs。

更好的方法还是在 Docker 里面跑一个专门的 jobs 服务,有更好的状态支持和管理,移除 Cron 依赖。

如果只是简单的服务,请参考第一条建议,一切从简。

回顾

因为博客架构简单,流量也少,所以用了他第一个开刀。

但是如果把博客这个服务抽象来看再梳理整个架构,架构上分了 3 层:应用 (Blog+Backup)、存储 (Data)、网关 (Web Rule),思路才是最重要的:每个应用程序都应该是无状态的,并且互相之间应该尽量解耦。

其实过程中主要是熟悉了 Docker 的用法🤦‍♂️。