PostgreSQL学徒

分析问题的同时顺手搞定一下挖矿病毒

前言

今天群里一位小伙伴提了这样一个案例:

“

现象:业务运行过程中会频繁创建写入删除大量的unlogged表,在业务运行过程中系统的sys cpu比较高(40%)user cpu比较低(5%),在对应的系统上产生了大量的文件(600w+),业务运行过程中手动触发checkpoint几个小时都无法正常结束,等待事件为CheckpointStart,这个checkpoint无法完成可能跟什么有关系?当业务运行结束后手动checkpoint很快能完成,系统上的文件也很快会回收到10w以内

我想着复现一下,结果发现我的小破云主机又被挖矿了!那就先解决一下这个病毒。

分析

不同于之前的案例,👉🏻 活久见,数据库里存病毒? 这次的病毒会悄悄创建一个超级用户,然后将默认的 postgres 用户改为非 superuser,我特意重新初始化了一个数据库观察现象:

[postgres@xiongcc ~]$ pg_ctl -D pgdata/ start
waiting for server to start....2022-11-15 20:31:35.356 CST [31237] LOG:  starting PostgreSQL 14.2 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 4.8.5 20150623 (Red Hat 4.8.5-44), 64-bit
2022-11-15 20:31:35.356 CST [31237] LOG:  listening on IPv6 address "::1", port 5432
2022-11-15 20:31:35.356 CST [31237] LOG:  listening on IPv4 address "127.0.0.1", port 5432
2022-11-15 20:31:35.361 CST [31237] LOG:  listening on Unix socket "/tmp/.s.PGSQL.5432"
2022-11-15 20:31:35.366 CST [31238] LOG:  database system was shut down at 2022-11-15 20:31:30 CST
2022-11-15 20:31:35.369 CST [31237] LOG:  database system is ready to accept connections
 done
server started
[postgres@xiongcc ~]$ psql
psql (14.2)
Type "help" for help.

postgres=# \du
                                   List of roles
 Role name |                         Attributes                         | Member of 
-----------+------------------------------------------------------------+-----------
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}

postgres=# \q
[postgres@xiongcc ~]$ psql
psql (14.2)
Type "help" for help.

postgres=> \du
                              List of roles
 Role name |                   Attributes                    | Member of 
-----------+-------------------------------------------------+-----------
 postgres  | Create role, Create DB, Replication, Bypass RLS | {}
 root_user | Superuser 

可以看到数据库里莫名起来多了一个超级用户 root_user,并且 postgres 用户还变成了非 superuser,为了看是什么玩意在作祟,重新来一次并且打开全日志 log_statement = all

[postgres@xiongcc ~]$ cat pgdata/log/postgresql-2022-11-15_204513.log 
2022-11-15 20:45:13.729 CST [32352] LOG:  starting PostgreSQL 14.2 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 4.8.5 20150623 (Red Hat 4.8.5-44), 64-bit
2022-11-15 20:45:13.730 CST [32352] LOG:  listening on IPv6 address "::1", port 5432
2022-11-15 20:45:13.730 CST [32352] LOG:  listening on IPv4 address "127.0.0.1", port 5432
2022-11-15 20:45:13.734 CST [32352] LOG:  listening on Unix socket "/tmp/.s.PGSQL.5432"
2022-11-15 20:45:13.739 CST [32354] LOG:  database system was shut down at 2022-11-15 20:44:50 CST
2022-11-15 20:45:13.742 CST [32352] LOG:  database system is ready to accept connections
2022-11-15 20:45:16.725 CST [32363] LOG:  statement: SELECT r.rolname, r.rolsuper, r.rolinherit,
          r.rolcreaterole, r.rolcreatedb, r.rolcanlogin,
          r.rolconnlimit, r.rolvaliduntil,
          ARRAY(SELECT b.rolname
                FROM pg_catalog.pg_auth_members m
                JOIN pg_catalog.pg_roles b ON (m.roleid = b.oid)
                WHERE m.member = r.oid) as memberof
        , r.rolreplication
        , r.rolbypassrls
        FROM pg_catalog.pg_roles r
        WHERE r.rolname !~ '^pg_'
        ORDER BY 1;
2022-11-15 20:45:24.362 CST [32381] LOG:  execute lrupsc_11547_0: select usename, usesuper from pg_shadow;  👈🏻从此处开始
2022-11-15 20:45:24.366 CST [32384] LOG:  statement: CREATE ROLE root_user WITH LOGIN SUPERUSER PASSWORD '491a59125348c2fec3e18c1674253d6a';
2022-11-15 20:45:24.395 CST [32388] LOG:  execute lrupsc_11549_0: select usename from pg_shadow where usesuper = 't' and usename <> 'root_user';
2022-11-15 20:45:24.395 CST [32388] LOG:  statement: alter user "postgres" with nosuperuser

可以看到跑了这么几条语句

  • select usename, usesuper from pg_shadow;
  • CREATE ROLE root_user WITH LOGIN SUPERUSER PASSWORD '491a59125348c2fec3e18c1674253d6a';
  • select usename from pg_shadow where usesuper = 't' and usename <> 'root_user';
  • alter user "postgres" with nosuperuser

并且通过观察日志,还在跑一些SQL。不难理解:pg_shadow 里面存放的是用户名和加密后的密码,这个病毒通过定时扫描 pg_shadow 获取用户名和密码

2022-11-15 20:45:24.395 CST [32388] LOG:  statement: alter user "postgres" with nosuperuser
2022-11-15 20:46:24.361 CST [32464] LOG:  execute lrupsc_11550_0: select usename, usesuper from pg_shadow;
2022-11-15 20:46:24.366 CST [32467] LOG:  execute lrupsc_11551_0: select usename from pg_shadow where usesuper = 't' and usename <> 'root_user';
2022-11-15 20:47:24.362 CST [32531] LOG:  execute lrupsc_11552_0: select usename, usesuper from pg_shadow;
2022-11-15 20:47:24.367 CST [32534] LOG:  execute lrupsc_11553_0: select usename from pg_shadow where usesuper = 't' and usename <> 'root_user';
2022-11-15 20:48:24.364 CST [32598] LOG:  execute lrupsc_11554_0: select usename, usesuper from pg_shadow;
2022-11-15 20:48:24.371 CST [32601] LOG:  execute lrupsc_11555_0: select usename from pg_shadow where usesuper = 't' and usename <> 'root_user';

在以前的版本,用户名密码默认的加密方式是 md5,但是加密方式相对"简陋",如下 👇🏻

postgres=# show password_encryption ;
 password_encryption 
---------------------
 md5
(1 row)
postgres=# create role r_test password 'hello123' login; 
CREATE ROLE
postgres=# select usename,passwd from pg_shadow where usename='r_test';   
 usename |               passwd                
---------+-------------------------------------
 r_test  | md5bb0d7bef45a0530ac529e7b43943a2d1
(1 row)

postgres=# select 'md5'||md5('hello123r_test') as mypasswd,passwd,usename from pg_shadow where usename = 'r_test';
              mypasswd               |               passwd                | usename 
-------------------------------------+-------------------------------------+---------
 md5bb0d7bef45a0530ac529e7b43943a2d1 | md5bb0d7bef45a0530ac529e7b43943a2d1 | r_test
(1 row)

因此对于简单密码很容易就破解出来了。那么这个病毒藏在哪里呢,对于这种执行一下就结束的短进程该怎么去获取?这里就要推荐另外一个工具了:execsnoop

“

execsnoop是专门用于为追踪短时进程(瞬时进程)设计的工具;

它通过 ftrace 实时监控进程的 exec() 行为,并输出短时进程的基本信息,包括进程 PID、父进程 PID、命令行参数以及执行的结果。

github地址:https://github.com/brendangregg/perf-tools/blob/master/execsnoop,将上面的github的内容复制,然后写入execsnoop文件,并且加上x权限即可。让我们抓一下看看

[postgres@xiongcc ~]$ sudo ./execsnoop.sh 
Tracing exec()s. Ctrl-C to end.
Instrumenting sys_execve
   PID   PPID ARGS
   756    752 gawk -v o=1 -v opt_name=0 -v name= -v opt_duration=0 [...]
   757    755 cat -v trace_pipe
   780   1308 /usr/sbin/sshd -D -R
   786   1308 /usr/sbin/sshd -D -R
   808   1308 /usr/sbin/sshd -D -R
   814    813 awk /^(MemFree|Buffers|Cached):/ {free += $2}; END {print free} /proc/meminfo
   902    458 sleep 60
   926   1308 /usr/sbin/sshd -D -R
   929   1308 /usr/sbin/sshd -D -R
   942  1322 pg_mem
   ...
   ...

可以看到跑了个pg_mem,使用find搜索了一圈,最终在我的实例目录下面藏了个pg_mem的脚本,脚本具体的内容就不贴了,大致就是获取账号密码,试图获取有用信息然后发出去,删除了之后我的数据库就恢复正常了。

病毒处理好之后,我们再来看看这个小伙伴的问题,为什么Checkpoint耗时特别久?在他这个案例中,使用到了大量的unlogged表,并且用完就删了,我们知道在PostgreSQL中删除表/截断表之后,表对应的文件和索引等,会被抹零,留下一个空文件,然后在下一次checkpoint的时候删除。

在这个案例中,checkpoint"卡住"的时候系统中有600万个文件,让我们模拟一下,先构造很多空文件出来

[postgres@xiongcc ~]$ cat generate.sh 
#!/bin/bash
for i in {1..10000} 
do
        for j in {1..10000} 
        do
                psql -U postgres -c "CREATE unlogged TABLE barf${i}${j}(id SERIAL NOT NULL PRIMARY KEY )" -c "drop table barf${i}${j}"
        done
done

构造了38万个文件之后,可以看到统计文件数量就耗时约9s

[postgres@xiongcc 13892]$ time ls -l | wc -l
389909

real    0m9.120s
user    0m2.449s
sys     0m5.591s

这个时候做一下checkpoint,并抓一下堆栈

Image

我和这位小伙伴要了一下问题时的堆栈,基本一致

Image

再抓一下等待事件,可以看到有 CheckpointStart 和 CheckpointDone

  • CheckpointDone:Waiting for a checkpoint to complete,等待checkpoint结束
  • CheckpointStart:Waiting for a checkpoint to start,等待checkpoint开始
Wed 16 Nov 2022 10:26:32 AM CST (every 0.5s)

   wait_event    | wait_event_type 
-----------------+-----------------
 CheckpointStart | IPC
(1 row)

...

Wed 16 Nov 2022 10:27:11 AM CST (every 0.5s)

   wait_event   | wait_event_type 
----------------+-----------------
 CheckpointDone | IPC
(1 row)

Wed 16 Nov 2022 10:27:12 AM CST (every 0.5s)

   wait_event   | wait_event_type 
----------------+-----------------
 CheckpointDone | IPC
(1 row)

...

Wed 16 Nov 2022 10:27:51 AM CST (every 0.5s)

   wait_event   | wait_event_type 
----------------+-----------------
 CheckpointDone | IPC
(1 row)

Wed 16 Nov 2022 10:27:52 AM CST (every 0.5s)

 wait_event | wait_event_type 
------------+-----------------
 ClientRead | Client
(1 row)

checkpoint 完成大概花费了90s,换算一下,40万花费90s,那么600万1350秒,大概20分钟,并且这是理想的状态下,我这个测试环境将 checkpoint_timeout 设置为了300m,为了在构建的过程中不要执行 checkpoint,假如实际系统中还有大量脏页,这个过程还会更久,600万个文件遍历统计一次都需要耗时很久。

这个其实和 WAL 的归档类似,归档进程需要遍历所有的 ready 文件并找到最老的文件,假如是高负载的服务器并且磁盘比较搓,归档可能落后了几千个 WAL,这样每一次遍历就会耗时很久,然后恶性循环,笔者曾经的项目,分布式数据库,往 HDFS 上备份,遇到过归档速度远远跟不上生成速度的,最终就放弃了PITR这个方案,采用定时逻辑备,牺牲一部分的 RPO。

Image

小结

这个问题要解决的话,更多得从业务侧去进行整改,数据库层面让 checkpoint 触发更快一点或许可以缓解这个问题,但是治标不治本,checkpoint 本身耗时就很久,并且 checkpoint 频繁了,WAL 写放大的问题会更严重,IO 尖刺也会更加明显,所以这是一个 trade-off 。