无尘阁日记

无尘阁日记

找出一次代码不执行因为内存超出杀死进程的问题
2025-08-22

问:

Aug 22 14:29:37 ali-national-pro-api kernel: oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=d1fc65f7640062b559d217d35e76b5ca8386c633322b0effca1a3f062fadecb7,mems_allowed=0,oom_memcg=/docker/d1fc65f7640062b559d217d35e76b5ca8386c633322b0effca1a3f062fadecb7,task_memcg=/docker/d1fc65f7640062b559d217d35e76b5ca8386c633322b0effca1a3f062fadecb7,task=php-fpm,pid=629188,uid=48


这是啥意思

答:

你贴的这一段日志其实是 Linux 内核的 OOM killer(Out Of Memory Killer) 在工作时的记录。意思是系统内存(或者 cgroup 限制的内存)不够了,内核强制杀掉了一些进程来释放内存。我们逐段解释一下:


日志逐句拆解

Aug 22 14:29:37 ali-national-pro-api kernel: oom-kill:

时间戳是 8 月 22 日 14:29:37,机器名是 ali-national-pro-api,信息来自 kerneloom-kill 表示内存不足(Out Of Memory)时内核执行的“杀进程”操作。

constraint=CONSTRAINT_MEMCG

表示触发 OOM 的约束条件是 cgroup 内存限制
也就是说,不是整个机器的内存耗尽,而是某个 容器(docker cgroup)分配的内存额度用完了

nodemask=(null)

这里没有限制 NUMA 节点。

cpuset=d1fc65f7640062b559d217d35e76b5ca8386c633322b0effca1a3f062fadecb7

这是对应容器的 cpuset(CPU 和内存绑定的集合)。一串哈希,看起来就是 Docker 容器的 ID。

mems_allowed=0

允许使用的内存节点编号。单 NUMA 节点机器时一般就是 0。

oom_memcg=/docker/d1fc65f7640062b559d217d35e76b5ca8386c633322b0effca1a3f062fadecb7

说明是 Docker 容器里的 memory cgroup 内存超限。

task_memcg=/docker/d1fc65f7640062b559d217d35e76b5ca8386c633322b0effca1a3f062fadecb7

具体触发 OOM 的进程所在的 memory cgroup。

task=php-fpm,pid=629188,uid=48

最后是被杀掉的进程:php-fpm,进程号 629188,用户 ID 48(在很多 Linux 里 UID=48 是 apache 或 php-fpm 的服务用户)。


总结

简而言之:

  • 在 某个 Docker 容器 内,php-fpm 进程占用内存过多,超出了该容器的内存限制;

  • 内核的 OOM Killer 被触发,挑选并杀掉了这个 php-fpm 进程以释放内存;

  • 并不是整台宿主机内存不足,而是容器级别的内存限制被打满。


建议的对策

  1. 检查容器内存限制
    用 docker inspect <container_id> | grep -i memory 看看容器设置的 memory 限制是多少。

  2. 查看 php-fpm 配置

    • 每个子进程的内存消耗可能偏高。

    • 检查 pm.max_children 等参数,避免开太多进程。

  3. 监控内存使用
    在容器里用 tophtop 或 smem,或者在宿主机上用 docker stats 来观察内存曲线。

  4. 调大内存或优化代码

    • 若业务确实需要更多内存,调大 Docker 的 --memory 限制。

    • 如果是代码泄漏或缓存过度,要排查并优化 PHP 程序。

    • 把以前针对收益法模型的失败重试机制启动起来,至少自己重试3次,以保证最大的成功率。


加入sheel脚本监控:

#!/usr/bin/env bash

set -euo pipefail


# =========================

# 配置(可在命令行参数覆盖)

# =========================

CONTAINERS=${CONTAINERS:-""}           # 空则监控所有运行中的容器

THRESHOLD=${THRESHOLD:-85}             # 预警阈值(百分比)

INTERVAL=${INTERVAL:-60}               # 轮询间隔秒数(仅 --loop 时生效)

ONCE=${ONCE:-0}                        # 1=只检查一次后退出

HYSTERESIS=${HYSTERESIS:-5}            # 抖动回滞:降至(阈值-该值)才视为恢复

STATE_DIR=${STATE_DIR:-/var/run/docker-mem-watch}

LOG_TAG="docker-mem-watch"


# 可选:设置 Slack/Webhook 通知(留空则不发)

WEBHOOK_URL="${WEBHOOK_URL:-}"


usage() {

  cat <<EOF

用法: $0 [--containers "c1 c2"] [--threshold 85] [--interval 60] [--once] [--loop]

环境变量也可配置:CONTAINERS, THRESHOLD, INTERVAL, HYSTERESIS, WEBHOOK_URL

例子:

  $0 --containers "web php" --threshold 90 --once

  CONTAINERS="web php" THRESHOLD=90 WEBHOOK_URL="https://xxxx" $0 --loop

EOF

}


# 单位转换,例如 "1.23GiB" -> bytes

to_bytes() {

  local v="$1"

  # 形如 "123.45MiB" 或 "123B"

  local num unit

  num=$(echo "$v" | awk '{print $1}' | sed 's/,/./g')

  unit=$(echo "$v" | awk '{print $2}')

  case "$unit" in

    B|Bytes|"")       awk -v n="$num" 'BEGIN{printf "%.0f", n}' ;;

    kB|KB)            awk -v n="$num" 'BEGIN{printf "%.0f", n*1024}' ;;

    MB|MiB)           awk -v n="$num" 'BEGIN{printf "%.0f", n*1024*1024}' ;;

    GB|GiB)           awk -v n="$num" 'BEGIN{printf "%.0f", n*1024*1024*1024}' ;;

    TB|TiB)           awk -v n="$num" 'BEGIN{printf "%.0f", n*1024*1024*1024*1024}' ;;

    *)                # 某些 docker 版本 MemUsage like: "123MiB"

                      if [[ "$v" =~ ^([0-9\.\,]+)([KMGTP]i?B)$ ]]; then

                        num="${BASH_REMATCH[1]}"; unit="${BASH_REMATCH[2]}"

                        case "$unit" in

                          KiB|KB) awk -v n="$num" 'BEGIN{printf "%.0f", n*1024}' ;;

                          MiB|MB) awk -v n="$num" 'BEGIN{printf "%.0f", n*1024*1024}' ;;

                          GiB|GB) awk -v n="$num" 'BEGIN{printf "%.0f", n*1024*1024*1024}' ;;

                          TiB|TB) awk -v n="$num" 'BEGIN{printf "%.0f", n*1024*1024*1024*1024}' ;;

                          *) echo 0 ;;

                        esac

                      else

                        echo 0

                      fi

    ;;

  esac

}


send_alert() {

  local level="$1"  # ALERT or RECOVER

  local name="$2"

  local cid="$3"

  local pct="$4"

  local usage_human="$5"

  local limit_human="$6"


  logger -t "$LOG_TAG" "$level: $name ($cid) memory ${pct}% [${usage_human}/${limit_human}]"


  if [[ -n "$WEBHOOK_URL" ]]; then

    # 简单 JSON;若是 Slack 兼容 webhook,直接显示文本

    curl -m 5 -sS -X POST -H 'Content-Type: application/json' \

      -d "{\"text\":\"[$level] ${name} (${cid}) 内存使用 ${pct}% (${usage_human}/${limit_human})\"}" \

      "$WEBHOOK_URL" >/dev/null || true

  fi

}


ensure_state() { mkdir -p "$STATE_DIR"; }

set_state() { echo "$2" > "${STATE_DIR}/$1.state"; }

get_state() { [[ -f "${STATE_DIR}/$1.state" ]] && cat "${STATE_DIR}/$1.state" || echo "OK"; }


check_one() {

  local c="$1"


  # docker stats 可能出现 locale 百分号,我们自行算百分比更稳妥

  # MemUsage 形如 "123.45MiB / 2GiB"

  local mem_usage_line

  mem_usage_line=$(docker stats --no-stream --format "{{.MemUsage}}" "$c" 2>/dev/null || true)

  if [[ -z "$mem_usage_line" ]]; then

    logger -t "$LOG_TAG" "WARN: 无法获取 $c 的 stats"

    return

  fi


  local used_human limit_human

  used_human=$(echo "$mem_usage_line" | awk -F'/' '{gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}')

  limit_human=$(echo "$mem_usage_line" | awk -F'/' '{gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2}')


  local used_bytes limit_bytes

  used_bytes=$(to_bytes "$used_human")

  limit_bytes=$(to_bytes "$limit_human")


  # 若 inspect 有明确 Memory 限制,优先使用

  local hard_limit

  hard_limit=$(docker inspect -f '{{.HostConfig.Memory}}' "$c" 2>/dev/null || echo 0)

  if [[ "$hard_limit" -gt 0 ]]; then

    limit_bytes="$hard_limit"

    # 友好显示

    if   [[ "$limit_bytes" -ge 1099511627776 ]]; then limit_human="$(awk -v b="$limit_bytes" 'BEGIN{printf "%.2fTiB", b/1024/1024/1024/1024}')"

    elif [[ "$limit_bytes" -ge 1073741824     ]]; then limit_human="$(awk -v b="$limit_bytes" 'BEGIN{printf "%.2fGiB", b/1024/1024/1024}')"

    else                                                limit_human="$(awk -v b="$limit_bytes" 'BEGIN{printf "%.2fMiB", b/1024/1024}')"

    fi

  fi


  if [[ "$limit_bytes" -le 0 ]]; then

    logger -t "$LOG_TAG" "INFO: $c 未设置内存上限(可能使用宿主机上限),跳过阈值判断,仅记录。used=${used_human} limit=${limit_human}"

    return

  fi


  local pct

  pct=$(awk -v u="$used_bytes" -v l="$limit_bytes" 'BEGIN{printf "%.0f", (u/l)*100}')


  local name cid

  name=$(docker inspect -f '{{.Name}}' "$c" 2>/dev/null | sed 's#^/##' || echo "$c")

  cid=$(docker inspect -f '{{.Id}}' "$c" 2>/dev/null | cut -c1-12 || echo "$c")


  local state new_state

  state=$(get_state "$cid")

  new_state="$state"


  if [[ "$pct" -ge "$THRESHOLD" && "$state" != "ALERT" ]]; then

    new_state="ALERT"

    send_alert "ALERT" "$name" "$cid" "$pct" "$used_human" "$limit_human"

  elif [[ "$pct" -le $((THRESHOLD - HYSTERESIS)) && "$state" == "ALERT" ]]; then

    new_state="OK"

    send_alert "RECOVER" "$name" "$cid" "$pct" "$used_human" "$limit_human"

  fi


  set_state "$cid" "$new_state"

}


one_round() {

  local list=()

  if [[ -n "$CONTAINERS" ]]; then

    # 用户指定

    read -r -a list <<<"$CONTAINERS"

  else

    # 监控所有运行容器

    mapfile -t list < <(docker ps --format '{{.Names}}')

  fi


  for c in "${list[@]}"; do

    check_one "$c"

  done

}


main() {

  if [[ $# -gt 0 ]]; then

    while [[ $# -gt 0 ]]; do

      case "$1" in

        --containers) shift; CONTAINERS="${1:-}";;

        --threshold)  shift; THRESHOLD="${1:-}";;

        --interval)   shift; INTERVAL="${1:-}";;

        --once)       ONCE=1;;

        --loop)       ONCE=0;;

        -h|--help)    usage; exit 0;;

        *) echo "未知参数: $1"; usage; exit 1;;

      esac

      shift || true

    done

  fi


  ensure_state


  if [[ "$ONCE" -eq 1 ]]; then

    one_round

  else

    while :; do

      one_round

      sleep "$INTERVAL"

    done

  fi

}


main "$@"


———

一、它在做什么(总体功能)

  1. 监控一个或一组 Docker 容器的内存占用。

  2. 计算“已用/上限”的百分比,达到阈值就发出“ALERT”告警;降回到阈值以下(含回滞 HYSTERESIS)时发“RECOVER”恢复。

  3. 告警默认写入系统日志(syslog/journal,tag 为 docker-mem-watch),可选通过 WEBHOOK_URL 以 HTTP POST 发送到外部(你不需要就留空)。

  4. 可单次检测(—once,适合 cron)或循环检测(—loop + INTERVAL,适合常驻)。

  5. 既支持你显式列容器名,也支持“自动监控当前所有运行中的容器”。

———

二、它如何做到(工作流程)

  1. 解析参数与环境变量

    • 参数优先级高于环境变量。支持 —containers、—threshold、—interval、—once/—loop。

    • 环境变量允许在 cron/systemd 里直接赋值(CONTAINERS/THRESHOLD/INTERVAL/HYSTERESIS/WEBHOOK_URL/STATE_DIR)。

  2. 确定监控对象

    • 若设置 CONTAINERS,则按空格切分监控这些容器;

    • 否则通过 docker ps --format '{{.Names}}' 获取所有运行中容器名进行监控。

  3. 采样每个容器的内存

    • 运行 docker stats --no-stream --format "{{.MemUsage}}" <container>

    • 该字段一般是“已用 / 上限”形如:123.45MiB / 2GiB

    • 脚本从中切出“已用”和“上限”的人类可读数值,并通过 to_bytes 函数规范化为字节数,避免不同 Docker/locale 的单位差异。

    • 再用 docker inspect -f '{{.HostConfig.Memory}}' 获取容器硬上限(字节)。若有明确上限(>0),以此为准(更准确)。

  4. 计算百分比并做阈值判定

    • 百分比 pct = used_bytes / limit_bytes * 100(四舍五入为整数)。

    • 若容器未设置上限(limit_bytes <= 0),脚本只记录信息并跳过阈值判断(避免误报)。

    • 引入“回滞”HYSTERESIS:超过阈值触发一次 ALERT;只有降到 阈值 - 回滞 才触发一次 RECOVER,避免在边缘抖动。

  5. 告警与状态记忆

    • 通过 logger -t docker-mem-watch 把 ALERT/RECOVER 写到系统日志。

    • 若设置 WEBHOOK_URL,会再以简单 JSON POST 形式发送一条文本到你的 webhook。

    • 每个容器的当前状态(OK/ALERT)保存在 STATE_DIR(默认 /var/run/docker-mem-watch)下的 CID.state 文件中,用来判断本轮是否需要再发告警或恢复通知。

  6. 运行模式

    • 单次模式:--once,跑一轮后退出(适合 cron)。

    • 循环模式:--loop,每次 one_round 后 sleep $INTERVAL,持续运行(适合 systemd service 持久化)。

———

三、关键配置项(环境变量与参数)

  • CONTAINERS:空字符串表示监控所有运行容器;否则写 "php-fpm web" 这种空格分隔列表。

  • THRESHOLD:阈值百分比(默认 85),到达或超过触发 ALERT。

  • HYSTERESIS:回滞(默认 5),在 ALERT 状态下需降到 THRESHOLD - HYSTERESIS(例如 90-5=85%)才触发 RECOVER。

  • INTERVAL:循环模式下的采样间隔秒(默认 60)。

  • WEBHOOK_URL:可选的外部通知地址(为空则不发送)。

  • STATE_DIR:状态文件目录(默认 /var/run/docker-mem-watch)。

  • 命令行参数:--containers--threshold--interval--once--loop-h/--help

———

四、它会输出什么(日志格式)

  • 正常记录:

    • 当超过阈值第一次发生:
      ALERT: <name> (<cid12>) memory <pct>% [<used_human>/<limit_human>]

    • 当从告警恢复:
      RECOVER: <name> (<cid12>) memory <pct>% [<used_human>/<limit_human>]

  • 采样异常(例如容器刚好退出):
    WARN: 无法获取 <container> 的 stats

  • 未设上限(只记流水、不做阈值判断):
    INFO: <container> 未设置内存上限...

这些都通过 logger 进入 syslog/journal,你可用:
journalctl -t docker-mem-watch -f 实时查看,或按你的 cron 重定向到 /var/log/docker-mem-watch.log

———

五、核心细节与实现要点

  1. 单位解析 to_bytes

    • 兼容 B、KB/kB、MB/MiB、GB/GiB、TB/TiB,并处理某些 Docker 输出中单位紧贴数字的情况(如 123MiB)。

    • 统一换算为字节,避免地区化小数点、单位差异带来的误差。

  2. 上限优先级

    • docker stats 的“上限”字段在 cgroup 未设置硬限制时可能是宿主机内存;

    • 若 docker inspect 读到 .HostConfig.Memory > 0,则以此为准(这是容器真正的硬上限),并把它格式化成人类可读显示。

  3. 状态机与回滞

    • 每容器维持一个状态文件(OK/ALERT),只在状态变化时告警/恢复;

    • 回滞避免上下抖动导致的频繁通知。

  4. 运行健壮性

    • set -euo pipefail:遇到未定义变量、管道错误或非零退出时直接失败,防止静默问题;

    • 2>/dev/null || true:读取 docker 信息时容忍单点失败,不导致整轮退出;

    • STATE_DIR 自动创建;未知参数时打印 usage() 并退出。

———

六、怎么部署(你关心的“只写日志,不发 webhook”)

  1. 给执行权限(这是你先前 cron 报错的根本原因):


    1. sudo chmod +x /usr/local/bin/docker-mem-watch.sh

  2. 用 cron 单次模式并把输出汇总到独立日志文件:


    加入:


    (不设置 WEBHOOK_URL,脚本就不会发外部请求;日志既有你重定向的 stdout/stderr,也有 syslog 里的 logger 记录。)

    1. * * * * * CONTAINERS="php-fpm" THRESHOLD=90 /usr/local/bin/docker-mem-watch.sh --once >> /var/log/docker-mem-watch.log 2>&1

    2. sudo touch /var/log/docker-mem-watch.log

    3. sudo chmod 644 /var/log/docker-mem-watch.log

    4. sudo crontab -e

———

七、常见边界与排障

  • 没有限制内存的容器:脚本只记录不告警。建议为关键容器设置 --memory,防 OOM 拖垮宿主。

  • 权限问题:cron 默认用 /bin/sh 调用脚本,但脚本有 shebang #!/usr/bin/env bash,前提是必须具备可执行权限;若非 root 执行,还需该用户在 docker 组。

  • 容器瞬时抖动:可适当提高 THRESHOLD 或增大 HYSTERESIS,减少告警噪声。

  • 只想监控特定容器:设置 CONTAINERS="php-fpm",避免监控全部。

  • 想看所有运行容器:留空 CONTAINERS 即可自动枚举。

———

八、你可以怎么改(按你的诉求更“只写日志”)
如果你希望完全不走 syslog、只往文件写,可以在脚本里把 send_alert() 的 logger -t ... 改成 echo,再由 cron 重定向到文件即可。例如:

# 把这行logger -t "$LOG_TAG" "$level: $name ($cid) memory ${pct}% [${usage_human}/${limit_human}]"# 改成echo "$(date '+%F %T') $level: $name ($cid) memory ${pct}% [${usage_human}/${limit_human}]"

这样所有告警行都会走到你 cron 的 >> /var/log/docker-mem-watch.log 文件里,清清楚楚,一眼能看。

———


如夜话,至此。