3. 编写第一个init脚本:start/stop/restart/status函数实现、chkconfig管理

好,到了真正动手的时候了。前面两章我们把init脚本的来龙去脉和基本结构聊清楚了,这一章咱们直接写一个能用的脚本出来。我个人习惯是,学任何东西都得先有个能跑起来的例子,不然光看理论总觉得隔着一层。

咱们的目标很明确:写一个可以被 service 命令管理、能被 chkconfig 纳入开机启动体系的init脚本。它得实现四个核心函数——start、stop、restart、status。说白了,就是让系统管理员能像这样操作它:

service myapp start
service myapp stop
service myapp restart
service myapp status

3.1 脚本的基本骨架

先搭个架子出来。每个init脚本本质上就是一个Shell脚本,但它必须遵守一些约定。我见过不少新手直接写业务逻辑,忘了加LSB头信息,结果 chkconfig 根本不认——嗯,这里要注意,头信息不是注释,是给工具看的元数据。

#!/bin/bash
#
# chkconfig: 2345 80 20
# description: My Custom Application Service
# processname: myapp
# config: /etc/myapp/myapp.conf

# Source function library
. /etc/init.d/functions

# 定义程序路径和名称
APP_NAME="myapp"
APP_PATH="/usr/local/myapp"
APP_BIN="$APP_PATH/bin/$APP_NAME"
APP_CONF="/etc/myapp/myapp.conf"
LOCK_FILE="/var/lock/subsys/$APP_NAME"
PID_FILE="/var/run/$APP_NAME.pid"

# 检查配置文件是否存在
[ -f "$APP_CONF" ] || exit 6

看到 chkconfig: 2345 80 20 这行了吗?这行决定了脚本在哪些运行级别启动,以及启动/关闭的优先级。2345表示在运行级别2、3、4、5下管理这个服务;80是启动优先级(数字越小越早启动);20是关闭优先级。我在项目中遇到过优先级冲突导致服务启动失败的情况,所以建议你给每个服务留出足够的间隔。

3.2 实现start函数

start函数的核心任务是:检查程序是否已经在运行,如果没有,就启动它,并记录PID。这里有个坑——很多程序启动后不会自动写PID文件,你得自己处理。

start() {
    echo -n "Starting $APP_NAME: "

    # 检查是否已经在运行
    if [ -f "$PID_FILE" ]; then
        read PID < "$PID_FILE"
        if kill -0 "$PID" 2>/dev/null; then
            echo "already running (PID: $PID)"
            return 0
        fi
    fi

    # 启动程序
    daemon --pidfile="$PID_FILE" "$APP_BIN" --config="$APP_CONF"
    RETVAL=$?

    # 检查启动结果
    if [ $RETVAL -eq 0 ]; then
        touch "$LOCK_FILE"
        echo "OK"
    else
        echo "FAILED"
    fi
    return $RETVAL
}

这里用了 daemon 函数,它是 /etc/init.d/functions 提供的工具函数。它会自动处理标准输出重定向、PID文件写入这些琐事。你可能会问:为什么不用 $! 直接取PID?因为有些程序会fork子进程,$! 拿到的是中间进程的PID,不是最终守护进程的。

3.3 实现stop函数

stop函数要优雅地终止进程。我个人的经验是:先发SIGTERM,给程序几秒钟时间自己清理,不行再发SIGKILL。直接kill -9是不礼貌的,万一程序正在写数据库呢?

stop() {
    echo -n "Stopping $APP_NAME: "

    # 检查PID文件
    if [ ! -f "$PID_FILE" ]; then
        echo "not running"
        return 0
    fi

    read PID < "$PID_FILE"

    # 先发SIGTERM
    kill "$PID" 2>/dev/null

    # 等待进程退出,最多等5秒
    for i in $(seq 1 5); do
        if ! kill -0 "$PID" 2>/dev/null; then
            break
        fi
        sleep 1
    done

    # 如果还没退出,强制杀掉
    if kill -0 "$PID" 2>/dev/null; then
        kill -9 "$PID" 2>/dev/null
        echo "KILLED"
    else
        echo "OK"
    fi

    # 清理文件
    rm -f "$PID_FILE" "$LOCK_FILE"
    return 0
}

我曾经遇到过一个问题:程序已经挂了,但PID文件还留着。这时候 kill -0 会失败,但脚本会认为进程不存在,直接返回。所以stop函数里要先检查PID文件是否存在,再检查进程是否存在——顺序很重要。

3.4 实现restart和status函数

restart说白了就是stop再start。但有个细节:如果stop失败了,要不要继续start?我个人倾向于不管stop结果如何,都尝试start。因为有时候stop失败是因为进程已经死了,但PID文件没清理干净。

restart() {
    stop
    start
}

status() {
    # 检查PID文件
    if [ ! -f "$PID_FILE" ]; then
        echo "$APP_NAME is stopped"
        return 3
    fi

    read PID < "$PID_FILE"

    # 检查进程是否存在
    if kill -0 "$PID" 2>/dev/null; then
        echo "$APP_NAME (PID: $PID) is running"
        return 0
    else
        echo "$APP_NAME is dead but PID file exists"
        return 1
    fi
}

status函数的返回值是有讲究的:0表示运行中,1表示进程死了但PID文件还在,3表示服务未启动。这些返回值会被监控系统用到,比如Nagios或Zabbix会检查这些退出码。

3.5 主逻辑与case分发

有了四个核心函数,最后一步就是根据命令行参数来调用它们。这是init脚本的标准写法:

case "$1" in
    start)
        start
        ;;
    stop)
        stop
        ;;
    restart)
        restart
        ;;
    status)
        status
        ;;
    *)
        echo "Usage: $0 {start|stop|restart|status}"
        exit 2
        ;;
esac

exit $RETVAL

这里有个小技巧:exit $RETVAL 中的 RETVAL 需要在每个函数里更新。我习惯在脚本开头定义 RETVAL=0,然后在每个函数里赋值。这样主逻辑结束时就能返回正确的退出码。

3.6 使用chkconfig管理脚本

脚本写好了,怎么让它开机自启?这就轮到 chkconfig 出场了。它实际上是在 /etc/rc.d/rc*.d 目录下创建符号链接,指向 /etc/init.d 里的脚本。

安装脚本到系统:

# 把脚本放到init.d目录
cp myapp /etc/init.d/myapp
chmod 755 /etc/init.d/myapp

# 添加到chkconfig管理
chkconfig --add myapp

# 查看管理状态
chkconfig --list myapp

# 手动设置开机启动
chkconfig myapp on

# 手动关闭开机启动
chkconfig myapp off

chkconfig --add 会读取脚本头部的 chkconfig: 行,然后自动在对应的运行级别目录下创建链接。比如 chkconfig: 2345 80 20 会在 /etc/rc.d/rc2.d、rc3.d、rc4.d、rc5.d 下创建 S80myapp 和 K20myapp 这样的链接。

关键概念: S 开头的链接表示启动(Start),K 开头的表示关闭(Kill)。后面的数字是优先级。系统启动时按 S 链接的数字从小到大执行;关闭时按 K 链接的数字从小到大执行。所以启动优先级80意味着它在大多数服务之后启动,关闭优先级20意味着它在大多数服务之前关闭——这样设计是为了避免依赖问题。

3.7 完整脚本示例

把上面所有片段拼起来,就是一个完整的init脚本了。我建议你把它保存为 /etc/init.d/myapp,然后试试 service myapp start 看看效果。

#!/bin/bash
#
# chkconfig: 2345 80 20
# description: My Custom Application Service
# processname: myapp
# config: /etc/myapp/myapp.conf

. /etc/init.d/functions

APP_NAME="myapp"
APP_PATH="/usr/local/myapp"
APP_BIN="$APP_PATH/bin/$APP_NAME"
APP_CONF="/etc/myapp/myapp.conf"
LOCK_FILE="/var/lock/subsys/$APP_NAME"
PID_FILE="/var/run/$APP_NAME.pid"
RETVAL=0

[ -f "$APP_CONF" ] || exit 6

start() {
    echo -n "Starting $APP_NAME: "
    if [ -f "$PID_FILE" ]; then
        read PID < "$PID_FILE"
        if kill -0 "$PID" 2>/dev/null; then
            echo "already running (PID: $PID)"
            return 0
        fi
    fi
    daemon --pidfile="$PID_FILE" "$APP_BIN" --config="$APP_CONF"
    RETVAL=$?
    if [ $RETVAL -eq 0 ]; then
        touch "$LOCK_FILE"
        echo "OK"
    else
        echo "FAILED"
    fi
    return $RETVAL
}

stop() {
    echo -n "Stopping $APP_NAME: "
    if [ ! -f "$PID_FILE" ]; then
        echo "not running"
        return 0
    fi
    read PID < "$PID_FILE"
    kill "$PID" 2>/dev/null
    for i in $(seq 1 5); do
        if ! kill -0 "$PID" 2>/dev/null; then
            break
        fi
        sleep 1
    done
    if kill -0 "$PID" 2>/dev/null; then
        kill -9 "$PID" 2>/dev/null
        echo "KILLED"
    else
        echo "OK"
    fi
    rm -f "$PID_FILE" "$LOCK_FILE"
    RETVAL=0
    return 0
}

restart() {
    stop
    start
}

status() {
    if [ ! -f "$PID_FILE" ]; then
        echo "$APP_NAME is stopped"
        return 3
    fi
    read PID < "$PID_FILE"
    if kill -0 "$PID" 2>/dev/null; then
        echo "$APP_NAME (PID: $PID) is running"
        return 0
    else
        echo "$APP_NAME is dead but PID file exists"
        return 1
    fi
}

case "$1" in
    start)
        start
        ;;
    stop)
        stop
        ;;
    restart)
        restart
        ;;
    status)
        status
        ;;
    *)
        echo "Usage: $0 {start|stop|restart|status}"
        exit 2
        ;;
esac

exit $RETVAL

调试小技巧: 如果你不确定脚本有没有问题,可以用 bash -x /etc/init.d/myapp start 来调试。它会打印每一行命令的执行过程,方便你定位问题。我在排查启动失败时,十次有八次是靠这个找到原因的。

3.8 init脚本的核心流程

下面这张图把整个init脚本的生命周期和chkconfig的管理流程串起来了。你可以看到,从脚本编写到最终开机自启,中间经过了哪些环节。

init脚本生命周期与chkconfig管理流程 编写init脚本 包含LSB头 + 4个函数 安装到/etc/init.d cp + chmod 755 chkconfig --add 读取chkconfig头信息 chkconfig自动创建符号链接 /etc/rc.d/rc2.d/S80myapp → /etc/init.d/myapp /etc/rc.d/rc3.d/S80myapp → /etc/init.d/myapp 系统启动时 按S链接优先级从小到大执行 调用 /etc/init.d/myapp start 手动管理 service myapp start|stop|restart|status chkconfig myapp on|off 💡 关键点: • LSB头中的 chkconfig 行决定了运行级别和优先级 • S80 表示启动优先级80,K20 表示关闭优先级20

3.9 常见问题与避坑指南

写init脚本的时候,有几个坑我踩过不止一次,分享给你:

  • PID文件路径不一致: 程序自己写的PID文件和脚本里定义的路径对不上。我曾经排查了一个小时,最后发现程序把PID写到了 /tmp 下,而脚本在 /var/run 里找——当然找不到。
  • daemon函数找不到: 如果你在脚本里用了 daemon 或 killproc,一定要记得 . /etc/init.d/functions。我见过有人直接复制脚本,忘了这行,结果报 command not found。
  • chkconfig头信息格式错误: chkconfig: 后面必须跟三个参数,而且要用空格分隔。少一个参数,chkconfig --add 就会静默失败——不报错,但也不创建链接。
  • 权限问题: 脚本必须要有执行权限(755),而且PID文件和锁文件的目录要确保存在。有些系统默认没有 /var/lock/subsys 目录,需要手动创建。

⚠️ 重要提醒: 在CentOS 7及以上版本中,chkconfig 已经被 systemctl 取代。但理解init脚本的原理仍然很重要——很多容器镜像、嵌入式系统、老旧服务器还在用SysV init。而且,systemd的service单元文件本质上也是在描述同样的东西:什么时候启动、怎么启动、怎么停止。