alpine-dev Podman image
备注
在 Alpine Podman image 基础上结合轻量级开发环境设置
构建
# Use Edge Alpine for dev env
FROM alpine:edge
# prepare proxy for GFW, ss run on Host
# ENV for "podman build & run"
# if only for "podman build", change ENV to ARG
#ARG http_proxy=socks5://127.0.0.1:1082
#ARG https_proxy=socks5://127.0.0.1:1082
#ARG HTTP_PROXY=socks5://127.0.0.1:1082
#ARG HTTPS_PROXY=socks5://127.0.0.1:1082
#!!! Don't use ARG or ENV to set proxy globally, in China GFW break github.com and golang.org, so only set proxy for these site, others are direct access
# Go support GOPROXY, In China, please use "https://goproxy.cn" ,else use "https://proxy.golang.org"
ARG GOPROXY=https://goproxy.cn,direct
# if podman run use "--net=host" then proxy point to 127.0.0.1 (same as host), else
# proxy point to host.containers.internal, but need change sing-box(ss client) listen to 0.0.0.0, and DONT forget config iptables/nftables
# HERE setup proxyon/proxyoff alias in container, user can control proxy switch easyly.
RUN echo 'alias proxyon="export http_proxy=socks5://127.0.0.1:1082 https_proxy=socks5://127.0.0.1:1082 HTTP_PROXY=socks5://127.0.0.1:1082 HTTPS_PROXY=socks5://127.0.0.1:1082 GOPROXY=https://goproxy.cn,direct"' > /etc/profile.d/proxy.sh && \
echo 'alias proxyoff="unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY GOPROXY"' >> /etc/profile.d/proxy.sh && \
chmod +x /etc/profile.d/proxy.sh
RUN apk update && apk upgrade
# Devops utilities
RUN apk add --no-cache openssh openssl bind-tools tmux git neovim
# install system core tools
RUN apk add --no-cache \
tini \
su-exec \
bash \
git \
curl \
doas \
ripgrep \
fd \
fzf \
ca-certificates
# base compile suit(GCC/Make/Headers), LSP and code format provide by Clang
RUN apk add --no-cache \
build-base \
musl-dev \
gdb \
cmake \
linux-headers \
clang \
clang-extra-tools
# install language develop env
# Go, Python, Ruby, Rust
# Not install nodesjs, because my Macbook Air 11" Late 2010 too old to run
# If need, you can "apk add nodejs npm"
# NOTE: "py3-pysocks" for pip support socks proxy, else will fail if use "ENV http_proxy=socks5://127.0.0.1:1082"
RUN apk add --no-cache \
go \
python3 \
py3-pip \
py3-pysocks \
ruby \
ruby-dev \
rust \
cargo
# Lightweight LSP and Code Analyszer (without Node.js heavy dependence)
# - C/C++: clangd (include in clang-extra-tools)
# - Rust: rust-analyzer
RUN apk add --no-cache rust-analyzer
# Entrypoint
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# create admin (UID/GID 1000)
ARG USER=admin
ARG GROUP=admin
ARG UID=1000
ARG GID=1000
RUN addgroup -g ${GID} ${GROUP} && \
adduser -u ${UID} -G ${GROUP} -s /bin/bash -D ${USER} && \
echo "permit nopass :${GROUP}" >> /etc/doas.conf && \
chmod 0400 /etc/doas.conf && \
chown root:root /etc/doas.conf
# WorkSpace
WORKDIR /workspace
RUN chown ${USER}:${GROUP} /workspace
USER admin
# develop enviroment
# Go PATH
ENV HOME=/home/${USER}
ENV GOPATH=$HOME/go
ENV GEM_HOME=$HOME/.gem/ruby/3.4.0
ENV PATH=$GOPATH/bin:$GEM_HOME/bin:$HOME/.cargo/bin:$HOME/venv/dev/bin:/usr/local/go/bin:$PATH
# - Go: gopls
RUN go install golang.org/x/tools/gopls@latest
# python program: virtualenv
RUN bash -c 'mkdir -p $HOME/venv && cd $HOME/venv && python3 -m venv dev'
# - python-frontmatter: Python batch handle YAML frontmatter properties of Astro Markdown
RUN bash -c 'source $HOME/venv/dev/bin/activate && pip install python-frontmatter'
# - Python: ruff + jedi-language-server
RUN bash -c 'source $HOME/venv/dev/bin/activate && pip install jedi-language-server ruff'
# sphinx doc for My "Cloud-Atlas"
RUN bash -c 'source $HOME/venv/dev/bin/activate && pip install sphinx sphinx_rtd_theme sphinxnotes-strike sphinxcontrib-video sphinxcontrib-youtube myst-parser jieba'
# - Ruby: solargraph
# 设置了GEM_PATH之后,不需要 --user-install 参数
RUN gem install --no-document solargraph
# - asciidoctor: AsciiDoc / Antora Offline Analyzer
RUN gem install --no-document asciidoctor asciidoctor-diagram
# - Markdown / Astro MDX: marksman (Markdown LSP write by Rust)
RUN mkdir -p $HOME/.cargo/bin && \
http_proxy=socks5://127.0.0.1:1082 https_proxy=socks5://127.0.0.1:1082 \
curl -fsSL https://github.com/artempyanykh/marksman/releases/latest/download/marksman-linux-x64 -o $HOME/.cargo/bin/marksman && \
chmod +x $HOME/.cargo/bin/marksman
# -----------------------------------------------------------------------------
# 配置 Neovim 开发环境 (单文件 init.lua + 插件离线预热)
# -----------------------------------------------------------------------------
USER admin
WORKDIR /home/${USER}
# 1. 复制独立的 init.lua 到 admin 用户的配置目录
# (--chown 确保复制进来的文件属主直接就是 admin:admin)
RUN mkdir -p /home/admin/.config/nvim
COPY --chown=admin:admin config/nvim/init.lua /home/admin/.config/nvim/init.lua
# 2. 在构建阶段提前下载并编译所有 Neovim 插件 (实现离线开箱即用)
RUN nvim --headless "+Lazy! sync" +qa
# 3. 恢复默认开发工作目录(关键步骤)
WORKDIR /workspace
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
CMD ["/bin/bash"]
其中 entrypoint.sh 脚本如下:
#!/bin/bash
# get HOST UID/GID dynamic (inject by env, default is 1000)
USER_ID=${NEW_UID:-1000}
GROUP_ID=${NEW_GID:-1000}
# if UID changed, fix admin's ID in container
if [ "$USER_ID" != "1000" ]; then
echo "Updating admin UID to $USER_ID..."
doas sed -i "s/^admin:x:1000:1000:/admin:x:$USER_ID:$GROUP_ID:/" /etc/passwd
doas sed -i "s/^admin:x:1000:/admin:x:$GROUP_ID:/" /etc/group
fi
# WorkSpace owner right( podman can do it, so remove)
#CURRENT_OWNER=$(stat -c '%u' /workspace)
#if [ "$CURRENT_OWNER" != "$USER_ID" ]; then
# echo "Adjusting permissions for /workspace..."
# chown -R admin:admin /workspace
#fi
# switch to admin, then run commands
# using exec to confirm signal has been forward to sub process
# exec su-exec admin "$@" REPORT ERROR => su-exec: setgroups: Operation not permitted
exec doas -u admin "$@"
备注
在上述Dockerfile中引入了 NeoVim轻量级IDE 配置 init.lua 方便一次性构建开发环境。
构建镜像:
podman build --rm -t alpine-dev .
警告
实际上为了解决GFW,需要设置代理才能pull镜像,但是为了避免代理环境变量污染容器镜像构建,在build时还需要 显式 清理调代理环境变量,所以实际build命令如下:
export http_proxy=socks5://127.0.0.1:1082 export https_proxy=socks5://127.0.0.1:1082 export HTTP_PROXY=socks5://127.0.0.1:1082 export HTTPS_PROXY=socks5://127.0.0.1:1082
podman build \
--build-arg http_proxy="" \
--build-arg https_proxy="" \
--build-arg HTTP_PROXY="" \
--build-arg HTTPS_PROXY="" \
--rm -t alpine-dev .
然后再运行:
podman run -d \
--name alpine-dev \
--hostname alpine-dev \
--net=host \
--user 1000:1000 \
--userns keep-id \
-e LANG=C.UTF-8 \
--workdir /workspace \
-v /home/admin/docs:/workspace \
-v /home/admin/.ssh:/home/admin/.ssh:ro \
--cap-add SYS_PTRACE \
--security-opt seccomp=unconfined \
localhost/alpine-dev \
sh -c "sleep infinity"
异常排查
整个实践过程其实非常波折,最大的困难就是 越过长城 ,其次是存储问题
代理影响
安装过程
pip报错:
pip install 安装模块报错WARNING: Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLEOFError(8, '[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol (_ssl.c:1082)'))': /simple/jedi-language-server/
...
Could not fetch URL https://pypi.org/simple/jedi-language-server/: There was a problem confirming the ssl certificate: SOCKSHTTPSConnectionPool(host='pypi.org', port=443): Max retries exceeded with url: /simple/jedi-language-server/ (Caused by SSLError(SSLEOFError(8, '[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol (_ssl.c:1082)'))) - skipping
ERROR: Could not find a version that satisfies the requirement jedi-language-server (from versions: none)
ERROR: No matching distribution found for jedi-language-server
实际上我为了能够避免代理全局影响,特意取消了Dockerfile开头的 ENV http_proxy=socks5://127.0.0.1:1082 改为在执行 curl 命令前设置环境变量:
RUN apk add --no-cache rust-analyzer && \
http_proxy=socks5://127.0.0.1:1082 https_proxy=socks5://127.0.0.1:1082 \
curl -fsSL https://github.com/artempyanykh/marksman/releases/latest/download/marksman-linux-x64 -o /usr/local/bin/marksman && \
chmod +x /usr/local/bin/marksman && \
go install golang.org/x/tools/gopls@latest && \
pip install --no-cache-dir --break-system-packages \
jedi-language-server \
ruff && \
gem install --no-document solargraph
这里可能存在一些混乱,我采用 && 连接多条命令,但是属于一条Dockerfile RUN 命令,其实就相当于一个shell会话,那么在 curl 命令前设置的这个环境变量一直保留着,影响到后面的 pip install 命令。
解决的方法有两种:
一种是拆分RUN命令,因为对Dockerfile来说每个RUN命令就是一次全新的shell环境:
RUN http_proxy=socks5://127.0.0.1:1082 https_proxy=socks5://127.0.0.1:1082 \
curl -fsSL https://github.com/artempyanykh/marksman/releases/latest/download/marksman-linux-x64 -o /usr/local/bin/marksman && \
chmod +x /usr/local/bin/marksman
RUN apk add --no-cache rust-analyzer && \
go install golang.org/x/tools/gopls@latest && \
pip install --no-cache-dir --break-system-packages \
jedi-language-server \
ruff && \
gem install --no-document solargraph
另一种方法是将 curl 代理限制在 子Shell 中,也就是添加一个 () :
()RUN apk add --no-cache rust-analyzer && \
(export http_proxy=socks5://127.0.0.1:1082 https_proxy=socks5://127.0.0.1:1082; \
curl -fsSL https://github.com/artempyanykh/marksman/releases/latest/download/marksman-linux-x64 -o /usr/local/bin/marksman) && \
chmod +x /usr/local/bin/marksman && \
go install golang.org/x/tools/gopls@latest && \
pip install --no-cache-dir --break-system-packages \
jedi-language-server \
ruff && \
gem install --no-document solargraph
但是万万没有想到还是没有解决(已经尝试将pip命令放到设置proxy执行curl命令行之前,确保proxy设置不影响pip),看起来确实访问 pypi.org 存在干扰?
(后备方案)显式使用国内镜像源来避开 pipy 官方源TLS握手不稳定的问题:
pip install --no-cache-dir --break-system-packages \
-i https://pypi.tuna.tsinghua.edu.cn/simple \
jedi-language-server \
找到原因了
原来 Podman自动继承宿主机代理 :
和 配置Docker使用代理 不同,Podman在设计上为了方便用户在代理环境下构建,默认会自动将宿主Shell中的 http_proxy , https_proxy , HTTP_PROXY , HTTPS_PROXY 以及 no_proxy 作为默认的 --build-arg 传递给Dockerfile中每个 RUN 命令。
也就是说,即使 Dockerfile 中没有写 ARG http_proxy ,Podman在后台也会默默把它传进去!!!
这就是为什么我以为Dockerfile没有代理,但是容器 RUN pip install 依然从环境变量中读到了 socks5://127.0.0.1:1082 从而触发了 SSLError(SSLEOFError) 报错。
我之所以在 podman build 之前就在shell中使用了代理设置,是因为 dockerhub ( hub.docker.com )被GFW屏蔽了,要下载 Alpine Linux 的初始镜像必须开启代理。
这里的矛盾就是一旦在执行 podman build 时激活了代理环境变量,该环境变量就会被注入Dockerfile的整个构建过程,即使Dockerfile中没有配置代理也如此。
解决的方法是在podman build时显式传入 --build-arg http_proxy="" ... 在命令中将代理参数显式清空
podman build 时显式清理调为pull镜像添加的代理环境变量podman build \
--build-arg http_proxy="" \
--build-arg https_proxy="" \
--build-arg HTTP_PROXY="" \
--build-arg HTTPS_PROXY="" \
-rm -t alpine-dev .
备注
原来Podman会默认将proxy环境变量注入容器构建,这个发现确实解释了为何我在build时候感觉明显的缓慢,原来每个 apk add 命令都是经过代理,导致网络速度降低。
修改文件id的问题
执行运行报错
参数不合适podman run -d \
--name alpine-dev \
--hostname alpine-dev \
--net=host \
--userns keep-id \
-e LANG=C.UTF-8 \
--workdir /workspace \
-v /home/admin/docs:/workspace:z \
-v admin_home_data:/home/admin \
-v /home/admin/.ssh:/home/admin/.ssh \
--cap-add SYS_PTRACE \
--security-opt seccomp=unconfined \
localhost/alpine-dev \
sh -c "sleep infinity"
报错:
WARN[0000] Failed, retrying in 1s ... (1/3). Error: initializing source docker://localhost/alpine-dev_dev-env:latest: pinging container registry localhost: Get "https://localhost/v2/": dial tcp [::1]:443: connect: connection refused
这是因为我写错了镜像名字 localhost/alpine-dev_dev-env ,导致触发了Podman自动拉取机制尝试访问网络上的镜像仓库。由于使用了 localhost 导致podman以为本地有一个registry进行拉取。
storage-chown-by-maps 异常
podman run长时间没有反应,检查top发现有一个storage-chown-by-maps在疯狂读写,下面是在 Alpine Linux 上top显示输出的负载最高的3个进程:
storage-chown-by-maps 在修改目录文件Mem: 3033104K used, 717280K free, 6732K shrd, 222644K buff, 2321480K cached
CPU: 1% usr 6% sys 0% nic 24% idle 66% io 0% irq 0% sirq
Load average: 4.45 1.79 0.68 2/158 16037
PID PPID USER STAT VSZ %VSZ CPU %CPU COMMAND
15997 15988 admin D 1345m 36% 0 5% {exe} storage-chown-by-maps /home/admin/.local/share/containers/storage/overlay/09649cceacfc0cb7f0f989c3586ad93377b78c1b7e78691d73c081d3a6c777d8/mer
1625 2 root DW 0 0% 1 2% [jbd2/sda5-8]
15988 15987 admin S 1282m 34% 0 0% podman run -d --name alpine-dev --hostname alpine-dev --net=host --userns keep-id -e LANG=C.UTF-8 --workdir /workspace -v /home/admin/docs:/workspac
207 2 root IW< 0 0% 0 0% [kworker/0:1H-kb]
这个问题困扰了我很久,gemini提示是因为 Dockerfile 中切换用户账号加上采用podman rootless导致的,也就是说,在Dockerfile中
但是,我按照提示将所有root身份的Dockerfile部分放在前面,只在Dockerfile最后部分采用了 USER admin 身份执行一些HOME目录下的 pip install 等操作,并且最后申明 USER admin :
...
# <== 前面部分都是root身份
# WorkSpace
WORKDIR /workspace
RUN chown ${USER}:${GROUP} /workspace
# <== 这里开始切换到admin
USER admin
# develop enviroment
# Go PATH
ENV HOME=/home/${USER}
ENV GOPATH=$HOME/go
...
# - Go: gopls
RUN go install golang.org/x/tools/gopls@latest
...
# sphinx doc for My "Cloud-Atlas"
RUN bash -c 'source $HOME/venv/dev/bin/activate && pip install sphinx sphinx_rtd_theme sphinxnotes-strike sphinxcontrib-video sphinxcontrib-youtube myst-parser jieba'
...
# 确保最后层是admin,避免启动时storage-chown-by-maps
USER admin
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
CMD ["/bin/bash"]
但是,一番折腾下来,我发现 podman run 依然通常出现了一个 storage-chown-by-maps 进程在疯狂读写磁盘:
storage-chown-by-maps 进程~ $ ps aux | grep storage-chown-by-maps
10772 admin 0:12 {exe} storage-chown-by-maps /home/admin/.local/share/containers/storage/overlay/7ec4a14163a9868abb3831be82122c0af571aa7bfe53a5f38cb1512d0c98df6b/merged
这个问题在于我的运行命令:
:z 参数触发chownpodman run -d \
--name alpine-dev \
--hostname alpine-dev \
--net=host \
--userns keep-id \
-e LANG=C.UTF-8 \
--workdir /workspace \
-v /home/admin/docs:/workspace:z \
-v admin_home_data:/home/admin \
-v /home/admin/.ssh:/home/admin/.ssh \
--cap-add SYS_PTRACE \
--security-opt seccomp=unconfined \
localhost/alpine-dev \
sh -c "sleep infinity"
对于podman,当使用了 --userns keep-id ,Podman在启动阶段会检查和同步容器内部曾与宿主机的存储映射(Storage SubUID Mapping),在老旧机器特别是老款SATA SSD上,磁盘随机IO会被堵死。
以前我使用这个运行参数没有发现问题是因为当时使用的是非常高性能的 NVMe存储 存储,NVMe特有的随机IO高性能并发处理比传统SATA SSD 快100倍 ,所以很快完成没有发现这个异常。这次在Host主机的 /home/admin/docs 目录下有一个2GB的海量小文件的目录,就导致我的孱弱的 MacBook Air 11" Late 2010 暴露出这个问题。
解决方法修改运行参数,将 --userns keep-id 修改成 --userns=host 不行
两种解决方案对比
一种绕过的方法是使用 --user=admin 替代 --user 1000:1000 --userns keep-id :
--user=admin 但不设置uid,gid对应映射podman run -d \
--name alpine-dev \
--hostname alpine-dev \
--net=host \
--user=admin \
-e LANG=C.UTF-8 \
--workdir /workspace \
-v /home/admin/docs:/workspace \
-v /home/admin/.ssh:/home/admin/.ssh:ro \
--cap-add SYS_PTRACE \
--security-opt seccomp=unconfined \
localhost/alpine-dev \
sh -c "sleep infinity"
这个过程确实会带来闪电般的快速启动,并且进入容器内部看到的就是 admin 用户身份:
--user=admin 启动如同闪电real 0m 0.22s
user 0m 0.11s
sys 0m 0.07s
但是,这个解决方案有缺陷:
使用纯
--user admin(不加--userns keep-id) ,Podman在Host主机上分配的静态映射区通常是从100000开始(取决于/etc/subuid)容器内
root(UID 0) 对应 Host主机上的100000容器内
admin(UID 1000) 对应 Host主机上的100999(即100000+1000-1)当卷挂载(如
/workspace)时,容器内admin创建的文件落到Host磁盘上后,属主会显示为100999:100999,与Host上真正的admin(UID 1000)彻底脱节,引发读写权限隔离或冲突
另一种方法是保持 --user 1000:1000 --userns keep-id ,这个方案实际上是Rootless标准做法:
构建阶段(
podman build):镜像内的
root(UID 0) 被物理写入Host主机磁盘为 UID100000镜像内的
admin(UID 0) 写入Host主机磁盘为 UID100999(100000+1000-1)此时镜像中每一个只读层(Read-Only Layer)在宿主机磁盘上的UID/GID属性已经固定下来
运行阶段(
podman run --user 1000:1000 --userns keep-id),强制把容器内的admin(UID 1000)直接绑定到宿主机的真实admin(UID 1000),这意味着Podman必须重建一套全新的映射关系(User Namespace Mapping):此时镜像只读层里的文件在宿主机磁盘上的实际属主依然是旧账本的
100999但是新账本要求容器内UID 1000对应宿主机的UID 1000: 这就带来容器内admin无法读写/home/admin目录下的文件,因为那些文件在创建镜像时保存为UID 100999为了解决这个UID映射和文件不一致问题,Podman在调用
containers/storage挂载容器层是,必须由storage-chown-by-maps辅助工具在宿主机Mountpoint(/home/admin/.local/share/containers/storage/overlay/<HASH>/merged)上执行以下4个阶段的工作:
storage-chown-by-maps 执行4个步骤[1. 读取新旧 UID 映射表]
│
▼
[2. 深度遍历全量 Inode 节点] ── (递归扫描每一个文件、目录、软链接)
│
▼
[3. 计算 UID/GID 物理偏移] ── (旧宿主机 UID -> 容器内 UID -> 新宿主机 UID)
│
▼
[4. 写入 Overlay 复制层/元数据 (chown)] ── (产生磁盘随机 I/O 巨量开销)
备注
由于 Linux OverlayFS 机制的限制,它无法像传统文件系统那样“一行命令改变整个挂载点”。 storage-chown-by-maps 必须调用 filepath.Walk 或 fts_read 算法:
深度优先递归遍历:从 / 根目录开始,递归扫描镜像中每一层的每一个目录、文件、软链接、设备节点。
文件基数放大: 当镜像中安装了大量的软件和工具会导致需要遍历扫描的节点(Inode)数量达到数十万计。
备注
OverlayFS Copy-Up 复制上移与元数据写盘:
Linux 内核的 OverlayFS 会触发 Copy-Up(复制上移) 动作——将该文件从只读层完整复制一份到 UpperDir(可写层)或元数据暂存区,然后再修改其 UID。
这意味着不仅有巨量的随机 Inode 读写,还会伴随着大量小文件的磁盘拷贝与元数据 Flush(同步刷新到磁盘)。
机制与配置 |
方案 A: |
方案 B: 纯 |
|---|---|---|
UID/GID 映射关系 |
容器 |
容器 |
卷挂载文件属主 |
完全相同(Host 侧也是 |
完全不同(Host 侧显示为 |
首次启动开销 |
需要全量扫描 ( |
零扫描秒开 |
老硬件 (MacBook 2010) 体验 |
首次启动 I/O 队列拉满导致假死(需跑完一次) |
随时销毁/重建都是秒级启动 |
实际效果
老旧硬件上的物理瓶颈(I/O 放大效应):
CPU 瓶颈: MacBook Air 11" Late 2010 的 Core 2 Duo 双核处理器在处理数十万次单线程/多线程
fchownat系统调用时,CPU 会长时间陷入内核态(System Time 100%)磁盘 IOPS 瓶颈: 早期 SATA 接口 SSD 的随机 4K 读写(IOPS) 非常低。数十万次递归遍历 + 修改元数据,会产生极其可怕的磁盘寻道与写放大,导致磁盘队列深度(Queue Depth)瞬间爆表,系统产生极高的
iowait假死现象。
我实测在 MacBook Air 11" Late 2010 第一次启动耗时高达 15分钟 :
而在我租用的使用 NVMe存储 存储的VPS虚拟机中,只需要十几秒钟就能完成启动。
好消息 是: 第二次启动(或重启)会瞬间完成
持久化结果(Persistence) :
storage-chown-by-maps的重转换结果是 一次性 的。修改完成后,对应的映射关系已经被物理写入并记录在 Podman 本地存储驱动(~/.local/share/containers/storage/overlay/)的容器独占层中。复用 Overlay 索引 : 再次执行
podman start或重新创建挂载该持久化层的容器时,Podman 检测到该容器层的 UserNS 映射转换已经完成,会直接mount -t overlay加载,彻底跳过扫描,秒级启动。
参考
gemini