Chaofan's

Bonvenon al la malpura mondo.

标签: Linux

  • 把服务器迁移回Debian和Docker

    去年,运行这个网站和Mastodon实例的服务器是一台Debian 9,后来跳级升级到Debian 11后,遇到一些零散的版本不兼容问题。年初,因为自己在折腾ZFS的NAS,所以我也将服务器迁移到了FreeBSD. 老实说FreeBSD在很多方面比起Linux更「原汁原味」,比如严格区分第三方软件的/usr/local/etc和系统/etc目录;而且没有systemd,采用的更传统风格的rc.d启动脚本。

    如果你的服务器也是FreeBSD系统,且也想在其上运行Mastodon实例的话,分享一下我的Mastodon实例启动脚本,放在/usr/local/etc/rc.d目录下,然后在rc.conf文件启用即可。注意ruby和node需要通过rbenv和nodenv等工具自行安装。

    再次切换回Linux的起因是,我尝试升级PostgreSQL到14版本,但包管理器提示PostgreSQL 14和PHP PostgreSQL驱动相互冲突,会导致PHP数据库驱动被卸载。更新系统,以及将PHP升级到8.2后依然存在该问题。再者,自从迁移至FreeBSD后,各项服务的响应速度一直不如Linux快,总是要慢一截。我当然不愿相信是FreeBSD性能真的不如Linux,但一时也没找到确切原因,甚至让我对ZFS留下了一些不好的印象。加之FreeBSD在服务器提供商那里没有现成的监控工具,不支持Swift语言等原因,我最终决定迁移回Debian系统,并且尝试以全Docker方式部署服务。

    在此之前我对Docker并没有那么多经验,只在大学某些课程项目和工作时需要的测试环境里短暂尝试过。甚至一度因为macOS运行Docker需要虚拟机,忘记了原因是依赖Linux内核功能,而习惯性认为Linux服务器上也需要一层虚拟化运行Docker造成性能损失。所以一直没有在自己的服务器上尝试使用Docker环境。由于服务规模没有那么大,就不折腾Kubernetes或Swarm这些东西了,直接用Docker Compose就好。

    我自己修改过的docker-compose.yml文件就不完整贴在这里了,需要的朋友可以基于Mastodon的这个参考文件编写。需要注意的几点:

    • 参考文件里的数据卷(Volume)映射用的是相对路径,如果你有很多个服务,可能不会想在clone下来的mastodon源码目录里运行其他所有服务,所以要修改一下路径,或者干脆改成绝对路径
    • 从Docker Hub拉下来的tootsuite/mastodon:latest镜像已经是构建完成的了,所以请把build: .注释掉
    • 如果一个服务只有internal_network,那么不光它无法接受外部请求,也不能向外界发出任何网络请求。所以如果有服务需要向外面抓取信息,请加入external_network
    • Docker Compose的内部网络里通信的服务之间,主机名将不再是localhost,请留意docker-compose up时输出的实例名,通常来说是「启动服务时所在目录_服务名_1
    • 如果你使用Let’s Encrypt,特别是从一个已有Let’s Encrypt目录的服务器迁移过来,请记得,(/usr/local)/etc/letsencrypt/live中的证书不是真实文件,而是指向../../archive目录的符号链接!单纯映射live目录会导致Docker环境无法找到证书
    • 如果你不是用root用户管理一切,那么Docker内部的PHP可能没有修改映射目录内容的权限,导致WordPress能PHP服务无法自更新,请chown一下

    另外,默认的PHP镜像并没有安装很多常用的数据库扩展,我们需要写一个自己的Dockerfile,放在某个目录里,然后在docker-compose.yml中PHP服务一项指定build:值。Dockerfile参考:

    FROM php:8-fpm-bullseye
    
    RUN apt-get update
    
    RUN apt-get install -y libpq-dev libzip-dev libicu-dev libpng-dev \
        && docker-php-ext-install mysqli \
        && docker-php-ext-configure pgsql --with-pgsql=/usr/local/pgsql \
        && docker-php-ext-install pdo pdo_pgsql pgsql zip exif gd intl

    如果有什么Cronjob需要利用Docker内部环境执行也很简单,使用docker exec CONTAINER_NAME COMMAND即可,注意如果是给cronjob执行,不要加-it选项,否则会有invalid tty错误;如果是终端里手动执行,需要加上。

  • 关于命令行参数的那些事

    事情是这样的。这学期的C语言课程期末的大作业(说实话我不太愿意称其为“项目”……),就像我的前几篇博文里提到的,要求编写一个程序,可以读入一串字符,包含声明变量、求值、打印的功能。如何进行词法分析和构建表达式树的过程在前两篇文章我已经说过了,这里不再赘述。那么很多同学问起来的一个问题是,在作业的要求里面,有一项是“从第一个命令行参数读入文件名”——这个到底是什么意思?

    有些同学把它理解成了从标准输入获取一个文件名的字符串。大错特错啦。不过也难怪,我们这个年代的人小时候接触DOS的机会不多了,可能从小到大根本就没有在计算机上敲过命令,以至于第一次写C程序的时候看到那个黑漆漆的窗口还大失所望。

    不过现在好了,至少你晓得了电脑上某一个“角落”存在着这样一个“黑框”,你可以在上面输入东西。在Windows系统上,我们可以通过开始-运行(没有开始菜单的话就按Win+R键),弹出“运行”窗口,输入cmd然后回车。这样我们就打开了一个“命令提示符”窗口。(什么,你用的是Linux?用Linux还不知道命令参数是什么,我劝你还是卸了吧)

    这个窗口在Windows NT系统里可以作为对DOS界面的一种模拟,因为不是所有的Windows应用程序都有图形界面的,比如我们现在写的C程序就没有,所谓的标准输入和输出只能在cmd模拟器里面跑。(如果你对命令行窗口仍有疑惑,可以参考我在知乎上的这个回答)在黑窗下,我们可以尝试敲敲一些命令:

    • dir,列出当前所在目录下的文件
    • cd,进入某个目录,cd ..表示回到上一层目录
    • 盘符,进入某个分区,比如输入D:回车表示进入D盘
    • exit,退出命令提示符
    • del,删除某个文件(注意可没有回收站让你反悔)
    • 把文件拖动到命令窗口,命令会自动显示这个文件的路径,直接输入文件名回车系统会调用适合的程序打开它

    好了,基本的操作我们已经熟悉了。这样已经够了。对啊,比如说我们用del命令的时候,del a.txt,这里的a.txt就是del命令的命令行参数!在Windows下,我们的程序最后会编译成某个.exe文件,IDE执行它是自动启动的,不会给我们输入命令行参数的机会(讲道理的话是可以设置的)。假设我们编译成某个可执行程序,放在当前所在的目录下,比如说,叫mao.exe吧,然后源文件名叫做mao.mao,我们这样输入:

    mao.exe mao.mao

    系统会给mao.exe应用程序的main函数传入argv参数。不过前提是你要写对main函数的原型,像这样:

    int main(int argc, char *argv[])
    {
        /* 代码在这里 */
        return 0;
    }

    那么对于上面的例子,我们的两个参数的值应该是这样的:argc=2,argv[0]="mao.exe",argv[1]="mao.mao".(唔也有可能是完整路径,懒得测了)然后我们打开文件的话,像这样打开就行了:

    #include <stdio.h>
    
    int main(int argc, char *argv[])
    {
        if (argc &gt; 1) {
            FILE *fp;
            if ((fp = fopen(argv[1], "r")) == NULL) {
                printf("Open file failed.\n");
            } else {
                /* 打开之后的操作在这里 */
                fclose(fp);
                /* 不要忘记了关闭 */
            }
        } else {
            printf("Not enough parameters!\n");
        }
        return 0;
    }

    当然我的代码仅仅只作为一个参考,抄去说不定程序会崩哦。

    好了,如何操作我们就先说到这里。你可能会纳闷——为什么要弄得这么麻烦?

    • 普通回答:为了让同学们熟悉一下文件操作和命令行参数的知识,提高自己查找知识解决问题的能力
    • 二逼回答:因为助教用的是Linux,这样做会很方便
    • 文艺回答:每一个C程序员都要对Unix系系统有亲近感,这是培养亲近感的很好的方式

    好吧,今天就说到这里,如果你们对Linux有兴趣,下次可以专门聊聊。

  • Linux下程序创建进程

    进程是操作系统中运行的程序实例。而多进程程序和多线程程序相比,具有更健壮,更简单的特点。

    在GNU/Linux操作系统中,创建一个新进程,可以使用fork,clone函数以及使用exec函数族调用其他程序替换当前进程镜像。

    这里主要讲fork函数。

    fork函数的原型为:

    #include <unistd.h>
    
    pid_t fork(void);

    pid_t是系统定义的类型,一般被定义为short int。

    这里看一个最简单的调用示例。

    #include <stdio.h>
    
    #include <unistd.h>
    
    int main(void)
    {
        pid_t pid;
        pid = fork();
        printf("My process ID is %d.\n", getpid());
        return 0;
    }

    这样就最简单的创建了一个子进程,并且打印出了进程的pid。

    fork函数是分裂执行的,这也就是fork(分叉)命名的原因吧。如何理解这个“分裂”呢,看这段程序。

    #include <stdio.h>
    #include <unistd.h>
    
    int main(void)
    {
        pid_t pid;
        pid = fork();
        if (pid > 0) {
            printf("I'm parent process.\n");
            /* 父进程的pid大于0 */
        } else if (pid == 0) {
            printf("I'm child process.\n");
            /* 子进程的pid等于0 */
        } else {
            printf("Cannot create process.\n");
            /* 如果pid小于0,表示出错 */
        }
        return 0;
    }

    内核在第一次调用fork()时,将当前进程的所有内存空间和文件描述符等资源复制一份给创建的子进程(实际上采用了“Copy-on-write”(写时复制),第一次试图对内存进行写操作的时候才复制,提高了效率)。所以fork调用后有2个进程在同时执行后面的代码。如何区分呢?

    在父进程中,pid变量被标记为一个正整数;而子进程的pid被标记为0;当然,当pid为负数时,系统只有1个进程运行,表示创建进程出错。

    如何证明这一点呢?看下面这个程序:

    #include <stdio.h>
    #include <unistd.h>
    
    int main(void)
    {
        pid_t pid1, pid2, pid3;
        pid1 = fork();
        pid2 = fork();
        pid3 = fork();
        printf("I'm a process.\n");
        return 0;
    }

    这个程序会打印出几个”I’m a process.”字符串?答案是8个(2的3次方)。如果再加一个fork答案就会是16个(2的4次方)。运行结果证明了这一点。第一次fork调用产生一个子进程,第二次两个进程各产生一个,第三次四个进程各又产生一个……所以结果是2*2*2=8个。(fork炸弹?哈哈)

    现在你会发现,调用fork()的程序需要ctrl+c才能退出。这是因为父进程在等待子进程退出。如果父进程在子进程结束之前退出了,那么子进程就会成为所谓的“僵尸进程”。

    要结束子进程可以使用wait函数。

    #include <unistd.h>
    
    pid_t wait(int * status);

    返回退出子进程的pid。调用后可以从status了解到wait的调用状态。

    以下的宏可以用来校验status变量。

    WIFEXITED正常退出,值为true
    WEXITSTATUS返回子进程exit状态,为int
    WIFSIGNALED子进程是否因为信号结束,是则为true
    WTERMSIG返回子进程退出的信号号(上个宏为true时才有意义)
    评估wait状态所用的宏

    如果当前有多个子进程,系统怎么能知道我要结束哪一个呢?所以,有了waitpid函数。

    #include <unistd.h>
    
    pid_t waitpid(pid_t pid, int * status, int options);

    pid参数表示需要等待结束的子进程。几种值的情况如下:

    >0等待pid的进程退出
    0等待任何一个与调用进程组ID相同的子进程退出
    -1等待任何一个子进程退出(相当于wait())
    <-1等待任何一个组ID与pid参数绝对值相同的子进程退出
    waitpid的pid参数值

    options提供了一些额外的选项来控制waitpid,目前只支持WNOHANG和WUNTRACED两个选项,可以用|连接起来。

    WNOHANG,如果没有子进程退出,它也会立即返回,不会一直等下去。

    WUNTRACED,用于跟踪调试。

    如果不想用options,可以传一个参数0。

    waitpid的status多了两个校验的宏,不过仅在设置WUNTRACED后可用。

    WIFSTOPPED如果子进程已经停止,返回true
    WSTOPSIG返回使子进程停止的型号(上个宏为true时才有意义)
    waitpid增加的校验宏

    如果调用出错,返回-1,并且errno被设置成特定的值;如果WNOHANG被设置且没有子进程退出,返回0;否则返回子进程的pid号。

    不过,有时候不需要这么麻烦。

    #include <unistd.h>
    #include <signal.h>
    
    int kill(pid_t pid, int sig_num);

    kill用于向进程发送信号。pid的各种值情况如下表:

    >0信号发送到pid指定的进程
    0信号发送到调用进程同组的所有进程
    -1信号发送到除init外的所有进程
    <0信号发送到pid绝对值指定进程组中所有进程
    kill函数的参数pid的值

    还有函数raise,用于给自己发信号。

    #include <unistd.h>
    #include <signal.h>
    
    int raise(int sig_num);
    /* 等价于 kill(getpid(), sig_num); */

    所以,终止子进程也可以这样:

    #include <unistd.h>
    #include <signal.h>
    
    int main(void)
    {
        pid_t pid = fork();
        kill(pid, SIGKILL);
        return 0;
    }

    这里讲了fork函数创建子进程的一些用法。关于exec函数族和信号,将会在以后的文章里说到。

    希望这篇文章能带给您以收获!