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的管理流程串起来了。你可以看到,从脚本编写到最终开机自启,中间经过了哪些环节。
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单元文件本质上也是在描述同样的东西:什么时候启动、怎么启动、怎么停止。