我做的某公司 DevOps 远程职位的面试题

简介

这是之前有一家海外公司招 DevOps 工程师,我投了简历,期望薪资写了薪资范围的最下限,然后被给了份题让先做一下,于是便有了这篇“水”文。

当然,最终我并没有拿到这个 offer,甚至连下一轮见 CTO 的机会都没有。(关于这个,我其实心里还是有一点小小的不服气的。)

题目详情

Q1

写一个定时执行的 Bash 脚本,每月的一号凌晨 1 点对 MongoDB 中 test.user_log 表进行备份、清理,具体要求如下:

  • 首先备份上个月的数据,备份完成后打包成.gz文件
  • 备份文件通过 sftp 传输到 backup [bakup@bakup.xxx.com] 服务器上,账户已经配置在~/.ssh/config
  • 备份完成后,再对备份过的数据进行清理: create_on [2024-01-01 03:33:11]
  • 如果脚本执行失败或者异常,则调用 https://monitor.xxx.com/webhook/mongodb
  • 这个表每日数据量大约在 200w 条, 单条数据未压缩的存储大小约 200B

Q2

根据要求提供一份 Nginx 配置, 要求如下:

  • 域名:xxx.com, 支持 https、HTTP/2
  • 非 http 请求经过 301 重定向到 https
  • 根据 UA 进行判断,如果包含关键字 “Google Bot”, 反向代理到 server_bot[bot.xxx.com] 去处理
  • /api/{name} 路径的请求通过 unix sock 发送到本地 php-fpm,文件映射 /www/api/{name}.php
  • /api/{name} 路径下需要增加限流设置,只允许每秒 1.5 个请求,超过限制的请求返回 http code 429
  • /statics/ 目录下是纯静态文件,需要做一些优化配置
  • 其它请求指向目录 /www/xxx/, 查找顺序 index.html –> public/index.html –> /api/index

Q3

现有一台服务器,如下图所示上面通过默认安装并运行了 3 个 docker 容器,需要通过 iptables 进行网络配置。请给出命令:

  • 只有 Docker_A 与 Docker_B 之间可以相互通信,Docker_C 不能访问其它两个容器
  • 只允许内网 IP 为 192.168.1.1 - 192.168.1.30 的内网 IP 访问所有容器
  • Docker_A:8080 与 Docker_C:80 通过与自身相同端口对外网提供服务, Docker_B:3316 不对外网提供服务
  • 所有配置需要固化,重启服务器自动生效

示意图

Q4

已知生产环境数据库结构如图所示:

1
2
3
4
5
graph LR;
master-->slave_1;
master-->slave_2;
slave_1-->slave_3;
slave_2-->slave_4;

因为 master 偶尔有硬件问题,需要先将 slave_1 提升为新 master, 然后旧 master 变成 slave_1。请给出操作方案和关键命令。

  • 主从数据库服务均处于独立服务器上,有独立的IP;
  • 应用程序写入数据库通过域名 mysql-master.xxx.com 访问;
  • 应用程序读取数据通过 Haproxy(mysql-slave.xxx.com) 访问所有从库 01-04
  • 尽量平滑处理,不影响生产环境

Q5

在生产环境中,应用程序是通过 Haproxy 来读取 slave 集群,但是偶尔会产生

SQLSTATE[HY000]: General error: 2006 MySQL server has gone away

的错误,请根据经验,给出一排查方案与可能的方向,与开发一起定位问题, 现已经排查:

  • 故障发生时,服务器之间防火墙正常,服务器之间可以正常通信;
  • 故障SQL均可以正常查询,同时不存在性能问题;
  • 故障频率没有发现特别规律,与服务器负载没有正相关;
  • 查看各服务的日志,只发现了错误信息,但没有进一步的说明;
1
2
3
4
5
6
graph LR;
Service-->Haproxy;
Haproxy-->slave_1;
Haproxy-->slave_2;
Haproxy-->slave_3;
Haproxy-->slave_4;

我的答案

A1

如下是备份用的 bash 程序,放在任意合适的目录即可,不过需要记下来路径,cron 的程序 backup_mongo 里要用到,并且给其赋予可执行权限。

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
#!/bin/bash

# filename: backup_mongo.sh

LAST_MONTH_START=$(date -d "$(date +%Y-%m-01) -1 month" +%Y-%m-01)
LAST_MONTH_END=$(date -d "$(date +%Y-%m-01)" +%Y-%m-%d)

DATABASE="test"
COLLECTION="user_log"
BACKUP_DIR="backup"
BACKUP_PATH="${BACKUP_DIR}/${LAST_MONTH_START}_${LAST_MONTH_END}_user_log_backup.gz"
LOG_FILE="${BACKUP_DIR}/backup_log_$(date +%Y%m%d_%H%M%S).txt"
SFTP_SERVER="[email protected]"
WEBHOOK_URL="https://monitor.xxx.com/webhook/mongodb"

[ -e ${BACKUP_DIR} ] || mkdir -p ${BACKUP_DIR}

echo "$(date +"%Y-%m-%d %H:%M:%S") - Starting backup and compression for ${DATABASE}.${COLLECTION} for the period from ${LAST_MONTH_START} to ${LAST_MONTH_END}" | \
tee -a "$LOG_FILE"
{
mongodump --db="$DATABASE" --collection="$COLLECTION" \
--gzip --archive="$BACKUP_PATH" \
--query="{ 'create_on': { '\$gte': { '\$date': '${LAST_MONTH_START}T00:00:00.000' }, '\$lt': { '\$date': '${LAST_MONTH_END}T00:00:00.000' } } }"

sftp $SFTP_SERVER <<< $'put '"${BACKUP_PATH}"

mongo "$DATABASE" \
--eval \
"db.$COLLECTION.remove({ 'create_on': { '\$gte': { '\$date': '${LAST_MONTH_START}T00:00:00.000' }, '\$lt': { '\$date': '${LAST_MONTH_END}T00:00:00.000' } } });"
echo "$(date +"%Y-%m-%d %H:%M:%S") - Backup and clean up success." | tee -a "$LOG_FILE"
} || {
echo "$(date +"%Y-%m-%d %H:%M:%S") - Backup or clean up script failed, sending message..." | tee -a "$LOG_FILE"
curl -X POST "$WEBHOOK_URL" --data 'Backup or clean up script failed'
exit 1
}

如下是 cron 的配置文件 backup_mongo 的内容:

1
2
3
4
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

0 1 1 * * root /path/to/backup_mongo.sh > /dev/null 2>&1

这个文件请放在目录 /etc/cron.d/ 下

A2

  • 如果是 Debian 系的系统,请把文件 ipo.com.conf 放到 /etc/nginx/sites.available/ 目录下并在 /etc/nginx/sites.enable/ 下做一个软链。
  • 但如果是红帽系的系统,请把文件 ipo.com.conf 放到目录 /etc/nginx/conf.d/ 下。

以下是文件 xxx.com.conf 的内容:

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
limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=90r/m; # 1.5r/s by per IP
limit_req_zone $server_name zone=api_per_server:10m rate=10r/s; # 1.5r/s by per server

upstream server_bot {
server bot.xxx.com:443;
}

server {
listen 80;
listen [::]:80;
server_name xxx.com;
return 301 https://$host$request_uri;
}

server {
listen 443 ssl;
http2 on;
server_name xxx.com;
ssl_certificate xxx.com.crt;
ssl_certificate_key xxx.com.key;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 或者直接启用 HSTS
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
if ($http_user_agent ~ "Google Bot") {
set $google_bot true;
}
location /api/ {
limit_req zone=api_per_ip burst=5 nodelay; # or using api_per_server
limit_req_status 429;
fastcgi_pass unix:/var/run/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME /www$fastcgi_script_name.php; # /api/a -> /www/api/a.php
}
location /statics/ {
autoindex off;
gzip on;
gzip_comp_level 2;
gzip_min_length 1000;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
expires 365d;
access_log off;
}
location / {
if ($google_bot = 'true') {
proxy_pass https://server_bot;
proxy_ssl_certificate /etc/nginx/client.pem;
proxy_ssl_certificate_key /etc/nginx/client.key;
proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
proxy_ssl_ciphers HIGH:!aNULL:!MD5;
proxy_ssl_trusted_certificate /etc/nginx/trusted_ca_cert.crt;

proxy_ssl_verify on;
proxy_ssl_verify_depth 2;
proxy_ssl_session_reuse on;
}
}
root /www/xxx/;
index index.html public/index.html /api/index;
}

A3

原来给出来的四个需求:

  1. 只有Docker_A 与 Docker_B 之间可以相互通信,Docker_C 不能访问其它两个容器;
  2. 只允许内网IP为 192.168.1.1 - 192.168.1.30 的内网IP访问所有容器;
  3. Docker_A:8080 与 Docker_C:80 通过相同端口对外网提供服务, Docker_B:3316 不对外网提供服务;
  4. 所有配置需要固化,重启服务器自动生效;

3.1

因为如果不做特殊设置,Docker 容器之间是可以直接互通的,所以这里只需要限制 Docker_C 不能访问 Docker_A 和 Docker_B 即可

1
2
iptables -I DOCKER-USER -s 172.17.0.2 -d 172.17.0.4 -j REJECT
iptables -I DOCKER-USER -s 172.17.0.3 -d 172.17.0.4 -j REJECT

这样设置完毕,反向 172.17.0.4->172.17.0.2 和 172.17.0.4->172.17.0.3 也是不通的,因为回包被拒了。所以反向的 rules 就不用写了。

3.2

第二问有些没太理解,理论上来讲,Docker 容器的网络跟 host 外面是隔离的,无论是 host 上 eth_private 还是 eth_public 上来的流量,应该都是不能直接访问任何容器的。

我只能大概用管饭文档上的一个利子来试着看是不是满足需求:

1
2
3
iptables -I DOCKER-USER -m iprange \
-i eth_private ! --src-range 192.168.1.1-192.168.1.30 \
-j DROP

3.3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
iptables -t filter -A DOCKER -d 172.17.0.2/32 \
-i eth_public -o Docker0 -p tcp \
-m tcp --dport 8080 -j ACCEPT
iptables -t filter -A DOCKER -d 172.17.0.3/32 \
-i eth_public -o Docker0 -p tcp \
-m tcp --dport 80 -j ACCEPT
iptables -t nat -A POSTROUTING -s 172.17.0.2/32 \
-d 172.17.0.2/32 -p tcp -m tcp \
--dport 8080 -j MASQUERADE
iptables -t nat -A POSTROUTING -s 172.17.0.3/32 \
-d 172.17.0.3/32 -p tcp -m tcp \
--dport 80 -j MASQUERADE
# 以上两句我其实也没想明白,我是看了 Docker host 的 iptables 的实际情况照着抄的
iptables -t nat -A DOCKER -i eth_public -p tcp \
-m tcp --dport 8080 -j DNAT \
--to-destination 172.17.0.2:8080
iptables -t nat -A DOCKER -i eth_public -p tcp \
-m tcp --dport 80 -j DNAT \
--to-destination 172.17.0.3:80

3.4

这个问题的答案依 Linux 发布版的不同以及具体软件的不同而不同

1
2
3
4
5
iptables-save > /etc/iptables/rules.v4
# 或者是红帽系的话
iptables-save > /etc/sysconfig/iptables
# or
service iptables save

A4

这道题我基本上考虑的最多的是怎么样保持数据一致性。系统可用性的考虑反倒是其次。

修改域名解析

  • 从域名 mysql-slave.xxx.com 解析,将 slave_1 摘出来
  • 尽量将域名 mysql-master.xxx.com 到 master 的指向去掉

逐级设置成 readonly

  • 将 master 设置成 readonly(set global read_only=ON;set global super_read_only=ON;)
  • 等 slave_1 和 slave_2 的数据跟 master 同步之后(show slave status 里看),将 slave_1 和 slave_2 设置为 readonly
  • 最后等 slave_3 的数据同步之后,将其也设为 readonly

slave_1 变成 master

  • slave_3 从 slave_1 下面拆出来,挂到 slave_2 下面(用命令 STOP SLAVE IO_THREAD;CHANGE MASTER TO slave_2;START SLAVE IO_THREAD
  • slave_1 上停掉 slave,起来 master(用命令 stop slave;reset slave all;show master status
  • slave_1 上还要启用 replication 的用户

slave_2 挂到 slave_1(new master) 下面

  • slave_2 上执行 STOP SLAVE IO_THREAD;CHANGE MASTER TO slave_1;START SLAVE IO_THREAD

master 变成 slave

  • master 上执行 reset master; reset slave all; CHANGE MASTER TO slave_1

slave_3 从 slave_2 下拆出来,挂到 master 下面

  • slave_3 下执行 STOP SLAVE IO_THREAD;CHANGE MASTER TO master;START SLAVE IO_THREAD

收尾工作:关掉 readonly 并改回域名解析

  • 从 slave_1(new master) 开始,逐级关掉 readonly(set global read_only=OFF;)
  • 确认数据同步正常之后,修改域名解析:
    • 将域名 mysql-slave.xxx.com 将 master(new slave) 加进去
    • 将域名 mysql-master.xxx.com 指向 slave_1(new master)

A5

2006 MySQL server has gone away

这个错误的坑我之前刚刚踩过(说是刚刚,其实也是有几个月了),这个错误出现的原因主要就是因为服务器端认为某个连接的 session 超时了,就给强制断掉了,这边客户端不知道,还在傻乎乎的发消息,于是就会得到错误提示:has gone away,控制这种超时的参数有两个:wait_timeoutinteractive_timeout,但是实际上影响超时的是 session 级别的 wait_timeout 参数。

而 session 级别的 wait_timeout 参数在客户端交互式登录(通常的 MySQL 客户端登录)时,继承的是 global 的 interactive_timeout 参数;而在非交互式登录(比如程序或 jdbc 这种连上来的情况),继承的是 global 级别的 wait_timeout 参数。

所以解决这个问题也有很多办法,最简单的,将这两个参数的值调大。其实这两个参数的缺省值是 8 小时,已经不小了。

要是仅从排错的角度出发的话,那么肯定要 MySQL server 要看日志、HAProxy 要看日志,出错的客户端要看日志,还要在出错的时候看 MySQL server 的状态(show processliss 什么的)

我刚看了有文档说,HAProxy 的 timeout server 和 timeout client 的两个值要跟 MySQL server 上的 session 级的 wait_timeout 一致。

还有,客户端连 HAProxy 的 MySQL 代理时,结束时要显式的主动断开连接。这个我想还好,最怕就是有连接池连 HAProxy 的 MySQL 的代理,我们当时踩坑也是因为有连接池……如果有,大概率是连接池的问题。

总结

我的答案不一定都对,因为我也没有环境去具体测试,但如果这些题是工作中给到我的真实工作内容,我有信心很好的完成他们。


本文由 老杨 原创,转载请注明出处。