dockerfile最佳实践

通过 dockerfile 可以制作镜像, 通过优化 dockerfile 中的指令, 可以减少镜像的体积. 按照一些规范制作 dockerfile, 可以增加 dockerfile 的可读性和可维护性

基本原则

  • 容器的生命周期是短暂的(无状态容器)
    通过 dockerfile 制作出的镜像, 从镜像启动容器, 这些容器的生命周期应尽量短暂. 这里的短暂意味着容器随时可以被停止和删除. 通过简单的配置可以立即启动一个新的容器
  • 尽量使用干净的目录去制作镜像, 避免不必要的性能损耗, 必要时可以使用.dockerignore文件排除不需要扫描的目录
  • 只安装需要的包
    为了减少镜像的体积和编译时间, 应该避免安装额外的, 不需要的包
  • 每个容器只运行一个进程
  • 减少镜像层
    dockerfile 中的指令会生成新的镜像层, 一个镜像最多有127层
  • 把多个参数排在不同的行中提高可读性\

注意: 编译缓存的问题
针对 ADD 和 COPY 命令, docker 会检查镜像层中所有源文件的元数据和文件内容. 其中检查元数据的时候, 不会检查最后修改时间和访问时间.
docker 在镜像缓存中寻找镜像层时, 不会检查文件. 例如, 在执行 RUN yum update 时, docker 仅仅会比较 RUN 命令的本身.

FROM 指令最佳实践

尽量使用官方镜像作为基础镜像. 如果考虑镜像的大小, 可以使用 Debian 的官方镜像, 该镜像体积小而且 Docker 对他进行了优化, store.docker.com 中有很多官方镜像都是基于 Debian 的镜像打造

RUN 指令最佳实践

  • 从可读性的角度考虑, 使用 RUN 命令时, 应使用 \ 将指令分成多行
  • 避免更新基础镜像中的基础软件包, 避免执行类似于 yum updateapt upgrade的命令. 在没有使用 --privileged的运行容器中, 基础镜像中的很多基础包是不能升级的.
  • 由于镜像的缓存机制, 在安装软件包时, 更新软件仓库索引和安装软件包必须在同一个 RUN 里执行, 否则可能会导致安装失败
1
2
3
4
5
# ✔️正确写法
RUN apt update && apt install -y \
dstat \
htop \
lsof
1
2
3
4
5
6
# ❌错误写法
RUN apt update
RUN apt install -y \
dstat \
htop \
lsof

由于镜像的缓存机制, 只验证的 RUN 后面的命令, 会导致执行过一次 apt update 之后, 再次遇到更新时将会使用缓存镜像, 从而导致以后安装不到最新的软件包

使用RUN apt update && apt install -y \的方式可以保证每次编译镜像时, 都是安装的最新的包. 这种技术叫做”缓存失效”

  • 尽量在一条 RUN 指令中包含所有需要的安装包. 如果安装包很多, 建议以首字母进行排序,每行只列出一个安装包, 方便后期维护.
  • 在安装命令的最后, 应该清理安装缓存, 减少镜像体积. 执行 ... && yum clean all... && rm -fr /var/lib/apt/lists/*

CMD & ENTRYPOINT 指令最佳实践

CMD

CMD 指令设置镜像中的默认启动命令和参数. 容器启动之后, 如果没有加入任何启动命令(也就是在镜像参数之后没有添加任何内容) 则默认执行镜像中 CMD 设置的默认的启动命令

  • 设置启动命令时, 应该尽量使用 JSON 格式 CMD ["command", "arg1", "arg2"]
    例如 Apache 的启动方式: CMD ["apache2", "-DFOREGROUND"]

  • 如果开发者和使用者都不是很熟悉 CMD 和 ENTRYPOINT 的工作原理的情况下, 尽量避免这两个指令配合使用
    例如 Django 的启动方式: CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

  • 相反, 如果开发者和使用者都很熟悉 CMD 和 ENTRYPOINT 的工作原理, 推荐 CMD 作为 ENTRYPOINT 的参数来配套使用

ENTRYPOINT

当需要把容器当做一个命令行工具使用时, 推荐通过 ENTRYPOINT 指令设置镜像的入口程序

  • 当启动主程序之前还需要执行大量的前置操作时, 可以将 ENTRYPOINT 的入口指令设置为一个脚本 entrypoint.sh

例如 postgres 的官方用法:

1
2
3
...
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["postgres"]

docker-entrypoint.sh ⬇️

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
#!/bin/bash
set -e

# usage: file_env VAR [DEFAULT]
# ie: file_env 'XYZ_DB_PASSWORD' 'example'
# (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of
# "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature)
file_env() {
local var="$1"
local fileVar="${var}_FILE"
local def="${2:-}"
if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then
echo >&2 "error: both $var and $fileVar are set (but are exclusive)"
exit 1
fi
local val="$def"
if [ "${!var:-}" ]; then
val="${!var}"
elif [ "${!fileVar:-}" ]; then
val="$(< "${!fileVar}")"
fi
export "$var"="$val"
unset "$fileVar"
}

if [ "${1:0:1}" = '-' ]; then
set -- postgres "$@"
fi

# allow the container to be started with `--user`
if [ "$1" = 'postgres' ] && [ "$(id -u)" = '0' ]; then
mkdir -p "$PGDATA"
chown -R postgres "$PGDATA"
chmod 700 "$PGDATA"

mkdir -p /var/run/postgresql
chown -R postgres /var/run/postgresql
chmod g+s /var/run/postgresql

# Create the transaction log directory before initdb is run (below) so the directory is owned by the correct user
if [ "$POSTGRES_INITDB_XLOGDIR" ]; then
mkdir -p "$POSTGRES_INITDB_XLOGDIR"
chown -R postgres "$POSTGRES_INITDB_XLOGDIR"
chmod 700 "$POSTGRES_INITDB_XLOGDIR"
fi

exec gosu postgres "$BASH_SOURCE" "$@"
fi

if [ "$1" = 'postgres' ]; then
mkdir -p "$PGDATA"
chown -R "$(id -u)" "$PGDATA" 2>/dev/null || :
chmod 700 "$PGDATA" 2>/dev/null || :

# look specifically for PG_VERSION, as it is expected in the DB dir
if [ ! -s "$PGDATA/PG_VERSION" ]; then
file_env 'POSTGRES_INITDB_ARGS'
if [ "$POSTGRES_INITDB_XLOGDIR" ]; then
export POSTGRES_INITDB_ARGS="$POSTGRES_INITDB_ARGS --xlogdir $POSTGRES_INITDB_XLOGDIR"
fi
eval "initdb --username=postgres $POSTGRES_INITDB_ARGS"

# check password first so we can output the warning before postgres
# messes it up
file_env 'POSTGRES_PASSWORD'
if [ "$POSTGRES_PASSWORD" ]; then
pass="PASSWORD '$POSTGRES_PASSWORD'"
authMethod=md5
else
# The - option suppresses leading tabs but *not* spaces. :)
cat >&2 <<-'EOWARN'
****************************************************
WARNING: No password has been set for the database.
This will allow anyone with access to the
Postgres port to access your database. In
Docker's default configuration, this is
effectively any other container on the same
system.
Use "-e POSTGRES_PASSWORD=password" to set
it in "docker run".
****************************************************
EOWARN

pass=
authMethod=trust
fi

{
echo
echo "host all all all $authMethod"
} >> "$PGDATA/pg_hba.conf"

# internal start of server in order to allow set-up using psql-client
# does not listen on external TCP/IP and waits until start finishes
PGUSER="${PGUSER:-postgres}" \
pg_ctl -D "$PGDATA" \
-o "-c listen_addresses='localhost'" \
-w start

file_env 'POSTGRES_USER' 'postgres'
file_env 'POSTGRES_DB' "$POSTGRES_USER"

psql=( psql -v ON_ERROR_STOP=1 )

if [ "$POSTGRES_DB" != 'postgres' ]; then
"${psql[@]}" --username postgres <<-EOSQL
CREATE DATABASE "$POSTGRES_DB" ;
EOSQL
echo
fi

if [ "$POSTGRES_USER" = 'postgres' ]; then
op='ALTER'
else
op='CREATE'
fi
"${psql[@]}" --username postgres <<-EOSQL
$op USER "$POSTGRES_USER" WITH SUPERUSER $pass ;
EOSQL
echo

psql+=( --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" )

echo
for f in /docker-entrypoint-initdb.d/*; do
case "$f" in
*.sh) echo "$0: running $f"; . "$f" ;;
*.sql) echo "$0: running $f"; "${psql[@]}" -f "$f"; echo ;;
*.sql.gz) echo "$0: running $f"; gunzip -c "$f" | "${psql[@]}"; echo ;;
*) echo "$0: ignoring $f" ;;
esac
echo
done

PGUSER="${PGUSER:-postgres}" \
pg_ctl -D "$PGDATA" -m fast -w stop

echo
echo 'PostgreSQL init process complete; ready for start up.'
echo
fi
fi

exec "$@"

CMD 和 ENTRYPOINT的区别

  • 当 dockerfile 中指定了 ENTRYPOINT 的时候, docker run 如果在镜像之后添加的指令, 那么这些指令将被当做 ENTRYPOINT 的参数执行
  • 如果 dockerfile 中同时有 CMD 和 ENTRYPOINT 指令, 当 CMD 指令可执行时, 它将在 ENTRYPOINT 之前运行; 如果 CMD 不是可执行的命令, 则将作为 ENTRYPOINT 的命令参数追加

EXPOSE 最佳实践

  • EXPOSE 用来声明未来容器内需要监听的端口, 在 bridge 模式下, 这些容器内部的端口会映射到宿主机的端口上, 建议在容器内部不要更改应用原生的端口号

  • EXPOSE 中只能指定未来容器内部需要暴露的端口, 不能指定未来容器外部与内部端口之间的映射关系, 比如设置 EXPOSE 8800:80 是没有任何意义的

ENV 最佳实践

  • 通过环境变量来配置服务的相关设置, 通过这种方式可以方便的在不同环境中修改配置, 快速启动服务, 加快开发, 测试, 部署的流程
  • 设置系统环境变量, 比如二进制包安装的 nginx, 在 ENV 设置 PATH
  • 也可以借鉴 LABEL 的使用哲学, 标记一些版本号或版本编码信息

ADD 与 COPY 最佳实践

ADD 与 COPY 都是将外部文件拷贝到镜像内部的指令, 相比之下可能 ADD 的功能更加强大一下, 但是我的建议如下:

  • 尽量不要拷贝远程文件, 这样也就用不着 ADD 的功能, 用 COPY 就可以了
  • 如果压缩包拷贝进镜像后, 不希望这个压缩包被自动解压缩, 用 COPY 就对了. 反之如果希望拷贝进镜像之后就自动解压做, 那就用 ADD 拷贝进去

如果涉及到远程文件, 建议使用 RUN curlRUN wget 命令替代 ADD

VOLUME 最佳实践

VOLUME 的作用是持久化容器内的文件, 声明某个目录需要在宿主机挂载后, 当 docker run 起镜像时, docker 会在宿主机默认目录下随机生成一个文件夹挂载到容器内部的指定文件夹.

当该容器被删除时, 宿主机上的这个文件夹并不会被删除. 当删除容器时加入了-v 参数, 那么对应的数据卷就会被删除

USER 最佳实践

如果容器中的应用程序运行时不需要特殊的权限, 可以通过 USER 指令把应用程序的所有者设置为非 root 用户. 如果该用户不存在, 首先需要使用 RUN 命令在镜像中创建用户.

  • 如果在每次编译镜像时, 对用户的 UID/GID 有要求需要保持一致, 应该在新建用户和组的时候指定 UID和 GID
  • 在镜像中避免使用sudo 命令. 应为该命令使用的 TTY 不确定, 对接收信号量也会造成影响. 如果确实需要使用 sudo 功能, 则可是使用 gosu 命令替代
  • 可以用 root 用户初始化一个 daemon, 然后用非 root 用户启动这个 daemon
  • 为了减少镜像体积, 应该避免不必要的用户切换

WORKDIR 最佳实践

  • 尽量使用绝对路径
  • 切换目录的时候尽量使用 WORKDIR, 而不是使用 RUN cd /dir

gosu 工具

gosu 是一个 golang 语言开发的工具, 用来取代 shell 中的 sudo 命令. su 和 sudo 命令有一些缺陷, 主要是会引起不确定的 TTY, 对信号量的转发也存在问题. 如果仅仅为了使用特定的用户运行程序, 使用 su 或 sudo 显得太重了, 为此 gosu 应运而生.

gosu 直接借用了 libcontainer 在容器中启动应用程序的原理, 使用 /etc/passwd 处理应用程序. gosu 首先找出指定的用户或用户组, 然后切换到该用户或用户组. 接下来, 使用 exec 启动应用程序. 到此为止, gosu 完成了它的工作, 不会参与到应用程序后面的声明周期中. 使用这种方式避免了 gosu 处理 TTY 和转发信号量的问题, 把这两个工作直接交给了应用程序去完成