用 Docker 打包和部署 Python 应用及 Linux 进程与线程知识
一、Docker 中 CMD 与 ENTRYPOINT 指令
CMD 指令和 ENTRYPOINT 指令都是在容器启动时执行,而非构建时。当 Dockerfile 中定义了 ENTRYPOINT 指令,CMD 指令则用于定义传递给该 ENTRYPOINT 的默认参数。例如, /start.sh 路径作为参数传递给 /entrypoint.sh , /entrypoint.sh 的最后一行执行 /start.sh :
exec "$@"
/start.sh 脚本来自 uwsgi - nginx 基础镜像。在 /entrypoint.sh 为 Nginx 和 uWSGI 配置好容器运行时环境后, /start.sh 启动它们。当 CMD 与 ENTRYPOINT 结合使用时,CMD 设置的默认参数可以在 Docker 主机命令行被覆盖。
大多数 Dockerfile 没有 ENTRYPOINT 指令,所以 Dockerfile 的最后一行通常是一个在前台运行的 CMD 指令,而非默认参数。可以使用如下技巧让通用 Docker 容器在开发时持续运行:
CMD tail -f /dev/null
除了 ENTRYPOINT 和 CMD,此示例中 python - 3.8 - alpine Dockerfile 里的所有指令仅在容器构建时执行。
二、构建 Docker 镜像
在构建 Docker 镜像之前,需要一个 Dockerfile。可以使用以下命令查看系统上已有的 Docker 镜像列表:
$ docker images
接下来获取并构建刚刚剖析的 Dockerfile,步骤如下:
1. 克隆包含 Dockerfile 的仓库:
$ git clone https://github.com/tiangolo/uwsgi-nginx-flask-docker.git
- 切换到仓库内的
docker-images子目录:
$ cd uwsgi-nginx-flask-docker/docker-images
- 将
python3.8-alpine.dockerfile复制到名为Dockerfile的文件:
$ cp python3.8-alpine.dockerfile Dockerfile
- 从 Dockerfile 构建镜像:
$ docker build -t my-image .
镜像构建完成后,它会出现在本地 Docker 镜像列表中:
$ docker images
列表中除了新构建的 my-image ,还会有 uwsgi - nginx 基础镜像。注意,uwsgi - nginx 基础镜像创建后的时间远长于 my-image 创建后的时间。
三、运行 Docker 镜像
现在已经构建了一个 Docker 镜像,可以将其作为容器运行。使用以下命令获取系统上正在运行的容器列表:
$ docker ps
要基于 my-image 运行一个容器,执行以下 docker run 命令:
$ docker run -d --name my-container -p 80:80 my-image
现在查看正在运行的容器状态:
$ docker ps
列表中应该会看到一个基于 my-image 镜像、名为 my-container 的容器。 docker run 命令中的 -p 选项将容器端口映射到主机端口,在此示例中,容器端口 80 映射到主机端口 80,这使得容器内运行的 Flask Web 服务器能够处理 HTTP 请求。
要停止 my-container ,运行以下命令:
$ docker stop my-container
再次检查正在运行的容器状态:
$ docker ps
my-container 应该不再出现在正在运行的容器列表中。但容器并未消失,只是停止了。可以通过在 docker ps 命令中添加 -a 选项来查看 my-container 及其状态:
$ docker ps -a
四、获取 Docker 镜像
之前提到过 Docker Hub、AWS ECR 和 Quay 等镜像仓库。实际上,从克隆的 GitHub 仓库在本地构建的 Docker 镜像已经发布在 Docker Hub 上。从 Docker Hub 获取预构建的镜像比在本地系统上自己构建要快得多。该项目的 Docker 镜像可在 https://hub.docker.com/r/tiangolo/uwsgi-nginx-flask 找到。
要从 Docker Hub 拉取与我们构建的 my-image 相同的 Docker 镜像,输入以下命令:
$ docker pull tiangolo/uwsgi-nginx-flask:python3.8-alpine
再次查看 Docker 镜像列表:
$ docker images
列表中应该会看到一个新的 uwsgi - nginx - flask 镜像。
要运行这个新获取的镜像,执行以下 docker run 命令:
$ docker run -d --name flask-container -p 80:80 tiangolo/uwsgi-nginx-flask:python3.8-alpine
如果不想输入完整的镜像名称,可以在上述 docker run 命令中用 docker images 中的对应镜像 ID(哈希值)替换完整的镜像名称(仓库:标签)。
五、发布 Docker 镜像
要将 Docker 镜像发布到 Docker Hub,首先需要有一个账户并登录。可以通过访问 https://hub.docker.com 网站注册账户。有了账户后,就可以将现有镜像推送到 Docker Hub 仓库,步骤如下:
1. 从命令行登录 Docker Hub 镜像仓库:
$ docker login
- 提示时输入 Docker Hub 用户名和密码。
- 用一个以仓库名称开头的新名称标记现有镜像:
$ docker tag my-image:latest <repository>/my-image:latest
将上述命令中的 <repository> 替换为 Docker Hub 上的仓库名称(与用户名相同)。也可以用想要推送的其他现有镜像名称替换 my-image:latest 。
4. 将镜像推送到 Docker Hub 镜像仓库:
$ docker push <repository>/my-image:latest
同样,进行与步骤 3 相同的替换。
推送到 Docker Hub 的镜像默认是公开可用的。要访问新发布镜像的网页,访问 https://hub.docker.com/repository/docker/
/my-image
。将上述 URL 中的 <repository> 替换为 Docker Hub 上的仓库名称(与用户名相同)。如果推送的实际镜像名称不同,也可以用其替换 my-image:latest 。在该网页上点击 “Tags” 标签,应该会看到用于获取该镜像的 docker pull 命令。
六、清理 Docker 资源
我们知道 docker images 列出镜像, docker ps 列出容器。在删除 Docker 镜像之前,必须先删除引用该镜像的所有容器。要删除 Docker 容器,首先需要知道容器的名称或 ID,步骤如下:
1. 查找目标 Docker 容器的名称:
$ docker ps -a
- 如果容器正在运行,停止它:
$ docker stop flask-container
- 删除 Docker 容器:
$ docker rm flask-container
将上述两个命令中的 flask-container 替换为步骤 1 中的容器名称或 ID。 docker ps 中显示的每个容器都有一个关联的镜像名称或 ID。删除引用某个镜像的所有容器后,就可以删除该镜像。
Docker 镜像名称(仓库:标签)可能会很长(例如 tiangolo/uwsgi-nginx-flask:python3.8-alpine ),因此在删除时,复制并粘贴镜像的 ID(哈希值)会更方便,步骤如下:
1. 查找 Docker 镜像的 ID:
$ docker images
- 删除 Docker 镜像:
$ docker rmi <image-ID>
将上述命令中的 <image-ID> 替换为步骤 1 中的镜像 ID。
如果只想清除系统上不再使用的所有容器和镜像,可以使用以下命令:
$ docker system prune -a
docker system prune 会删除所有停止的容器和悬空镜像。
七、Linux 进程与线程相关概念
1. 技术要求
要跟随本章示例操作,需确保基于 Linux 的主机系统安装了以下软件:
- Python:Python 3 解释器和标准库
- Miniconda:conda 包和虚拟环境管理器的最小安装程序
如果尚未安装 Miniconda,可以参考相关安装说明。本章练习还需要 GCC C 编译器和 GNU make,但大多数 Linux 发行版已自带这些工具。
本章的所有代码可在 https://github.com/PacktPublishing/Mastering-Embedded-Linux-Programming-Third-Edition 的 Chapter17 文件夹中找到。
2. 进程还是线程
许多熟悉实时操作系统(RTOS)的嵌入式开发人员认为 Unix 进程模型很繁琐。另一方面,他们发现 RTOS 任务和 Linux 线程有相似之处,并且倾向于将现有的设计以一对一的方式将 RTOS 任务映射到线程。有时会看到整个应用程序用一个包含 40 个或更多线程的进程来实现的设计。
进程是一个内存地址空间和一条执行线程。地址空间是进程私有的,不同进程中的线程无法访问。这种内存分离由内核中的内存管理子系统创建,它为每个进程维护一个内存页映射,并在每次上下文切换时重新编程内存管理单元。进程的部分地址空间映射到一个包含程序运行的代码和静态数据的文件。
线程是进程内的一条执行线程。所有进程都从一个运行 main() 函数的线程开始,这个线程称为主线程。可以使用 pthread_create(3) POSIX 函数创建额外的线程,从而使多个线程在同一地址空间中执行。
基于这些信息,对于一个将 40 个 RTOS 任务移植到 Linux 的假设系统,可以想象两种极端设计:
- 将任务映射到进程 :有 40 个独立的程序通过 IPC(如本地套接字)进行通信。这样可以大大减少内存损坏问题,因为每个进程中的主线程相互隔离,并且每个进程退出后会清理资源,减少资源泄漏。但进程间的消息接口相当复杂,在一组进程紧密协作的情况下,消息数量可能会很大,成为系统性能的限制因素。此外,任何一个进程可能会因 bug 崩溃,而其他 39 个进程需要继续运行,每个进程都必须处理邻居进程不再运行的情况并优雅恢复。
- 将任务映射到线程 :将系统实现为一个包含 40 个线程的单一进程。协作变得容易,因为它们共享相同的地址空间和文件描述符,消息传递的开销减少或消除,线程间的上下文切换比进程间更快。但缺点是引入了一个任务损坏另一个任务的堆或栈的可能性。如果任何一个线程遇到致命 bug,整个进程将终止,所有线程都会随之结束。最后,调试复杂的多线程进程可能是一场噩梦。
结论是这两种设计都不理想,应该有更好的方法,但在探讨之前,我们将更深入地研究进程和线程的 API 及行为。
3. 进程的创建与特性
进程持有线程可以运行的环境,包括内存映射、文件描述符、用户和组 ID 等。第一个进程是 init 进程,它在启动时由内核创建,PID 为 1。此后,进程通过称为分叉(forking)的复制操作创建。
创建新进程的 POSIX 函数是 fork(2) 。这是一个特殊的函数,每次成功调用会有两个返回:一个在调用该函数的进程(父进程)中,一个在新创建的进程(子进程)中。调用后,子进程是父进程的精确副本,具有相同的栈、堆、文件描述符,并执行 fork 之后的同一行代码。程序员区分它们的唯一方法是查看 fork 的返回值:子进程返回 0,父进程返回大于 0 的值,实际上返回给父进程的值是新创建子进程的 PID。还有第三种可能,返回值为负,表示 fork 调用失败,仍然只有一个进程。
虽然两个进程大部分相同,但它们处于不同的地址空间,一个进程对变量的更改不会被另一个进程看到。实际上,内核不会物理复制父进程的内存,而是共享内存并标记为写时复制(CoW)标志。如果父进程或子进程修改此内存,内核会进行复制并写入副本,这使得 fork 函数高效,同时保留了进程地址空间的逻辑分离。
用 Docker 打包和部署 Python 应用及 Linux 进程与线程知识
八、线程的创建与特性
线程是进程内的执行单元,所有进程初始都有一个主线程来运行 main() 函数。可以使用 pthread_create(3) POSIX 函数创建额外线程,使多个线程在同一进程的地址空间内并行执行。
由于线程共享同一进程的资源,它们可以自由读写相同的内存区域,使用相同的文件描述符。这使得线程间的通信相对容易,但也带来了同步和锁定的问题。例如,多个线程同时访问和修改同一变量时,可能会导致数据不一致或程序崩溃。为了避免这些问题,需要使用同步机制,如互斥锁(mutex)、信号量(semaphore)等。
下面是一个简单的使用 pthread_create 创建线程的示例代码:
#include <stdio.h>
#include <pthread.h>
// 线程函数
void *thread_function(void *arg) {
printf("This is a new thread.\n");
return NULL;
}
int main() {
pthread_t thread_id;
int result;
// 创建新线程
result = pthread_create(&thread_id, NULL, thread_function, NULL);
if (result != 0) {
perror("Thread creation failed");
return 1;
}
// 等待线程结束
result = pthread_join(thread_id, NULL);
if (result != 0) {
perror("Thread join failed");
return 1;
}
printf("Main thread exiting.\n");
return 0;
}
在这个示例中, pthread_create 函数用于创建一个新线程,该线程执行 thread_function 函数。 pthread_join 函数用于等待新线程执行完毕,确保主线程在新线程结束后再退出。
九、进程间通信(IPC)
进程间通信(IPC)是指不同进程之间进行数据交换和同步的机制。常见的 IPC 方式包括管道(Pipe)、消息队列(Message Queue)、共享内存(Shared Memory)和本地套接字(Local Socket)等。
- 管道(Pipe) :管道是一种半双工的通信方式,数据只能在一个方向上流动。它通常用于父子进程之间的通信。创建管道使用
pipe()系统调用,返回两个文件描述符,一个用于读,一个用于写。
#include <stdio.h>
#include <unistd.h>
int main() {
int pipefd[2];
pid_t pid;
char buffer[100];
// 创建管道
if (pipe(pipefd) == -1) {
perror("Pipe creation failed");
return 1;
}
// 创建子进程
pid = fork();
if (pid == -1) {
perror("Fork failed");
return 1;
}
if (pid == 0) {
// 子进程:写数据到管道
close(pipefd[0]); // 关闭读端
write(pipefd[1], "Hello from child!", 16);
close(pipefd[1]);
} else {
// 父进程:从管道读数据
close(pipefd[1]); // 关闭写端
read(pipefd[0], buffer, sizeof(buffer));
printf("Parent received: %s\n", buffer);
close(pipefd[0]);
}
return 0;
}
- 消息队列(Message Queue) :消息队列允许进程之间以消息的形式交换数据。每个消息都有一个类型和内容,进程可以根据消息类型来接收特定的消息。使用
msgget()、msgsnd()和msgrcv()等系统调用实现消息队列的创建、发送和接收。 - 共享内存(Shared Memory) :共享内存是一种高效的 IPC 方式,多个进程可以直接访问同一块物理内存区域。使用
shmget()、shmat()和shmdt()等系统调用实现共享内存的创建、附加和分离。 - 本地套接字(Local Socket) :本地套接字可以用于同一主机上不同进程之间的通信,类似于网络套接字,但使用的是本地文件系统路径而不是网络地址。它支持流式(SOCK_STREAM)和数据报(SOCK_DGRAM)两种通信方式。
十、线程同步与锁定
如前文所述,线程共享同一进程的资源,因此在多线程编程中,需要使用同步和锁定机制来避免数据竞争和不一致的问题。常见的同步机制包括互斥锁(mutex)、信号量(semaphore)和条件变量(condition variable)等。
- 互斥锁(Mutex) :互斥锁是一种最基本的同步机制,用于保护共享资源,确保同一时间只有一个线程可以访问该资源。使用
pthread_mutex_init()初始化互斥锁,pthread_mutex_lock()加锁,pthread_mutex_unlock()解锁。
#include <stdio.h>
#include <pthread.h>
pthread_mutex_t mutex;
int shared_variable = 0;
// 线程函数
void *thread_function(void *arg) {
pthread_mutex_lock(&mutex);
shared_variable++;
printf("Thread incremented shared_variable to %d\n", shared_variable);
pthread_mutex_unlock(&mutex);
return NULL;
}
int main() {
pthread_t thread_id;
int result;
// 初始化互斥锁
pthread_mutex_init(&mutex, NULL);
// 创建新线程
result = pthread_create(&thread_id, NULL, thread_function, NULL);
if (result != 0) {
perror("Thread creation failed");
return 1;
}
// 等待线程结束
result = pthread_join(thread_id, NULL);
if (result != 0) {
perror("Thread join failed");
return 1;
}
// 销毁互斥锁
pthread_mutex_destroy(&mutex);
printf("Main thread exiting.\n");
return 0;
}
- 信号量(Semaphore) :信号量是一种更通用的同步机制,可以用于控制对多个共享资源的访问。信号量有一个整数值,表示可用资源的数量。使用
sem_init()初始化信号量,sem_wait()减少信号量的值,sem_post()增加信号量的值。 - 条件变量(Condition Variable) :条件变量用于线程间的等待和通知机制。当某个条件不满足时,线程可以等待条件变量;当条件满足时,另一个线程可以通知等待的线程。使用
pthread_cond_init()初始化条件变量,pthread_cond_wait()等待条件变量,pthread_cond_signal()或pthread_cond_broadcast()通知等待的线程。
十一、调度策略
在 Linux 系统中,调度器负责决定哪个进程或线程在何时运行。调度策略主要分为分时调度(Timeshare Scheduling)和实时调度(Real - Time Scheduling)两种。
- 分时调度(Timeshare Scheduling) :分时调度是最常见的调度策略,适用于大多数普通应用程序。它将 CPU 时间划分为多个时间片,每个进程或线程在一个时间片内运行,然后切换到下一个进程或线程。这种调度策略保证了每个进程或线程都有机会运行,实现了公平的资源分配。
- 实时调度(Real - Time Scheduling) :实时调度用于对时间要求严格的应用程序,如工业控制、航空航天等领域。实时调度有两种主要的策略:SCHED_FIFO(先进先出)和 SCHED_RR(轮转)。SCHED_FIFO 调度策略中,具有相同优先级的进程或线程按照先进先出的顺序执行;SCHED_RR 调度策略中,具有相同优先级的进程或线程在一个时间片内轮流执行。
可以使用 sched_setscheduler() 系统调用设置进程或线程的调度策略和优先级。例如:
#include <stdio.h>
#include <sched.h>
int main() {
struct sched_param param;
int policy = SCHED_FIFO;
// 设置优先级
param.sched_priority = sched_get_priority_max(policy);
// 设置调度策略和优先级
if (sched_setscheduler(0, policy, ¶m) == -1) {
perror("Failed to set scheduler");
return 1;
}
printf("Scheduler set to SCHED_FIFO with max priority.\n");
return 0;
}
十二、总结
通过本文,我们了解了 Docker 在 Python 应用打包和部署中的使用,包括 Dockerfile 的编写、镜像的构建、运行、获取、发布和清理等操作。同时,深入探讨了 Linux 系统中进程和线程的概念、创建方法、通信机制、同步和锁定问题以及调度策略。
在实际开发中,合理选择使用进程和线程,以及合适的 IPC 方式和同步机制,对于提高程序的性能和稳定性至关重要。例如,对于需要大量计算和资源隔离的任务,可以使用多个进程;对于需要高效通信和共享资源的任务,可以使用多线程。同时,根据应用的实时性要求,选择合适的调度策略,确保系统能够满足不同的性能需求。
总之,掌握 Docker 和 Linux 进程与线程的相关知识,能够帮助开发者更好地进行软件开发和系统设计,提高开发效率和软件质量。
超级会员免费看
776

被折叠的 条评论
为什么被折叠?



