Docker tini进程管理器

Tini

tini 容器init 是一个最小化的 init 系统,运行在容器内部,用于启动一个子进程,并等待进程退出时清理僵尸和执行信号转发。 这是一个替代庞大复杂的systemd体系的解决方案,已经集成到Docker 1.13中,并包含在Docker CE的所有版本。

Tini的优点:

  • tini可以避免应用程序生成僵尸进程

  • tini可以处理Docker进程中运行的程序的信号,例如,通过Tini, SIGTERM 可以终止进程,不需要你明确安装一个信号处理器

我们为什么要使用Tini,可以参考 What is advantage of Tini? 后续我再整理一下

使用Tini

要激活Tini,在 docker run 命令中传递 --init 参数就可以。

在Docker中,只需要加载Tini并传递运行的程序和参数给Tini就可以:

# Add Tini
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]

# Run your program under Tini
CMD ["/your/program", "-and", "-its", "arguments"]
# or docker run your-image /your/program ...

上述Dockerfile中,通过 ENTRYPOINT 启动 tini 作为进程管理器,然后再通过 tini 运行 CMD 指定的程序命令。

备注

tini release download 提供了不同处理器架构的

如果要使用tini签名,请参考 tini 容器init 发行文档

构建基于Tini的ssh容器

  • 创建一个 Dockerfile 如下

docker_tini/Dockerfile.ssh_exit_0
 1FROM docker.io/centos:7
 2
 3RUN yum clean all && yum -y update && yum install -y net-tools iproute openssh-clients openssh-server which sudo
 4RUN groupadd -g 500 admin && useradd -g 500 -u 500 -d /home/admin -m admin
 5RUN echo 'admin ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
 6
 7# Add Tini
 8ENV TINI_VERSION v0.19.0
 9ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
10RUN chmod +x /tini
11ENTRYPOINT ["/tini", "--"]
12
13RUN ssh-keygen -A
14# Run your program under Tini
15# CMD ["/your/program", "-and", "-its", "arguments"]
16CMD ["/usr/sbin/sshd"]
  • 构建镜像:

    docker build -t local:ssh - < Dockerfile.ssh_exit_0
    
  • 运行容器:

    docker run -itd --hostname myssh --name myssh local:ssh
    

但是,此时检查 docker ps 却看不到 myssh 这个容器。这是为什么呢?

  • 执行检查:

    docker ps --all
    

可以看到原来容器结束了,并且退出返回值是 0 ,这意味着执行成功:

CONTAINER ID   IMAGE               COMMAND                  CREATED         STATUS                     PORTS     NAMES
21fb4926ac47   local:ssh           "/tini -- /usr/sbin/…"   4 minutes ago   Exited (0) 4 minutes ago             myssh

WHY?

原因是docker只检测前台程序是否结束,对于 sshd 这样的后台服务,运行以后返回终端,则docker认为顺利结束了,就停止了容器。解决的方法,一般是运行一个前台程序,例如服务不放到后台运行,或者索性再执行一个 bash ,甚至我们可以编译一个 pause 执行程序(通过c的pause实现) 避免前台程序结束

  • 尝试添加 bash 作为结尾:

    CMD ["/usr/sbin/sshd && /bin/bash"]
    

但是很不幸,执行以后退出返回码是错误的 127

我参考了一下之前的 Docker容器中运行ssh服务 方法修订成:

CMD ["bash -c '/usr/sbin/sshd && /bin/bash'"]

依然错误,比较难处理 ' ' ,所以还是改写成脚本来执行比较方便

  • 创建一个 entrypoint.sh 脚本

docker_tini/entrypoint_ssh_bash
1/usr/sbin/sshd && /bin/bash
  • 修订 Dockerfile 如下,将这个脚本复制到镜像内部并作为entrypoint

docker_tini/Dockerfile.ssh_bash
 1FROM docker.io/centos:7
 2
 3RUN yum clean all && yum -y update && yum install -y net-tools iproute openssh-clients openssh-server which sudo
 4RUN groupadd -g 500 admin && useradd -g 500 -u 500 -d /home/admin -m admin
 5RUN echo 'admin ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
 6
 7# Add Tini
 8ENV TINI_VERSION v0.19.0
 9ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
10RUN chmod +x /tini
11ENTRYPOINT ["/tini", "--"]
12
13COPY entrypoint_ssh_bash /entrypoint.sh
14RUN chmod +x /entrypoint.sh
15
16RUN ssh-keygen -A
17# Run your program under Tini
18# CMD ["/your/program", "-and", "-its", "arguments"]
19CMD ["/entrypoint.sh"]
  • 现在我们重新构建镜像:

    docker rm myssh
    docker rmi local:ssh
    docker build -t local:ssh - < Dockerfile.ssh_bash
    
    docker run -itd --hostname myssh --name myssh local:ssh
    

现在就可以可以正常运行ssh了。

不过,你会觉得,这样有什么优势呢?我们不能直接执行shell脚本么

原因是 tini 提供了很好到进程管理功能,能够转发信号给管理的子进程,这样就方便在 Kubernetes 中调度管理。

需要注意的是,如果在 entrypoint 最后调用了 bash ,则通过 docker attach <contianer> 访问终端时,和 docke run ... /bin/bash 一样,绝对不能执行 ctrl-d 退出,否则会直接结束容器。

上面我也提到了,如果不使用 bash 结束,我们也可以编译一个 pause 程序,请参考 Void (Linux) distribution (一个完全独立的发行版)提供的工具集 void-runit 中的 pauese.c

构建Tini的多服务容器

使用Tini作为容器进程管理器的要点:

  • 使用脚本包装需要启动的服务进程

  • 程序要关闭 daemon 模式,然后使用 & 符号放到后台运行,这样进程管理 Tini 能够感知进程是否正常运行(或死亡)

  • 当任何一个进程退出(SIGLILL,SIGTERM等),Tini会感知到进程退出

  • Tini会搜集子进程的退出状态并执行进程表中必要的清理,避免被杀死的进程进入zombie僵尸

  • 容器的默认配置是Tini在任何子进程终止时自动退出,由于Tini是PID 1,这会导致整个容器停止

以下是 Distrobox运行Alpine Linux 的 Dockerfile,其中包含Tini包装脚本 /entrypoint.sh 案例:

同时运行crond和ssh的容器(也可以启动更多程序,如nginx),改进包装脚本
FROM alpine:latest

ENV container=docker

ARG USER=admin
ARG GROUP=admin
ARG UID=1000
ARG GID=1000

RUN cat << 'EOF' > /README

# How

use docker or podman to build and run container, command syntax is same:

- Host's admin HOME will bind to HOME in container, use Host's environment to set same uid/gid

  - if not use HOME in Host, can use other secret directory to store admin's public key and bind to container's /home/admin/.ssh directory

- ssh port change to 1122 because poeman rootless container cannot use port below 1024

# BUILD

podman build --build-arg ADMIN_UID=$(id -u) --build-arg ADMIN_GID=$(id -g) \
        -t alpine-dev:latest .

# RUN

export uid=$(id -u)
export gid=$(id -g)

podman run -dt --name alpine-dev --hostname alpine-dev \
     -p 1122:1122 \
     -v /home/admin:/home/admin \
     --user $uid:$gid \
     --userns keep-id:uid=$uid,gid=$gid \
     alpine-dev:latest
EOF

# tini entrypoint script: "here documents" / "here scripts"
# Enable BuildKit: Ensure your Docker daemon is configured to use BuildKit, which is typically enabled by default in recent Docker versions. If not, you might need to set the DOCKER_BUILDKIT=1 environment variable when running docker build.
RUN cat << 'EOF' > /entrypoint.sh
#!/usr/bin/env ash
set -e

# Function to gracefully shut down all background processes
function shutdown() {
    echo "Shutting down services..."
    kill ${CRON_PID} ${NGINX_PID} ${SSHD_PID}
    wait ${CRON_PID} ${NGINX_PID} ${SSHD_PID}
    echo "Services stopped."
}

# Trap termination signals to call the shutdown function
trap shutdown SIGTERM SIGINT

# --- Start the services in the background ---

# Start syslog (failed bind, now can't use)
sudo syslogd -n &
SYSLOGD_PID=$!
echo "SYSLOGD started with PID $SYSLOGD_PID"

# Start Crond 
# Use the -f flag to run in the foreground (daemon off mode)
sudo /usr/sbin/crond -f &
CRON_PID=$!
echo "Cron started with PID $CRON_PID"

# Start Nginx
# Use the -g "daemon off;" flag to run in the foreground
# nginx -g "daemon off;" &
# NGINX_PID=$!
# echo "Nginx started with PID $NGINX_PID"

# Start SSHD
# Use the -D flag to prevent forking into the background
# only sshd daemon (without flags) logging through syslogd, so set "-E log_file"
sudo /usr/sbin/sshd -D -E /var/log/sshd.log &
SSHD_PID=$!
echo "SSHD started with PID $SSHD_PID"

# Wait for any process to exit
# If any service fails, 'wait -n' returns its exit status, and 'set -e'
# ensures the script exits, which tells Tini to stop.
wait -n

# If we reach here, one process has exited.
# Call shutdown to terminate the others cleanly before exiting the script.
shutdown
EOF

RUN chmod +x /entrypoint.sh

RUN apk update && apk upgrade

# tini process manager
RUN apk add --no-cache tini

# Devops utilities
RUN apk add --no-cache openssh openssl sudo bind-tools tmux git neovim

# add account "admin" and give sudo privilege
# "admin" uid/gid same as host (alpine linux)
RUN addgroup -g ${GID} ${USER}
RUN adduser -u ${UID} -G ${GROUP} -h /home/${USER} -s /bin/sh -D ${USER}
RUN echo "%${GROUP}        ALL=(ALL)       NOPASSWD: ALL" >> /etc/sudoers
# account without password will be locked, so set admin's password randomly, only login through ssh
RUN RANDOM_PASSWORD=$(openssl rand -base64 12 | head -c 16) && echo "${USER}:$RANDOM_PASSWORD" | chpasswd

# set TIMEZONE to Shanghai
RUN apk add --no-cache tzdata
RUN ln -s /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

# init sshd
RUN ssh-keygen -A
# vscode remote ssh need 'AllowTcpForwarding'
RUN sed -i 's/AllowTcpForwarding no/AllowTcpForwarding yes/g' /etc/ssh/sshd_config
# speed connect
RUN echo "UseDNS no" >> /etc/ssh/sshd_config
# alpine linux sshd default use password auth, empty password will lock account, change to disable password auth
RUN echo "PasswordAuthentication no" >> /etc/ssh/sshd_config
# podman rootless container can't use service port below 1024
RUN echo "Port 1122" >> /etc/ssh/sshd_config
# enable sshd logging
RUN echo "SyslogFacility AUTH" >> /etc/ssh/sshd_config
RUN echo "LogLevel INFO" >> /etc/ssh/sshd_config

# config syslogd, split auth log
RUN echo "auth,authpriv.* /var/log/auth.log" >> /etc/syslog.conf

# developement
RUN apk add build-base
RUN apk add gdb strace
RUN apk add go
RUN apk add rust
# nodejs choice LTS version
RUN apk add nodejs-lts
# I use graphviz for sphinx docs, you may not need
RUN apk add graphviz

# Python virtualenv
USER ${USER}
# machine learning environment
RUN sh -c "cd /home/${USER} && python3 -m venv venv/ml"
# Install NumPy, Matplotlib (Deep Learning from Scratch)
RUN sh -c "source /home/${USER}/venv/ml/bin/activate && pip install --upgrade pip && pip install numpy matplotlib"

# TensorFlow wheels built for glibc-based distros, so cannot install direct. Need compile from source. I will do it later...
#RUN sh -c "source /home/${USER}/venv/ml/bin/activate && pip install --upgrade pip && pip install numpy scipy matplotlib scikit-learn tensorflow-cpu==2.20.0 pillow"

# Sphinx doc environment
RUN sh -c "cd /home/${USER} && python3 -m venv venv/sphinx"
RUN sh -c "source /home/${USER}/venv/sphinx/bin/activate && pip install sphinx sphinx_rtd_theme sphinxnotes-strike sphinxcontrib-video sphinxcontrib-youtube myst-parser jieba"

# user profile
RUN echo "alias vi=nvim" >> /home/${USER}/.profile

# run entrypoint.sh needs root
USER root

# run services when container started
# NOW cannot use: EXPOSE 22:1122
# define multiple ports: EXPOSE 22 80 443 8080 9000
# define port range: EXPOSE 5000-5010 
# define port range with udp protocol: EXPOSE 20000-20100/udp 

# to development environment, not define any ports for freely use

# Use Tini as the ENTRYPOINT and pass the wrapper script as its argument.
# The `--` separates Tini arguments from the command to be executed.
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]

# The CMD is empty because the entrypoint handles everything.
CMD []

上述Dockerfile将原本单独的 /entrypoint.sh 脚本合并到Dockerfile中,采用 here document 方式生成,不仅容易分发Dockerfile,也非常容易修订脚本。

构建Tini的多服务容器(旧方法,归档)

上面我们已经实现了一个在tini下启动sshd的方法,那么我们现在来构建多个服务

  • 构建一个多服务启动的脚本,这里我们启动案例是 sshcron

entrypoint_ssh_cron_bash 脚本
#!/usr/bin/env bash

sshd() {
    /usr/bin/ssh-keygen -A
    /usr/sbin/sshd
}

crond() {
    /usr/sbin/crond
}

main() {
    sshd
    crond
    # 这里最后执行/bin/bash在docker中没有问题,但是K8s检测程序运行结束会判断pod终止crash,所以无法running
    /bin/bash
}

main

警告

这里的 entrypoint_ssh_cron_bash 脚本实际上有一个缺陷,只能在Docker中正常工作,应用到Kubernetes上会出现pod不断Crash。原因在 kind部署 fedora-dev-tini (tini替代systmed) 有详细分析以及对应的改进

  • 修订 Dockerfile 如下,将这个脚本复制到镜像内部并作为entrypoint

将 entrypoint_ssh_cron_bash 脚本复制到容器内部作为 tini 调用的 /entrypoint.sh 脚本来启动多个服务
FROM docker.io/centos:7

RUN yum clean all && yum -y update && yum install -y net-tools iproute openssh-clients openssh-server crontabs which sudo
RUN groupadd -g 500 admin && useradd -g 500 -u 500 -d /home/admin -m admin
RUN echo 'admin ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers

# Add Tini
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]

COPY entrypoint_ssh_cron_bash /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Add ssh public key for login
RUN mkdir -p /home/admin/.ssh
COPY admin.authorized_keys /home/admin/.ssh/authorized_keys
RUN chown -R admin:admin /home/admin/.ssh
RUN chmod 600 /home/admin/.ssh/authorized_keys
RUN chmod 700 /home/admin/.ssh

RUN ssh-keygen -A
# Run your program under Tini
# CMD ["/your/program", "-and", "-its", "arguments"]
CMD ["/entrypoint.sh"]