Distrobox环境容器中ssh服务

我在使用 distrobox 中发现默认的网络环境,容器和Host主机是使用 同一个网络堆栈

容器内部无法启动 sshd 服务

Distrobox运行Alpine Linux 实践中,我尝试:

配置 --init-hooks "sudo rc-service sshd start" 尝试容器启动时启动ssh服务
distrobox create --name alpine-dev --image alpine-dev:latest --init-hooks "sudo rc-service sshd start" --additional-flags "-p 1122:22"

结果启动失败,检查 podman logs alpine-dev 显示ssh服务早已启动,重复执行启动ssh导致报错:

由于容器内ssh服务已启动,再执行 --init-hooks "sudo rc-service sshd start" 报错
...
+ sudo rc-service sshd start
 * WARNING: sshd is already starting
+ '[' 1 -ne 0 ]
+ printf 'Error: An error occurred\n'
Error: An error occurred

但是实际上,我去除掉 --init-hooks "sudo rc-service ssh start 进入到容器内部检查,发现 sshd 服务并没有启动

检查 ssh 进程:

检查 ssh 进程
air13:/etc/ssh# ps aux | grep ssh
root      9296  0.0  0.0   1624   924 pts/1    S+   10:17   0:00 grep ssh
admin    15128  0.0  0.0   7896  5024 ?        S    Nov03   0:00 ssh: /home/admin/.ssh/192.168.1.20-22-admin [mux]
admin    21636  0.0  0.0   7596  5480 ?        S+   Nov03   0:00 ssh acloud-dev
admin    21641  0.0  0.0   7936  5036 ?        S    Nov03   0:08 ssh: /home/admin/.ssh/192.168.1.6-1122-admin [mux]

等等! 为何看到的进程和Host主机是一样的,我并没有在容器内部执行 ssh acloud-dev 等命令,为何会在容器内部看到Host物理主机上的 ssh 命令进程?

我真的已经进入容器内部了吗? ( distrobox enter alpine-dev )

df -h 显示确实已经进入了容器,根文件系统挂在是 overlay :

文件系统挂载是 overlay 模式
📦[admin@alpine-dev .ssh]$ df -h
Filesystem      Size  Used Avail Use% Mounted on
tmpfs           3.9G   27M  3.9G   1% /tmp
devtmpfs         10M     0   10M   0% /dev
shm             3.9G   14M  3.9G   1% /dev/shm
/dev/sda3       107G   13G   89G  13% /run/host
overlay         107G   13G   89G  13% /home/admin/.local/share/containers/storage/overlay/964da9628fd23b5b826733e8889fd4b0fd4bea87e3ec4e0016aade014f10b5b1/merged
tmpfs           1.6G  520K  1.6G   1% /run/host/run
tmpfs           789M  176K  789M   1% /run/user/1000
/dev/sda1       511M  288K  511M   1% /run/host/boot/efi
overlay         107G   13G   89G  13% /

📦[admin@alpine-dev .ssh]$ sudo su -

air13:~# df -h
Filesystem      Size  Used Avail Use% Mounted on
tmpfs           3.9G   27M  3.9G   1% /tmp
devtmpfs         10M     0   10M   0% /dev
shm             3.9G   14M  3.9G   1% /dev/shm
/dev/sda3       107G   13G   89G  13% /run/host
overlay         107G   13G   89G  13% /home/admin/.local/share/containers/storage/overlay/419fddbeedb5311d07420e40f50e8eb126c1eaa8d1d51f9cdcc56985ddb984ab/merged
overlay         107G   13G   89G  13% /home/admin/.local/share/containers/storage/overlay/964da9628fd23b5b826733e8889fd4b0fd4bea87e3ec4e0016aade014f10b5b1/merged
overlay         107G   13G   89G  13% /
tmpfs           1.6G  520K  1.6G   1% /run/host/run
tmpfs           789M  176K  789M   1% /run/user/1000
/dev/sda1       511M  288K  511M   1% /run/host/boot/efi

但是,启动和停止sshd服务都诡异地报告已经有其他启动和停止了

探索

我尝试前台启动 sshd 服务(这里我为了避免和Host主机ssh服务端口22冲突,修订了容器的 /etc/ssh/sshd_config 绑定 23 端口):

前台启动 sshd 服务
sudo /usr/sbin/sshd -D -e

出乎意料的时,容器内的root用户也没有权限绑定端口 23 :

前台启动 sshd 服务显示没有权限监听23端口
Bind to port 23 on 0.0.0.0 failed: Permission denied.
Bind to port 23 on :: failed: Permission denied.
Cannot bind any address.

我使用 python3http.server 模块来测试容器能够绑定的端口,确认容器内部低于 1024 端口都无法监听:

检查发现容器内部无法绑定 1024 以下端口
# 可以bind 1024端口
📦[admin@alpine-dev .ssh]$ sudo python3 -m http.server -b 0.0.0.0 1024
Serving HTTP on 0.0.0.0 port 1024 (http://0.0.0.0:1024/) ...

# 但是低于1024端口无法bind
📦[admin@alpine-dev .ssh]$ sudo python3 -m http.server -b 0.0.0.0 1023
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "/usr/lib/python3.12/http/server.py", line 1314, in <module>
    test(
  File "/usr/lib/python3.12/http/server.py", line 1261, in test
    with ServerClass(addr, HandlerClass) as httpd:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/socketserver.py", line 457, in __init__
    self.server_bind()
  File "/usr/lib/python3.12/http/server.py", line 1308, in server_bind
    return super().server_bind()
           ^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/http/server.py", line 136, in server_bind
    socketserver.TCPServer.server_bind(self)
  File "/usr/lib/python3.12/socketserver.py", line 478, in server_bind
    self.socket.bind(self.server_address)
PermissionError: [Errno 13] Permission denied

解决方案(思路)

想到在部署 Alpine Linux运行Podman 时,选择采用 rootless 模式运行,是不是这个原因导致呢?

确实如此...google提示: rootless 容器中 root 用户实际上没有真正Host系统的root权限,所以无法监听(bind)低于 1024 端口。解决的方法主要有两个:

  • 方法一 ( 推荐 ):在 rootless 容器中,将应用服务监听端口调整到 1024 及以上端口,然后在运行容器的 --publish <HOST端口>:<Container端口> 时映射出容器,同时在前面采用类似 Nginx反向代理 对外提供 80/443 这样的公共服务端口,反向代理到容器映射出的 1024 及以上端口

  • 方法二 ( 不推荐 ): 调整Host主机 net.ipv4.ip_unprivileged_port_start `` 内核参数,将非私有端口范围降低到需要bind的端口号以下,就能够允许 ``rootless 容器绑定到原先无法绑定的私有端口(即设置为 net.ipv4.ip_unprivileged_port_start = 80 ,则允许所有 80 及以上端口非root可以bind,但是不包括低于 80 端口,如 22 端口不包括)

设置 net.ipv4.ip_unprivileged_port_start1022 (降低2个端口号)
# 设置1022及以上为非私有端口
echo 'net.ipv4.ip_unprivileged_port_start = 1022' >> /etc/sysctl.conf
# 生效
sysctl -p
  • 根据上述解决方法,我采用方法一,即调整容器内部服务端口,采用 1024及以上 非私有端口,所以 Distrobox运行Alpine LinuxDockerfile 镜像我也相应做了调整

  • 另外在容器内部,虽然能够通过 --init-hooks 去执行 /usr/sbin/sshd -D & 命令,但是进行一旦挂掉,容器内部没有进程管理器是无法自动重启 sshd 服务的,所以我最后还是决定通过 tini 进程管理器来维护 sshd 服务

调整容器内服务端口( 1024以上 )

由于我考虑 Docker tini进程管理器 管理容器内多进程可能更为优雅,所以我尝试采用 here document 改进过的 Dockerfile :

采用 1122 端口来运行容器内 ssh 服务
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 []
构建镜像
podman build -t alpine-dev .
  • 创建通过 Dockerfile 构建镜像的容器:

去除 init-hooks 参数,尝试通过 tini 启动容器中多个服务方式来运行 distrobox
distrobox create --name alpine-dev --image alpine-dev:latest --additional-flags "-p 1122:1122"
  • 创建成功后,执行以下命令来进入容器(运行容器):

进入容器
distrobox enter alpine-dev

但是发现容器内没有 sshd 进程,这是为什么?

distrobox 覆盖了 DockerfileENTRYPOINTCMD

原来 distrobox enter 不会执行 Dockerfile 中的 ENTRYPOINT ,因为 distrobox 的主要功能是提供一个交互的shell环境,通过 distrobox 内置的脚本和用户shell 覆盖镜像内的默认entrypoint和command 来实现:

  • 轻量级容器环境: Distrobox 被设计成一个轻量级集成容器环境,就像一个常规的系统shell一样运行,而不是一个标准的面向应用的容器

  • 默认行为是shell: distrobox enter 默认命令进入容器时实际上是使用当前用户的shell( bashzsh )

  • Distrobox 使用自己的 entrypoint : 当执行 distrobox create 创建distrobox容器时,一个名为 distrobox-init 的脚本被设置为容器的实际 entrypoint 。这个脚本处理所有必要的设置,例如安装缺失的依赖,设置用户uid/gid,并挂载Host主机的目录。当完成设置以后,就会加载用户shell或指定命令,完全 bypass 掉原生镜像中的 ENTRYPOINTCMD

distrobox 初始化设置中执行定制脚本

distrobox create 阶段提供了 --init-hooks--pre-init-hooks 来运行容器设置完成但还没有加载最终shell时可以运行的脚本。也就是说,可以在 --init-hooks 中指定运行 sshd :

distrobox 中使用 --init-hooks 运行 sshd 服务
distrobox create --name alpine-dev --image alpine-dev:latest --init-hooks "sudo /usr/sbin/sshd" --additional-flags "-p 1122:1122"

通过上述方式构建的 distrobox 容器,使用 distrobox enter alpine-dev 检查,就可以看到容器内部运行了 sshd 服务,也就能够在Host主机通过 ssh localhost -p 1122 登录

更好的方案

实际上 Distrobox 不适合作为标准容器来运行远程服务,而是作为桌面系统的补充,无缝运行一些需要在轻量级隔离环境中安装的桌面应用,特别是类似 Alpine Linux musl 库无法正常运行的依赖 glibc 的应用。

对于上述需要运行 sshd 服务,甚至多个应用服务的远程开发测试环境,实际上适合采用标准的 podmanDockerrun ,这样就能够充分利用 Docker tini进程管理器 进程管理器来运行和监视多个服务进程,并在服务进程退出时及时终止容器,符合标准 Container 运行或 Kubernetes 运行:

通过标准 podman 运行镜像来实现 tini 启动多个服务
podman run -dt --name alpine-dev --hostname alpine-dev \
    -p 1122:1122 \
    -v /home/admin:/home/admin \
    alpine-dev:latest