├── .gitignore ├── LICENSE ├── README.md ├── clear.bat ├── dev.md ├── docker_image_puller.py ├── favicon.ico ├── requirements.txt ├── version.txt └── 截图.png /.gitignore: -------------------------------------------------------------------------------- 1 | test.py 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker Image Puller 2 | 3 | ## 项目简介 4 | 5 | Docker Image Puller 是一个方便的工具,用于从 Docker 仓库拉取镜像,支持国内镜像源加速和多架构支持。该工具采用 MIT 许可证,开放源代码,方便用户根据需要进行定制和扩展。 6 | 7 | ## 特点 8 | 9 | - **无需安装 Docker 或 Python 环境**:直接使用单文件 EXE 或 Python 脚本,开箱即用。 10 | - **无依赖 EXE 执行**:编译为独立 EXE 文件,无需安装 Python 环境,无需安装 Docker 环境,直接在 Releases 下载就能直接使用。 11 | - **国内镜像源加速**:通过配置国内镜像源,大幅提高镜像下载速度,解决国内无法直接下载的问题。 12 | - **多架构支持**:支持多种架构(如 `amd64`、`arm64`),满足不同环境需求,尤其是 arm64 内网服务器。 13 | - **兼容最新 Docker Hub API**:确保与 Docker Hub 的最新接口兼容,获取最新的镜像信息。 14 | - **单文件 Python 脚本**:便于携带和使用,无需复杂安装。 15 | - **用户友好**:提供交互式输入,简化操作流程。 16 | - **优化性能**:提高下载速度和可靠性。 17 | 18 | 19 | ## 截图: 20 | 21 | ![用户界面截图](./截图.png) 22 | 23 | 24 | ## 安装 25 | 26 | ### 下载 EXE 文件 27 | 28 | 前往 [Releases](https://github.com/topcss/docker-pull-tar/releases) 页面,下载 `DockerPull.exe`,无需安装任何依赖,直接运行。 29 | 30 | 31 | ### 通过 Git 克隆 32 | 33 | ```bash 34 | git clone https://github.com/topcss/docker-pull-tar.git 35 | ``` 36 | 37 | ### 基本用法 38 | 39 | ```bash 40 | python docker_image_puller.py [镜像名称] [架构] [仓库地址] 41 | ``` 42 | 43 | ### 示例 44 | 45 | #### 交互式模式 46 | 47 | ```bash 48 | D:\> DockerPull.exe 49 | 50 | 欢迎使用 Docker 镜像拉取工具! 51 | 请输入以下信息: 52 | 请输入 Docker 镜像名称(例如:library/ubuntu:latest):alpine 53 | 请输入架构(默认:amd64): 54 | 请输入 Docker 仓库地址(默认:docker.xuanyuan.me): 55 | 仓库地址:docker.xuanyuan.me 56 | 仓库名:library/alpine 57 | 标签:latest 58 | 架构:amd64 59 | Docker 镜像已拉取:library_alpine.tar 60 | ``` 61 | 62 | #### 命令行模式 63 | 64 | ### 基本用法 65 | 66 | ```bash 67 | python docker_image_puller.py [选项] 68 | ``` 69 | 70 | ### 参数说明 71 | 72 | - `-h, --help`:显示帮助信息。 73 | - `-v, --version`:显示版本信息。 74 | - `-i, --image`:指定 Docker 镜像名称(例如:library/ubuntu:latest)。 75 | - `-a, --arch`:指定架构(默认:amd64)。 76 | - `-r, --registry`:指定 Docker 仓库地址(默认:docker.xuanyuan.me)。 77 | - `--debug`:启用调试模式,打印详细日志。 78 | 79 | ### 示例 80 | 81 | #### 显示帮助信息 82 | 83 | ```bash 84 | python docker_image_puller.py -h 85 | ``` 86 | 87 | #### 查看版本信息 88 | 89 | ```bash 90 | python docker_image_puller.py -v 91 | ``` 92 | 93 | #### 指定镜像名称、架构和仓库地址 94 | 95 | ```bash 96 | python docker_image_puller.py -i alpine -a arm64 -r 1ms.run --debug 97 | ``` 98 | 99 | ## 内网 Docker 导入方法 100 | 101 | 1. **拉取镜像并打包** 102 | 使用本工具拉取镜像并生成 `.tar` 文件,例如 `library_alpine.tar`。 103 | 104 | 2. **将 `.tar` 文件传输到内网机器** 105 | 通过 U 盘、内网文件服务器或其他方式将 `.tar` 文件传输到目标机器。 106 | 107 | 3. **导入镜像到 Docker** 108 | 在内网机器上运行以下命令导入镜像: 109 | 110 | ```bash 111 | docker load -i library_alpine.tar 112 | ``` 113 | 114 | 4. **验证镜像** 115 | 导入完成后,运行以下命令查看镜像: 116 | 117 | ```bash 118 | docker images 119 | ``` 120 | 121 | 然后启动容器: 122 | 123 | ```bash 124 | docker run -it alpine 125 | ``` 126 | 127 | ## 许可证 128 | 129 | 本项目采用 MIT 许可证,详情见 [LICENSE](LICENSE) 文件。 130 | 131 | ## 联系方式 132 | 133 | 如有任何问题或建议,请通过 [GitHub Issues](https://github.com/topcss/docker-pull-tar/issues) 提出。 134 | 135 | ## 为什么选择这个工具? 136 | 137 | - **无需安装 Docker 或 Python**:直接运行 EXE 文件,适合内网环境。 138 | - **速度快**:国内镜像源加速,下载更快。 139 | - **架构灵活**:支持 `amd64` 和 `arm64` 架构,适应多种环境。 140 | - **易于使用**:单文件脚本,无需复杂配置。 141 | - **开放源代码**:自由定制和扩展。 142 | 143 | ## 常见问题 144 | 145 | **Q**: 如何配置国内镜像源? 146 | **A**: 在命令行中指定仓库地址参数,例如 `docker.xuanyuan.me`。 147 | 148 | **Q**: 支持哪些架构? 149 | **A**: 目前支持 `amd64` 和 `arm64` 架构。 150 | 151 | **Q**: 是否需要安装 Docker 或 Python? 152 | **A**: 不需要!直接下载 `DockerPull.exe` 即可运行。 153 | 154 | **Q**: 如何在内网中使用? 155 | **A**: 使用本工具拉取镜像并生成 `.tar` 文件,然后通过 `docker load` 命令导入内网机器。 156 | 157 | --- 158 | 159 | 希望通过这个工具能为您的 Docker 镜像管理带来便利! 🚀 160 | 161 | --- 162 | 163 | ### 目录 164 | 165 | - [Docker Image Puller](#docker-image-puller) 166 | - [项目简介](#项目简介) 167 | - [特点](#特点) 168 | - [截图:](#截图) 169 | - [安装](#安装) 170 | - [下载 EXE 文件](#下载-exe-文件) 171 | - [通过 Git 克隆](#通过-git-克隆) 172 | - [基本用法](#基本用法) 173 | - [示例](#示例) 174 | - [交互式模式](#交互式模式) 175 | - [命令行模式](#命令行模式) 176 | - [基本用法](#基本用法-1) 177 | - [参数说明](#参数说明) 178 | - [示例](#示例-1) 179 | - [显示帮助信息](#显示帮助信息) 180 | - [查看版本信息](#查看版本信息) 181 | - [指定镜像名称、架构和仓库地址](#指定镜像名称架构和仓库地址) 182 | - [内网 Docker 导入方法](#内网-docker-导入方法) 183 | - [许可证](#许可证) 184 | - [联系方式](#联系方式) 185 | - [为什么选择这个工具?](#为什么选择这个工具) 186 | - [常见问题](#常见问题) 187 | - [目录](#目录) 188 | 189 | --- 190 | 191 | 如果有其他需求或需要进一步优化,请随时告诉我! 😊 -------------------------------------------------------------------------------- /clear.bat: -------------------------------------------------------------------------------- 1 | @REM 删除多余文件 2 | rmdir dist /s /q 3 | rmdir build /s /q 4 | del DockerPull.exe.spec 5 | del Pipfile* 6 | 7 | @REM pause 8 | -------------------------------------------------------------------------------- /dev.md: -------------------------------------------------------------------------------- 1 | 为了减小打包的体积,需要在虚拟化环境中打包,以下为打包的步骤。 2 | 3 | 第一步 4 | 5 | ``` bat 6 | 7 | set WORKON_HOME=d:\.virtualenvs 8 | 9 | @REM 建立虚拟环境 10 | pipenv install 11 | @REM 进入虚拟环境 12 | pipenv shell 13 | 14 | ``` 15 | 16 | 第二步,虚拟环境需要单独执行,否则会报错 17 | 18 | ``` bat 19 | @REM 安装依赖,在虚拟环境中 20 | pip install pyinstaller requests urllib3 tqdm -i https://pypi.tuna.tsinghua.edu.cn/simple/ 21 | @REM 打包 22 | pyinstaller -F -n DockerPull.exe -i favicon.ico --version-file=version.txt docker_image_puller.py 23 | @REM 卸载依赖 24 | pipenv uninstall --all 25 | @REM 删除虚拟环境 26 | pipenv --rm 27 | @REM 退出虚拟环境 28 | exit 29 | 30 | ``` 31 | -------------------------------------------------------------------------------- /docker_image_puller.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import gzip 4 | import json 5 | import hashlib 6 | import shutil 7 | import threading 8 | import requests 9 | from requests.adapters import HTTPAdapter 10 | from urllib3.util.retry import Retry 11 | from tqdm import tqdm 12 | import tarfile 13 | import urllib3 14 | import argparse 15 | import logging 16 | import base64 17 | from concurrent.futures import ThreadPoolExecutor, as_completed 18 | 19 | # Set default encoding to UTF-8 20 | import io 21 | sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') 22 | sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') 23 | 24 | # 禁用 SSL 警告 25 | urllib3.disable_warnings() 26 | 27 | # 版本号 28 | VERSION = "v1.1.0" 29 | 30 | # 配置日志 31 | logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s', encoding='utf-8') 32 | logger = logging.getLogger(__name__) 33 | 34 | stop_event = threading.Event() 35 | 36 | 37 | def create_session(): 38 | """创建带有重试和代理配置的请求会话""" 39 | session = requests.Session() 40 | retry_strategy = Retry( 41 | total=3, 42 | backoff_factor=1, 43 | status_forcelist=[429, 500, 502, 503, 504], 44 | ) 45 | adapter = HTTPAdapter(max_retries=retry_strategy) 46 | session.mount("http://", adapter) 47 | session.mount("https://", adapter) 48 | 49 | # 设置代理 50 | session.proxies = { 51 | 'http': os.environ.get('HTTP_PROXY') or os.environ.get('http_proxy'), 52 | 'https': os.environ.get('HTTPS_PROXY') or os.environ.get('https_proxy') 53 | } 54 | if session.proxies.get('http') or session.proxies.get('https'): 55 | logger.info('使用代理设置从环境变量') 56 | 57 | return session 58 | 59 | def parse_image_input(args): 60 | """解析用户输入的镜像名称,支持私有仓库格式""" 61 | image_input = args.image 62 | # 检查是否包含私有仓库地址 63 | if '/' in image_input and ('.' in image_input.split('/')[0] or ':' in image_input.split('/')[0]): 64 | # 私有仓库格式: harbor.abc.com/abc/nginx:1.26.0 65 | registry, remainder = image_input.split('/', 1) 66 | parts = remainder.split('/') 67 | if len(parts) == 1: 68 | repo = '' 69 | img_tag = parts[0] 70 | else: 71 | repo = '/'.join(parts[:-1]) 72 | img_tag = parts[-1] 73 | 74 | # 解析镜像名和标签 75 | img, *tag_parts = img_tag.split(':') 76 | tag = tag_parts[0] if tag_parts else 'latest' 77 | 78 | # 组合成完整的仓库路径 79 | repository = remainder.split(':')[0] 80 | 81 | return registry, repository, img, tag 82 | else: 83 | # 标准Docker Hub格式 84 | parts = image_input.split('/') 85 | if len(parts) == 1: 86 | repo = 'library' 87 | img_tag = parts[0] 88 | else: 89 | repo = '/'.join(parts[:-1]) 90 | img_tag = parts[-1] 91 | 92 | # 解析镜像名和标签 93 | img, *tag_parts = img_tag.split(':') 94 | tag = tag_parts[0] if tag_parts else 'latest' 95 | 96 | # 组合成完整的仓库路径 97 | repository = f'{repo}/{img}' 98 | if not args.custom_registry: 99 | registry = 'registry-1.docker.io' 100 | else: 101 | registry = args.custom_registry 102 | return registry, repository, img, tag 103 | 104 | def get_auth_head(session, auth_url, reg_service, repository, username=None, password=None): 105 | """获取认证头,支持用户名密码认证""" 106 | try: 107 | url = f'{auth_url}?service={reg_service}&scope=repository:{repository}:pull' 108 | 109 | headers = {} 110 | # 如果提供了用户名和密码,添加到请求头 111 | if username and password: 112 | auth_string = f"{username}:{password}" 113 | encoded_auth = base64.b64encode(auth_string.encode('utf-8')).decode('utf-8') 114 | headers['Authorization'] = f'Basic {encoded_auth}' 115 | 116 | # 打印 curl 命令 117 | logger.debug(f"获取认证头 CURL 命令: curl '{url}'") 118 | 119 | resp = session.get(url, headers=headers, verify=False, timeout=30) 120 | resp.raise_for_status() 121 | access_token = resp.json()['token'] 122 | auth_head = {'Authorization': f'Bearer {access_token}', 'Accept': 'application/vnd.docker.distribution.manifest.v2+json'} 123 | 124 | return auth_head 125 | except requests.exceptions.RequestException as e: 126 | logger.error(f'请求认证失败: {e}') 127 | raise 128 | 129 | def fetch_manifest(session, registry, repository, tag, auth_head): 130 | """获取镜像清单""" 131 | try: 132 | url = f'https://{registry}/v2/{repository}/manifests/{tag}' 133 | # 打印 curl 命令 134 | headers = ' '.join([f"-H '{key}: {value}'" for key, value in auth_head.items()]) 135 | curl_command = f"curl '{url}' {headers}" 136 | logger.debug(f'获取镜像清单 CURL 命令: {curl_command}') 137 | resp = session.get(url, headers=auth_head, verify=False, timeout=30) 138 | if resp.status_code == 401: 139 | logger.info('需要认证。') 140 | return resp, 401 141 | resp.raise_for_status() 142 | return resp, 200 143 | except requests.exceptions.RequestException as e: 144 | logger.error(f'请求清单失败: {e}') 145 | raise 146 | 147 | def select_manifest(manifests, arch): 148 | """选择适合指定架构的清单""" 149 | selected_manifest = None 150 | for m in manifests: 151 | if (m.get('annotations', {}).get('com.docker.official-images.bashbrew.arch') == arch or \ 152 | m.get('platform',{}).get('architecture') == arch) and \ 153 | m.get('platform', {}).get('os') == 'linux': 154 | selected_manifest = m.get('digest') 155 | break 156 | return selected_manifest 157 | 158 | def download_file_with_progress(session, url, headers, save_path, desc): 159 | """下载文件""" 160 | try: 161 | with session.get(url, headers=headers, verify=False, timeout=30, stream=True) as resp: 162 | resp.raise_for_status() 163 | total_size = int(resp.headers.get('content-length', 0)) 164 | 165 | with open(save_path, 'wb') as file, tqdm( 166 | total=total_size, unit='B', unit_scale=True, desc=desc, position=0, leave=True 167 | ) as pbar: 168 | for chunk in resp.iter_content(chunk_size=1024): 169 | if stop_event.is_set(): 170 | raise KeyboardInterrupt 171 | if chunk: 172 | file.write(chunk) 173 | pbar.update(len(chunk)) 174 | return True 175 | except KeyboardInterrupt: 176 | logging.debug(f'⚠️ 下载 {url} 被用户取消') 177 | if os.path.exists(save_path): 178 | os.remove(save_path) # 删除部分下载的文件 179 | return False 180 | except Exception as e: 181 | logging.error(f'❌ 下载 {url} 失败: {e}') 182 | return False 183 | 184 | def download_layers(session, registry, repository, layers, auth_head, imgdir, resp_json, imgparts, img, tag): 185 | """多线程下载镜像层""" 186 | os.makedirs(imgdir, exist_ok=True) 187 | 188 | try: 189 | config_digest = resp_json['config']['digest'] 190 | config_filename = f'{config_digest[7:]}.json' 191 | config_path = os.path.join(imgdir, config_filename) 192 | config_url = f'https://{registry}/v2/{repository}/blobs/{config_digest}' 193 | 194 | logger.debug(f'下载 Config: {config_filename}') 195 | if not download_file_with_progress(session, config_url, auth_head, config_path, "Config"): 196 | raise Exception(f'Config JSON {config_filename} 下载失败') 197 | 198 | except Exception as e: 199 | logging.error(f'请求配置失败: {e}') 200 | return 201 | 202 | repo_tag = f'{"/".join(imgparts)}/{img}:{tag}' if imgparts else f'{img}:{tag}' 203 | content = [{'Config': config_filename, 'RepoTags': [repo_tag], 'Layers': []}] 204 | parentid = '' 205 | layer_json_map = {} 206 | 207 | with ThreadPoolExecutor(max_workers=4) as executor: 208 | futures = {} 209 | try: 210 | for layer in layers: 211 | if stop_event.is_set(): 212 | raise KeyboardInterrupt # 检测到终止信号 213 | 214 | ublob = layer['digest'] 215 | fake_layerid = hashlib.sha256((parentid + '\n' + ublob + '\n').encode('utf-8')).hexdigest() 216 | layerdir = f'{imgdir}/{fake_layerid}' 217 | os.makedirs(layerdir, exist_ok=True) 218 | layer_json_map[fake_layerid] = {"id": fake_layerid, "parent": parentid if parentid else None} 219 | parentid = fake_layerid 220 | 221 | url = f'https://{registry}/v2/{repository}/blobs/{ublob}' 222 | save_path = f'{layerdir}/layer_gzip.tar' 223 | futures[executor.submit(download_file_with_progress, session, url, auth_head, save_path, ublob[:12])] = save_path 224 | 225 | for future in as_completed(futures): 226 | if stop_event.is_set(): 227 | raise KeyboardInterrupt # 退出 228 | future.result() 229 | except KeyboardInterrupt: 230 | logging.error("用户终止下载,清理已下载文件...") 231 | stop_event.set() # 设置终止标志 232 | executor.shutdown(wait=False) 233 | for future, save_path in futures.items(): 234 | if os.path.exists(save_path): 235 | os.remove(save_path) # 删除部分下载的文件 236 | sys.exit(1) 237 | 238 | for fake_layerid in layer_json_map.keys(): 239 | if stop_event.is_set(): 240 | sys.exit(1) # 检测到终止信号,提前退出 241 | 242 | layerdir = f'{imgdir}/{fake_layerid}' 243 | gz_path = f'{layerdir}/layer_gzip.tar' 244 | tar_path = f'{layerdir}/layer.tar' 245 | 246 | with gzip.open(gz_path, 'rb') as gz, open(tar_path, 'wb') as file: 247 | shutil.copyfileobj(gz, file) 248 | os.remove(gz_path) 249 | 250 | json_path = f'{layerdir}/json' 251 | with open(json_path, 'w') as file: 252 | json.dump(layer_json_map[fake_layerid], file) 253 | 254 | content[0]['Layers'].append(f'{fake_layerid}/layer.tar') 255 | 256 | manifest_path = os.path.join(imgdir, 'manifest.json') 257 | with open(manifest_path, 'w') as file: 258 | json.dump(content, file) 259 | 260 | repositories_path = os.path.join(imgdir, 'repositories') 261 | with open(repositories_path, 'w') as file: 262 | json.dump({repository if '/' in repository else img: {tag: parentid}}, file) 263 | 264 | logging.info(f'✅ 镜像 {img}:{tag} 下载完成!') 265 | 266 | def create_image_tar(imgdir, repository, tag, arch): 267 | """将镜像打包为 tar 文件""" 268 | safe_repo = repository.replace("/", "_") 269 | docker_tar = f'{safe_repo}_{tag}_{arch}.tar' 270 | try: 271 | with tarfile.open(docker_tar, "w") as tar: 272 | tar.add(imgdir, arcname='/') 273 | logger.debug(f'Docker 镜像已拉取:{docker_tar}') 274 | return docker_tar 275 | except Exception as e: 276 | logger.error(f'打包镜像失败: {e}') 277 | raise 278 | 279 | def cleanup_tmp_dir(): 280 | """删除 tmp 目录""" 281 | tmp_dir = 'tmp' 282 | try: 283 | if os.path.exists(tmp_dir): 284 | logger.debug(f'清理临时目录: {tmp_dir}') 285 | shutil.rmtree(tmp_dir) 286 | logger.debug('临时目录已清理。') 287 | except Exception as e: 288 | logger.error(f'清理临时目录失败: {e}') 289 | 290 | def main(): 291 | """主函数""" 292 | try: 293 | parser = argparse.ArgumentParser(description="Docker 镜像拉取工具") 294 | parser.add_argument("-i", "--image", required=False, help="Docker 镜像名称(例如:nginx:latest 或 harbor.abc.com/abc/nginx:1.26.0)") 295 | parser.add_argument("-q", "--quiet", action="store_true", help="静默模式,减少交互") 296 | parser.add_argument("-r", "--custom_registry", help="自定义仓库地址(例如:harbor.abc.com)") 297 | parser.add_argument("-a", "--arch", help="架构,默认:amd64,常见:amd64, arm64v8等") 298 | parser.add_argument("-u", "--username", help="Docker 仓库用户名") 299 | parser.add_argument("-p", "--password", help="Docker 仓库密码") 300 | parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {VERSION}", help="显示版本信息") 301 | parser.add_argument("--debug", action="store_true", help="启用调试模式,打印请求 URL 和连接状态") 302 | 303 | # 显示程序的信息 304 | logger.info(f'欢迎使用 Docker 镜像拉取工具 {VERSION}') 305 | 306 | args = parser.parse_args() 307 | 308 | if args.debug: 309 | logger.setLevel(logging.DEBUG) 310 | 311 | # 获取镜像名称 312 | if not args.image: 313 | args.image = input("请输入 Docker 镜像名称(例如:nginx:latest 或 harbor.abc.com/abc/nginx:1.26.0):").strip() 314 | if not args.image: 315 | logger.error("错误:镜像名称是必填项。") 316 | return 317 | 318 | # # 获取架构 319 | # if not args.arch and not args.quiet: 320 | # args.arch = input("请输入架构(常见: amd64, arm64v8等,默认: amd64):").strip() or 'amd64' 321 | 322 | # 获取自定义仓库地址 323 | if not args.custom_registry and not args.quiet: 324 | # use_custom_registry = input("是否使用自定义仓库地址?(y/n, 默认: y): ").strip().lower() or 'y' 325 | # if use_custom_registry == 'y': 326 | # args.custom_registry = input("请输入自定义仓库地址: )").strip() 327 | args.custom_registry = input("请输入自定义仓库地址: (默认 dockerhub)").strip() 328 | 329 | # 解析镜像信息 330 | registry, repository, img, tag = parse_image_input(args) 331 | 332 | # 获取认证信息 333 | if not args.username and not args.quiet: 334 | args.username = input("请输入镜像仓库用户名: ").strip() 335 | if not args.password and not args.quiet: 336 | args.password = input("请输入镜像仓库密码: ").strip() 337 | session = create_session() 338 | auth_head = None 339 | try: 340 | url = f'https://{registry}/v2/' 341 | logger.debug(f"获取认证信息 CURL 命令: curl '{url}'") 342 | resp = session.get(url, verify=False, timeout=30) 343 | auth_url = resp.headers['WWW-Authenticate'].split('"')[1] 344 | reg_service = resp.headers['WWW-Authenticate'].split('"')[3] 345 | auth_head = get_auth_head(session, auth_url, reg_service, repository, args.username, args.password) 346 | # 获取清单 347 | resp, http_code = fetch_manifest(session, registry, repository, tag, auth_head) 348 | if http_code == 401: 349 | use_auth = input(f"当前仓库 {registry},需要登录?(y/n, 默认: y): ").strip().lower() or 'y' 350 | if use_auth == 'y': 351 | args.username = input("请输入用户名: ").strip() 352 | args.password = input("请输入密码: ").strip() 353 | auth_head = get_auth_head(session, auth_url, reg_service, repository, args.username, args.password) 354 | 355 | resp, http_code = fetch_manifest(session, registry, repository, tag, auth_head) 356 | except requests.exceptions.RequestException as e: 357 | logger.error(f'连接仓库失败: {e}') 358 | raise 359 | 360 | resp_json = resp.json() 361 | 362 | # 处理多架构镜像 363 | manifests = resp_json.get('manifests') 364 | if manifests is not None: 365 | archs = [m.get('annotations', {}).get('com.docker.official-images.bashbrew.arch') or 366 | m.get('platform',{}).get('architecture') 367 | for m in manifests if m.get('platform',{}).get('os') == 'linux'] 368 | 369 | # 打印架构列表 370 | if archs: 371 | logger.debug(f'当前可用架构:{", ".join(archs)}') 372 | 373 | if len(archs) == 1: 374 | args.arch = archs[0] 375 | logger.info(f'自动选择唯一可用架构: {args.arch}') 376 | 377 | # 获取架构 378 | if not args.arch or args.arch not in archs: 379 | args.arch = input(f"请输入架构(可选: {', '.join(archs)},默认: amd64):").strip() or 'amd64' 380 | 381 | digest = select_manifest(manifests, args.arch) 382 | if not digest: 383 | logger.error(f'在清单中找不到指定的架构 {args.arch}') 384 | return 385 | 386 | # 构造请求 387 | url = f'https://{registry}/v2/{repository}/manifests/{digest}' 388 | headers = ' '.join([f"-H '{key}: {value}'" for key, value in auth_head.items()]) 389 | curl_command = f"curl '{url}' {headers}" 390 | logger.debug(f'获取架构清单 CURL 命令: {curl_command}') 391 | 392 | # 获取清单 393 | manifest_resp = session.get(url, headers=auth_head, verify=False, timeout=30) 394 | try: 395 | manifest_resp.raise_for_status() 396 | resp_json = manifest_resp.json() 397 | except Exception as e: 398 | logger.error(f'获取架构清单失败: {e}') 399 | return 400 | 401 | if 'layers' not in resp_json: 402 | logger.error('错误:清单中没有层') 403 | return 404 | 405 | 406 | logger.info(f'仓库地址:{registry}') 407 | logger.info(f'镜像:{repository}') 408 | logger.info(f'标签:{tag}') 409 | logger.info(f'架构:{args.arch}') 410 | 411 | # 下载镜像层 412 | imgdir = 'tmp' 413 | os.makedirs(imgdir, exist_ok=True) 414 | logger.info('开始下载') 415 | 416 | # 根据镜像类型,提供正确的imgparts 417 | if registry == 'registry-1.docker.io' and repository.startswith('library/'): 418 | # Docker Hub 419 | imgparts = [] # 官方镜像不需要前缀 420 | else: 421 | # 422 | imgparts = repository.split('/')[:-1] 423 | 424 | download_layers(session, registry, repository, resp_json['layers'], auth_head, imgdir, resp_json, imgparts, img, tag) 425 | 426 | # 打包镜像 427 | output_file = create_image_tar(imgdir, repository, tag, args.arch) 428 | logger.info(f'镜像已保存为: {output_file}') 429 | logger.info(f'可使用以下命令导入镜像: docker load -i {output_file}') 430 | if registry not in ("registry-1.docker.io", "docker.io"): 431 | logger.info(f'您可能需要: docker tag {repository}:{tag} {registry}/{repository}:{tag}') 432 | 433 | 434 | 435 | except KeyboardInterrupt: 436 | logger.info('用户取消操作。') 437 | except requests.exceptions.RequestException as e: 438 | logger.error(f'网络连接失败: {e}') 439 | except json.JSONDecodeError as e: 440 | logger.error(f'JSON解析失败: {e}') 441 | except FileNotFoundError as e: 442 | logger.error(f'文件操作失败: {e}') 443 | except argparse.ArgumentError as e: 444 | logger.error(f'命令行参数错误: {e}') 445 | except Exception as e: 446 | logger.error(f'程序运行过程中发生异常: {e}') 447 | import traceback 448 | logger.debug(traceback.format_exc()) 449 | 450 | finally: 451 | cleanup_tmp_dir() 452 | input("按任意键退出程序...") 453 | sys.exit(0) 454 | 455 | if __name__ == '__main__': 456 | main() -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topcss/docker-pull-tar/05cba8226750a359536351d34aee68d6ae157aa4/favicon.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2025.1.31 2 | charset-normalizer==3.4.1 3 | idna==3.10 4 | requests==2.32.3 5 | tqdm==4.67.1 6 | urllib3==2.3.0 7 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | # UTF-8 2 | VSVersionInfo( 3 | ffi=FixedFileInfo( 4 | filevers=(1, 0, 7, 0), 5 | prodvers=(1, 0, 7, 0), 6 | mask=0x3f, 7 | flags=0x0, 8 | OS=0x4, 9 | fileType=0x1, 10 | subtype=0x0, 11 | date=(0, 0) 12 | ), 13 | kids=[ 14 | StringFileInfo( 15 | [ 16 | StringTable( 17 | '040904B0', 18 | [ 19 | StringStruct('CompanyName', 'topcss'), 20 | StringStruct('FileDescription', 'Docker Image Puller 无需安装 Docker 或 Python 环境,直接从 Docker 仓库拉取镜像,支持国内镜像源加速和多架构支持。项目地址:https://github.com/topcss/docker-pull-tar'), 21 | StringStruct('FileVersion', '1.0.7.0'), 22 | StringStruct('InternalName', 'DockerPull'), 23 | StringStruct('LegalCopyright', 'MIT License | GitHub: https://github.com/topcss/docker-pull-tar'), 24 | StringStruct('OriginalFilename', 'DockerPull.exe'), 25 | StringStruct('ProductName', 'DockerPull'), 26 | StringStruct('ProductVersion', '1.0.7.0') 27 | ] 28 | ) 29 | ] 30 | ), 31 | VarFileInfo([VarStruct('Translation', [1033, 1200])]) 32 | ] 33 | ) -------------------------------------------------------------------------------- /截图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topcss/docker-pull-tar/05cba8226750a359536351d34aee68d6ae157aa4/截图.png --------------------------------------------------------------------------------