0%

Hexo 博客容器化

容器化技术已经越来越成为互联网应用的潮流,本文记录了我的 Hexo 博客容器化的过程。

容器化应用,不仅可以提高现有应用的安全性和可移植性,还能节省成本。想一想,如果每一次更换云服务器,都需要重新把 这篇博文 的操作重复一遍,该是多么痛苦的一件事情。容器化之后,我们只需要有一个 Dockerfile 就可以方便的迁移博客,所有的网站和博客的更新都可以版本化控制。

下面所有的操作,默认是在一个已经配置好 docker 环境的云服务器上。首先我会简单说一下系统的模块设计,然后针对三个不同的模块详细介绍,实现系统的基本功能。在之后是在已经实现的基本功能基础上,一步一步的加上一些扩展功能,更加完善当前系统,使其更加方便易用。

模块设计

深度定制Hexo博客 这篇文章中,服务器所有的模块其实可以分为三个部分:

  • Git 服务器模块
  • Hexo 解析渲染模块
  • Nginx 服务器模块

整个系统的操作逻辑如下:

  • Git 服务器模块存储了 blog 的 repo,这个 repo 里面保存的是网站所有源代码的内容,而不是像 github 那种保存源文件渲染之后的内容。还需要注意的是,这里的 repo 是对应的 blog.git,而客户端存储的是 blog。
  • 客户端,也就是自己的笔记本也存储了 blog 的源文件。每次更新博客是在客户端进行,更新完之后会自动上修改上传到 Git 服务器。
  • Git 服务器自己的 hook 工具会自动将这份更新同步到 Hexo 解析渲染模块。
  • Hexo 解析渲染模块有一份和客户端同步的 blog 源文件。每次客户端 push 到 git 服务器之后,git 服务器就会自动同步 Hexo 解析渲染模块的文件。然后执行 hexo generate 的命令,生成对应的网站的 HTML 文件,放在对应的 public 文件夹中
  • Nginx 服务器根据配置,指定对应的 root 目录即可

虽然在 深度定制Hexo博客 这篇文章中我还增加了一些扩展功能,比如 https,比如 在线编辑 的功能,但是基本的操作实际上就是上面这个模型的。

对应于微服务化,我应该如何去设计我自己的Docker镜像和对应的配置文件呢?

最直接的,我们按照这个功能划分,把这个系统分为三个模块,具体的用 docker-compose的方式展现出来,就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
version: '2'
services:
git-server:
container-name: hexo-git-server
image: houmin/git-server
restart: always
volumes:
- ./git-server/keys:/git-server/keys
- ./git-server/repos:/git-server/repos
ports:
- "2222:22"
hexo:
container_name: hexo_build
image: houmin/alpine-hexo
restart: always
volumes:
- ./source:/hexo/source:ro
- ./themes:/hexo/themes:ro
- ./public:/hexo/public
- ./node_modules:/hexo/node_modules
ports:
- "4000:4000"
nginx:
container_name: hexo_web
image: nginx:1.17
restart: always
volumes:
- ./public:/usr/share/nginx/html:ro
- ./conf:/ect/nginx/conf.d
ports:
- "80:80"

看起来很不错,我们期待的是,每次客户端更新博客之后,将更新 push 到 git 服务器,然后 git 服务器上面的 hook 就会自动去更新 hexo 解析渲染模块的内容,渲染出最新的网页内容。然后因为 hexo 解析渲染模块的生成目录,实际上就是 Nginx 服务内容的目录,所以渲染之后网页内容就可以及时的更新。

现在的一个问题就是,如果把 git 服务器模块和 hexo 渲染模块放在两个容器里面运行,在 git 服务器模块中怎么使用 hook 去更新 hexo 渲染模块里面命令,具体的就是 hexo generate

在同一台物理机上,我的 hook 操作如下

1
2
3
4
5
6
7
#!/bin/bash

HEXO_PATH="/home/git/hexo"
unset GIT_DIR
git -C $HEXO_PATH fetch origin;
git -C $HEXO_PATH rebase origin/master;
hexo --cwd $HEXO_PATH generate;

直接去执行本机的 hexo 命令,指定本机的路径。这个操作在同一台物理机上是可以的,但是微服务化之后容器间就不太行了。到了这一步,就回到了微服务化的常见套路了,两个可选方案:

  • RPC
  • REST

两种方案都可以,但是既然只是作为一个简单的博客,我想实现的事情很简单,就是一个容器让另外一个容器执行一个命令。考虑到 hexo 解析渲染模块已经有 js 的所有环境,使用 js 实现一个 REST server 服务也不难,所以就直接采用 REST 了。

基本功能

Git 服务器模块

这里的 git 服务器主要是参考了 git-server-docker,针对自己的需求做了定制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
FROM alpine:3.4

MAINTAINER houmin <houmin.wei@pku.edu.cn>

RUN apk add --no-cache \
openssh \
git \
curl

# Key generation on the server
RUN ssh-keygen -A

WORKDIR /git-server

# -D flag avoids password generation
# -s flag changes user's shell
RUN mkdir /git-server/keys \
&& adduser -D -s /usr/bin/git-shell git \
&& echo git:12345 | chpasswd \
&& mkdir /home/git/.ssh

COPY git-shell-commands /home/git/git-shell-commands

COPY sshd_config /etc/ssh/sshd_config
COPY start.sh start.sh

EXPOSE 22

VOLUME ["/git-server/keys"]
VOLUME ["/git-server/repos"]
VOLUME ["/hexo"]

CMD ["sh", "start.sh"]

注意,为了使得 git-shell-commands 能够生效,需要使得其具有执行权限 chmod +x -R git-shell-commands

start.sh 具体内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/bin/sh

# If there is some public key in keys folder
# then it copies its contain in authorized_keys file
if [ "$(ls -A /git-server/keys/)" ]; then
cd /home/git
cat /git-server/keys/*.pub > .ssh/authorized_keys
chown -R git:git .ssh
chmod 700 .ssh
chmod -R 600 .ssh/*
fi

# Checking permissions and fixing SGID bit in repos folder
# More info: https://github.com/jkarlosb/git-server-docker/issues/1
if [ "$(ls -A /git-server/repos/)" ]; then
cd /git-server/repos
chown -R git:git .
chmod -R ug+rwX .
find . -type d -exec chmod g+s '{}' +
fi

# clone hexo from local git repo
cd /hexo
git init .
git remote add origin /git-server/repos/hexo.git
git fetch
git branch master origin/master
git checkout master

chown -R 1000:1000 /hexo

# -D flag avoids executing sshd as a daemon
/usr/sbin/sshd -D

编译 docker 镜像

1
~/git$: docker build -t houmin/alpine-git-server .

通过 docker-compose 运行 git-server

1
2
3
4
5
6
7
8
9
10
11
12
version: '2'
services:
git-server:
container_name: git-server
image: houmin/alpine-git-server
restart: always
volumes:
- ~/git-server/keys:/git-server/keys
- ~/git-server/repos:/git-server/repos
- ~/hexo:/hexo
ports:
- "2222:22"

把本地 public key 复制到 git server 的 keys 目录,并且重启 git server

1
2
3
Copy Public Key To Git Server: $ scp ~/.ssh/id_rsa.pub user@host:~/git-server/keys
You need restart the container when keys are updated:
$ docker restart <container-id>

这时候使用通过如下命令测试

1
2
3
4
5
6
$ ssh git@<ip-docker-server> -p 2222
...
Welcome to git-server-docker!
You've successfully authenticated, but I do not
provide interactive shell access.
...

把 blog.git 上传到 git-server/repos 目录下

1
2
From remote:
$ scp -r myrepo.git user@host:~/git-server/repos

从而可以在客户端本地

1
$ git clone ssh://git@<ip-docker-server>:2222/git-server/repos/myrepo.git

Hexo 解析渲染模块

Dockerfile 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FROM mhart/alpine-node:12

WORKDIR /hexo

ENV HEXO_VERSION 4.0.0

RUN npm config set registry https://registry.npm.taobao.org \
&& npm install -g hexo@${HEXO_VERSION}

EXPOSE 8080

VOLUME ["/hexo"]
VOLUME ["/hexo/node_modules"]
VOLUME ["/hexo/public"]

COPY package.json /hexo/package.json
COPY entrypoint.sh /hexo/entrypoint.sh
COPY _config.yml /hexo/_config.yml
COPY server.js /hexo/server.js

CMD ["sh", "/hexo/entrypoint.sh"]

entrypoint.sh 如下

1
2
3
4
5
6
7
8
9
#!/bin/sh

npm install hexo@4.0.0 --save
npm install --production

hexo douban -bm
hexo generate

node server.js

server.js 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var express = require('express');
var execSync = require('child_process').execSync;
var app = express();
var fs = require('fs');

app.get('/list', function(req, res) {
res.send('Response from REST request /list');
const output = execSync('hexo generate', {encoding: 'utf-8'});
console.log('output:\n' + output);
})

var server = app.listen(8080, function() {
var port = server.address().port
console.log('application instance, access address: port: %s', port)
})

使用 docker-compose 启动 hexo-parser 模块

1
2
3
4
5
6
7
8
9
10
11
12
13
version: '2'
services:
hexo-parser:
container_name: hexo-parser
image: houmin/alpine-hexo-parser
restart: always
volumes:
- ~/blog/source:/hexo/source:ro
- ~/blog/themes:/hexo/themes:ro
- ~/blog/public:/hexo/public
- ~/blog/node_modules:/hexo/node_modules
ports:
- "8080:8080"

Nginx 服务器模块

docker-compose 启动 nginx 服务器

1
2
3
4
5
6
7
8
9
10
11
version: '2'
services:
nginx:
container_name: hexo-nginx-server
image: nginx:1.17
restart: always
volumes:
- ~/blog/public:/usr/share/nginx/html:ro
- ~/blog/conf:/ect/nginx/conf.d
ports:
- "80:80"

微服务整合

至此,通过一个yaml文件就可以启动所有的服务了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
version: '2'
services:
hexo-git-server:
container_name: hexo-git-server
image: houmin/alpine-git-server
restart: always
volumes:
- ~/git-server/keys:/git-server/keys
- ~/git-server/repos:/git-server/repos
ports:
- "2222:22"
hexo-parser:
container_name: hexo-parser
image: houmin/alpine-hexo-parser
restart: always
volumes:
- ~/hexo/source:/hexo/source:ro
- ~/hexo/themes:/hexo/themes:ro
- ~/hexo/public:/hexo/public
- ~/hexo/node_modules:/hexo/node_modules
ports:
- "8080:8080"
hexo-nginx-server:
container_name: hexo-nginx-server
image: nginx:1.17
restart: always
volumes:
- ~/hexo/public:/usr/share/nginx/html:ro
- ~/hexo/conf:/ect/nginx/conf.d
ports:
- "80:80"

在上面三个模块中,交互比较紧密的是 git-serverhexo-parser。每次用户在本地更新完博客之后,将新的 commit 上传到 git-server 中,git-server 通过 hook 更新 hexo 博客中的源文件,然后向 hexo-parser 发出请求,具体的 hook 如下

1
2
3
4
5
6
7
#!/bin/sh

HEXO_PATH="/hexo"
unset GIT_DIR
git -C $HEXO_PATH fetch origin;
git -C $HEXO_PATH rebase origin/master;
curl -X GET http://hexo-parser:8080/list

这里的 http://hexo-parser:8080/list 也就是 server.js 提供的服务。

注:为了能够在 git-server 实现 git fetch的功能,在 git-server中 clone 的是 local 的 git repo,而不是通过远端的 ssh 协议,详见 entrypoint.sh

扩展功能

支持 HTTPS

众所周知,HTTPS 是一件特别繁琐的事情,关于 HTTPS 可以看一看 这篇文章

简单来说,为了避免中间人攻击,网站服务器需要用证书 certificate 证明自己的身份,下面是用户和网站交互流程图。

SSL Handshake

证书需要从 CA(Certificate Authority) 申请。CA是权威的证书颁发机构,比如GeoTrust、GlobalSign,也有像 Let’s Encrypt 这样的免费证书颁发机构,下面是从 CA 申请证书的示意图。

从CA申请证书

对于 Nginx,我们通过下面的配置文件来配置证书:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
server {
listen 80;
server_name houmin.site;
location / {
return 301 https://$host$request_uri;
}
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
server {
listen 443 ssl;
server_name houmin.site;

location / {
error_page 404 /404/;
}

root /usr/share/nginx/html;

ssl_certificate /etc/letsencrypt/live/houmin.site/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/houmin.site/privkey.pem;

include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}

这里的 fullchain.pemprivkey.pem 就是网站从 CA 申请到的证书。

Let’s Encrypt 给我们提供了 certbot 工具便于更好的申请和管理证书。这里有一个鸡生蛋蛋生鸡的问题,到底是先有 nginx 呢,还是申请到证书呢?Nginx and Let’s Encrypt with Docker 的解决方法是,Create a dummy certificate, start nginx, delete the dummy and request the real certificates.

令人开心的是,这篇博文的作者也顺手给我们提供了一个 bootstrap 的脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#!/bin/bash

if ! [ -x "$(command -v docker-compose)" ]; then
echo 'Error: docker-compose is not installed.' >&2
exit 1
fi

domains=(example.org www.example.org)
rsa_key_size=4096
data_path="./data/certbot"
email="" # Adding a valid address is strongly recommended
staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits

if [ -d "$data_path" ]; then
read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
exit
fi
fi


if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
echo "### Downloading recommended TLS parameters ..."
mkdir -p "$data_path/conf"
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
echo
fi

echo "### Creating dummy certificate for $domains ..."
path="/etc/letsencrypt/live/$domains"
mkdir -p "$data_path/conf/live/$domains"
docker-compose run --rm --entrypoint "\
openssl req -x509 -nodes -newkey rsa:1024 -days 1\
-keyout '$path/privkey.pem' \
-out '$path/fullchain.pem' \
-subj '/CN=localhost'" certbot
echo


echo "### Starting nginx ..."
docker-compose up --force-recreate -d nginx
echo

echo "### Deleting dummy certificate for $domains ..."
docker-compose run --rm --entrypoint "\
rm -Rf /etc/letsencrypt/live/$domains && \
rm -Rf /etc/letsencrypt/archive/$domains && \
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
echo


echo "### Requesting Let's Encrypt certificate for $domains ..."
#Join $domains to -d args
domain_args=""
for domain in "${domains[@]}"; do
domain_args="$domain_args -d $domain"
done

# Select appropriate email arg
case "$email" in
"") email_arg="--register-unsafely-without-email" ;;
*) email_arg="--email $email" ;;
esac

# Enable staging mode if needed
if [ $staging != "0" ]; then staging_arg="--staging"; fi

docker-compose run --rm --entrypoint "\
certbot certonly --webroot -w /var/www/certbot \
$staging_arg \
$email_arg \
$domain_args \
--rsa-key-size $rsa_key_size \
--agree-tos \
--force-renewal" certbot
echo

echo "### Reloading nginx ..."
docker-compose exec nginx nginx -s reload

准备好脚本和 docker-compose.yml 之后,就可以得到网站的证书了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
.
├── data
│   ├── certbot
│   │   ├── conf
│   │   │   ├── accounts
│   │   │   │   └── acme-v02.api.letsencrypt.org
│   │   │   │   └── directory [error opening dir]
│   │   │   ├── archive [error opening dir]
│   │   │   ├── csr
│   │   │   │   └── 0000_csr-certbot.pem
│   │   │   ├── keys [error opening dir]
│   │   │   ├── live
│   │   │   │   ├── houmin.site
│   │   │   │   │   ├── cert.pem -> ../../archive/houmin.site/cert1.pem
│   │   │   │   │   ├── chain.pem -> ../../archive/houmin.site/chain1.pem
│   │   │   │   │   ├── fullchain.pem -> ../../archive/houmin.site/fullchain1.pem
│   │   │   │   │   ├── privkey.pem -> ../../archive/houmin.site/privkey1.pem
│   │   │   │   │   └── README
│   │   │   │   └── README
│   │   │   ├── options-ssl-nginx.conf
│   │   │   ├── renewal
│   │   │   │   └── houmin.site.conf
│   │   │   ├── renewal-hooks
│   │   │   │   ├── deploy
│   │   │   │   ├── post
│   │   │   │   └── pre
│   │   │   └── ssl-dhparams.pem
│   │   └── www
│   └── nginx
│   └── app.conf
├── docker-compose.yml
└── init-letsencrypt.sh

这之后,就可以用 docker-compose 启动网站了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
version: '3'
services:
nginx:
image: nginx:1.17-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ~/hexo/public:/usr/share/nginx/html:ro
- ~/hexo/nginx:/etc/nginx/conf.d
- ./data/certbot/conf:/etc/letsencrypt
- ./data/certbot/www:/var/www/certbot
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
certbot:
image: certbot/certbot
volumes:
- ./data/certbot/conf:/etc/letsencrypt
- ./data/certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

注:如果在申请证书是失败,有可能是因为这个时候 Nginx 没有启动。而 Nginx 没有启动原因有可能是执行

1
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem

命令的时候,在阿里云上 443错误导致没有获得 ssl-dhparams.pem 导致 Nginx 启动失败

至此,HTTPS已经配置完毕。

支持 Hexo Admin

这篇博文 中,我利用 Hexo Admin 配置了网站后台,但是后来实践起来发现这个功能倒不是很实用,所以在容器化的这篇文章中暂时没有支持 Hexo Admin。等以后如果需要了,可以再配置。

多版本网站支持

促成这篇文章最主要的动力,是因为阿里云通知我 houmin.site 这个域名备案成功,让我在 2 月份之前将网站的备案加上。那么现在我拥有两份网站,houmin.cchoumin.site,其中 houmin.sitehoumin.cc 的备份。其中,houmin.cc 是在 AWS 上,houmin.site 是在阿里云上。等 AWS 的一年免费体验结束之后,我需要把 houmin.cc 也迁移到 houmin.site中,到时候可能需要用 Nginx 做反向代理,或者玩玩 traefik 也不错🤔

当网站的一切都版本化和容器化之后,多版本网站显得容易了很多。对于两个网站,分别创建两个branch,网站主要内容相同,区别在于

  • 站点配置文件
    • url 的配置
    • Nofollow
  • NexT 配置文件
    • 备案
    • SEO推广
    • Valine Appid
  • robots.txt

针对上述几点配置之后,每次更新的网站的时候,将更新同步到不同的 branch,推送到不同的 git server 就可以了。