前言
使用harbor过程中,一直想使用harbor的api实现镜像的上传功能,但是实际上harbor是直接调用了docker registry的api,harbor层只是做了一个透传的功能,这个可以参考《harbor权威指南》这本书,参考官网接口以及网上大佬的思想,实现了一个Java版本,主要是实现了docker daemon上传的逻辑。
docker镜像tar包结构
实现上传首先需要将镜像的tar包解压,读取目录结构,一个典型的docker镜像包(使用docker save
命令)结构如下:
.
├── 1ecf8bc84a7c3d60c0a6bbdd294f12a6b0e17a8269616fc9bdbedd926f74f50c
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── 6f4ec1f3d7ea33646d491a705f94442f5a706e9ac9acbca22fa9b117094eb720.json
├── aaac5bde2c2bcb6cc28b1e6d3f29fe13efce6d6b669300cc2c6bfab96b942af4
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── b63363f0d2ac8b3dca6f903bb5a7301bf497b1e5be8dc4f57a14e4dc649ef9bb
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── c453224a84b8318b0a09a83052314dd876899d3a1a1cf2379e74bba410415059
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── dd8ef1d42fbcccc87927eee94e57519c401b84437b98fcf35505fb6b7267a375
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── manifest.json
└── repositories
清单文件manifet.json结构
[
{
"Config":"6f4ec1f3d7ea33646d491a705f94442f5a706e9ac9acbca22fa9b117094eb720.json",
"RepoTags":[
"alpine:filebeat-6.8.7-arm64"
],
"Layers":[
"aaac5bde2c2bcb6cc28b1e6d3f29fe13efce6d6b669300cc2c6bfab96b942af4/layer.tar",
"dd8ef1d42fbcccc87927eee94e57519c401b84437b98fcf35505fb6b7267a375/layer.tar",
"c453224a84b8318b0a09a83052314dd876899d3a1a1cf2379e74bba410415059/layer.tar",
"b63363f0d2ac8b3dca6f903bb5a7301bf497b1e5be8dc4f57a14e4dc649ef9bb/layer.tar",
"1ecf8bc84a7c3d60c0a6bbdd294f12a6b0e17a8269616fc9bdbedd926f74f50c/layer.tar"
]
}
]
manifest.json 包含了对这个tar包的描述信息,比如image config文件地址,tags说明,镜像layer信息,在解析的时候也是根据这个文件去获取关联的文件
Doker Registry api
上传过程主要调用docker registry api</br>
参考https://docs.docker.com/registry/spec/api/#pushing-an-image
上传流程
- 获取鉴权信息
- 检查layer.tar是否已经存在
- 上传layer.tar
- 上传image config
- 上传manifest(非包中的manifest.json而是Manifest struct)
Java实现
-
项目结构如下
核心实现类DockerImageUploadBiz.java
/**
* @author Administrator
*/
@Service
@Slf4j
public class DockerImageUploadBiz {
//远程仓库
@Value("${docker.remote.repo}")
private String targetRepoAddress;
//本地解压路径
@Value("${docker.upload.extractPath}")
private String tarPath;
//harbor用户名
@Value("${docker.harbor.userName}")
private String userName;
//密码
@Value("${docker.harbor.password}")
private String password;
/**
* @param sourceTar 上传的镜像文件
* @param project 项目
*/
public void push(File sourceTar, String project) {
if (!sourceTar.exists()) {
log.warn("Error!file is not exist!path:{}", sourceTar);
return;
}
try {
String unTarPath = FileUtil.doUnArchiver(sourceTar, tarPath);
String manifest = FileUtil.readJsonFile(unTarPath + File.separator + "manifest.json");
JSONArray jsonArray = JSONObject.parseArray(manifest);
if (Objects.isNull(jsonArray)) {
log.warn("manifest convert error!path:{},content:{}", unTarPath + File.separator + "manifest.json", manifest);
return;
}
for (Object arr : jsonArray) {
JSONObject jsonObject = (JSONObject) arr;
JSONArray repoTags = jsonObject.getJSONArray("RepoTags");
for (Object repoTag : repoTags) {
String repo = repoTag.toString();
String substring = repo.substring(repo.lastIndexOf('/') + 1);
String[] split = substring.split(":");
String imageName = split[0];
String tag = split[1];
log.info("imageName:{},tag:{}", imageName, tag);
JSONArray layers = jsonObject.getJSONArray("Layers");
//1.上传layer
log.info("========================STEP:1/3===============================");
log.info("PUSHING LAYERS STARTING...");
List<String> layerPathList = new ArrayList<>(layers.size());
int i = 1;
for (Object layer : layers) {
String layerPath = unTarPath + File.separator + layer.toString();
log.info("PUSHING LAYER:{}-{} ...", i, layers.size());
layerPathList.add(layerPath);
pushLayer(project, layerPath, imageName);
i++;
}
log.info("PUSHING LAYERS ENDED...");
log.info("========================STEP:2/3===============================");
//2.上传config
log.info("PUSHING CONFIG STARTING...");
String config = jsonObject.getString("Config");
String configPath = unTarPath + File.separator + config;
pushingConfig(project, configPath, imageName);
log.info("PUSHING CONFIG ENDED...");
log.info("========================STEP:3/3===============================");
//3.上传manifest
log.info("PUSHING MANIFEST STARTING...");
pushingManifest(project, layerPathList, configPath, imageName, tag);
log.info("PUSHING MANIFEST ENDED...");
log.info("PUSHING {} COMPLETED!", repo);
}
}
} catch (Exception e) {
log.error("", e);
}
}
/**
* 上传镜像层
*
* @param layerPath 层路径
* @param imageName 镜像名称
*/
private void pushLayer(String project, String layerPath, String imageName) throws Exception {
File layerFile = new File(layerPath);
boolean layerExist = checkLayerExist(project, layerFile, imageName);
if (layerExist) {
log.info("LAYER ALREADY EXISTS! LAYER PATH:{}", layerPath);
return;
}
String location = startingPush(project, imageName);
chunkPush(layerFile, location);
// monolithicPush(layerFile,location);
}
/**
* 判断层是否存在
*
* @param layer 层
* @param imageName 镜像名称
* @return true:存在,false:不存在
*/
private boolean checkLayerExist(String project, File layer, String imageName) throws Exception {
String hash256 = FileUtil.hash256(layer);
String url = String
.format("%s/v2/%s/blobs/%s", targetRepoAddress, project + "/" + imageName, "sha256:" + hash256);
Response response = OkHttpClientUtil.headOkHttp(url);
return response.code() == HttpStatus.OK.value();
}
/**
* 开始上传
*
* @param imageName 镜像名称
*/
private String startingPush(String project, String imageName) throws IOException {
String url = String.format("%s/v2/%s/blobs/uploads/", targetRepoAddress, project + "/" + imageName);
Response response = OkHttpClientUtil.postOkHttp(url, RequestBody.create(null, ""));
if (response.code() == HttpStatus.ACCEPTED.value()) {
return response.header("location");
}
return "";
}
/**
* 分块上传
*/
private void chunkPush(File layerFile, String url) throws Exception {
long length = layerFile.length();
log.info("file size:{}", length);
//10M
int len = 1024 * 1024 * 5;
byte[] chunk = new byte[len];
int offset = 0;
int index = 0;
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
while (true) {
byte[] blocks = FileUtil.getBlock(offset, layerFile, chunk.length);
if (Objects.isNull(blocks)) {
log.warn("File block is null!");
break;
}
offset += blocks.length;
messageDigest.update(blocks);
log.info("pushing range:[{}-{}]... {}%", index, offset, String.format("%.2f", (float) offset / (float) length * 100));
if (offset == length) {
String hash256 = FileUtil.byte2Hex(messageDigest.digest());
url = String.format("%s&digest=sha256:%s", url, hash256);
Response response = OkHttpClientUtil.putOkHttp(url, index, offset, blocks);
if (response.code() != HttpStatus.CREATED.value()) {
log.error("chunk push error!code:{},digest:{},{}", response.code(), hash256, response.body().string());
throw new RuntimeException("chunk push error");
}
response.close();
break;
} else {
Response response = OkHttpClientUtil.patchOkHttp(url, index, offset, blocks);
if (response.code() != HttpStatus.ACCEPTED.value()) {
log.error("patch error!code:{},response:{}", response.code(), response.body().string());
throw new RuntimeException("patch error!");
}
url = response.header("location");
}
index = offset;
}
}
/**
* 整块上传
*
* @param layer
*/
private void monolithicPush(File layer, String url) throws Exception {
byte[] contents = FileUtils.readFileToByteArray(layer);
String hash256 = FileUtil.hash256(layer);
url = url + "&digest=sha256:" + hash256;
Response response = OkHttpClientUtil.putOkHttp(url, contents);
if (response.code() != HttpStatus.CREATED.value()) {
log.error("monolithicPush error!code:{},{}", response.code(), response.body().string());
throw new RuntimeException("monolithicPush error!");
}
}
/**
* 上传config
*
* @param configPath 路径
* @param imageName 镜像名称
* @throws Exception 异常
*/
private void pushingConfig(String project, String configPath, String imageName) throws Exception {
File file = new File(configPath);
if (checkLayerExist(project, file, imageName)) {
log.warn("{} exists!", configPath);
return;
}
log.info("start pushing config...");
String url = startingPush(project, imageName);
monolithicPush(file, url);
log.info("config:{} upload success!", configPath);
}
/**
* 上传manifest清单
*
* @param layerArrays
* @param configPath
* @param tag
* @throws Exception
*/
private void pushingManifest(String project, List<String> layerArrays, String configPath, String imageName, String tag) throws Exception {
ManifestV2 manifestV2 = new ManifestV2()
.setMediaType("application/vnd.docker.distribution.manifest.v2+json")
.setSchemaVersion(2);
File configFile = new File(configPath);
String hash256 = FileUtil.hash256(configFile);
Config config = new Config()
.setMediaType("application/vnd.docker.container.image.v1+json")
.setDigest("sha256:" + hash256)
.setSize((int) configFile.length());
manifestV2.setConfig(config);
List<Layer> layers = layerArrays.stream()
.map(layerPath -> {
File layerFile = new File(layerPath);
Layer layer = new Layer();
String hash2561 = FileUtil.hash256(layerFile);
layer.setDigest("sha256:" + hash2561);
layer.setMediaType("application/vnd.docker.image.rootfs.diff.tar");
layer.setSize((int) layerFile.length());
return layer;
}).collect(Collectors.toList());
manifestV2.setLayers(layers);
String manifestStr = JSON.toJSONString(manifestV2);
// System.out.println(manifestStr);
String url = String.format("%s/v2/%s/manifests/%s", targetRepoAddress, project + "/" + imageName, tag);
Response response = OkHttpClientUtil.putManifestOkHttp(url, manifestStr.getBytes(StandardCharsets.UTF_8));
if (response.code() != HttpStatus.CREATED.value()) {
log.error("upload manifest error!,code:{},response:{}", response.code(), response.body().string());
return;
}
// response.close();
log.info("manifest upload success!");
}
}
- FileUtil.java实现了文件的解压以及获取文件sha256等方法
/**
* @author Administrator
*/
public class FileUtil {
/**
* 解压tar
*
* @param sourceFile
* @param destPath
* @throws Exception
*/
public static String doUnArchiver(File sourceFile, String destPath)
throws Exception {
byte[] buf = new byte[1024];
FileInputStream fis = new FileInputStream(sourceFile);
BufferedInputStream bis = new BufferedInputStream(fis);
TarArchiveInputStream tais = new TarArchiveInputStream(bis);
TarArchiveEntry tae = null;
destPath = createTempDirIfNotExist(sourceFile.getName(), destPath);
while ((tae = tais.getNextTarEntry()) != null) {
File f = new File(destPath + "/" + tae.getName());
if (tae.isDirectory()) {
f.mkdirs();
} else {
/*
* 父目录不存在则创建
*/
File parent = f.getParentFile();
if (!parent.exists()) {
parent.mkdirs();
}
FileOutputStream fos = new FileOutputStream(f);
BufferedOutputStream bos = new BufferedOutputStream(fos);
int len;
while ((len = tais.read(buf)) != -1) {
bos.write(buf, 0, len);
}
bos.flush();
bos.close();
}
}
tais.close();
return destPath;
}
/**
* 创建临时目录
*
* @param pathName
* @param basePath
*/
private static synchronized String createTempDirIfNotExist(String pathName, String basePath) {
String dir;
if (pathName.contains(".")) {
String[] split = pathName.split("\\.");
dir = basePath + File.separator + split[0];
} else {
dir = basePath + File.separator + pathName;
}
File file = new File(dir);
if (!file.exists()) {
file.mkdirs();
}
return dir;
}
/**
* 读取json文件
*
* @param fileName
* @return
*/
public static String readJsonFile(String fileName) {
try {
File jsonFile = new File(fileName);
Reader reader = new InputStreamReader(new FileInputStream(jsonFile), StandardCharsets.UTF_8);
return IOUtils.toString(reader);
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
public static String hash256(File file) {
try (InputStream fis = new FileInputStream(file)) {
byte[] buffer = new byte[4096];
MessageDigest md5 = MessageDigest.getInstance("SHA-256");
for (int numRead = 0; (numRead = fis.read(buffer)) > 0; ) {
md5.update(buffer, 0, numRead);
}
return byte2Hex(md5.digest());
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
/**
* 将byte转为16进制
*
* @param bytes 要转换的bytes
* @return 16进制String
*/
public static String byte2Hex(byte[] bytes) {
StringBuilder stringBuffer = new StringBuilder();
String temp;
for (byte b : bytes) {
temp = Integer.toHexString(b & 0xFF);
if (temp.length() == 1) {
// 1得到一位的进行补0操作
stringBuffer.append("0");
}
stringBuffer.append(temp);
}
return stringBuffer.toString();
}
/**
* 文件分块工具
*
* @param offset 起始偏移位置
* @param file 文件
* @param blockSize 分块大小
* @return 分块数据
*/
public static byte[] getBlock(long offset, File file, int blockSize) {
byte[] result = new byte[blockSize];
try (RandomAccessFile accessFile = new RandomAccessFile(file, "r")) {
accessFile.seek(offset);
int readSize = accessFile.read(result);
if (readSize == -1) {
return null;
} else if (readSize == blockSize) {
return result;
} else {
byte[] tmpByte = new byte[readSize];
System.arraycopy(result, 0, tmpByte, 0, readSize);
return tmpByte;
}
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
- OkHttpClientUtil.java实现了http相关方法
/**
* @author Administrator
*/
@Slf4j
public class OkHttpClientUtil {
private static final TrustAllManager trustAllManager = new TrustAllManager();
private static final OkHttpClient okHttpClient = new OkHttpClient.Builder()
.authenticator((route, response) -> {
//用户名、密码
String credential = Credentials.basic("admin", "password", StandardCharsets.UTF_8);
return response.request().newBuilder()
.header("Authorization", credential)
.build();
})
.connectionPool(new ConnectionPool(10,1, TimeUnit.MINUTES))
.sslSocketFactory(createTrustAllSSLFactory(trustAllManager),trustAllManager)
// .proxy(new Proxy(Proxy.Type.HTTP,new InetSocketAddress("127.0.0.1",8888)))
.writeTimeout(60,TimeUnit.MINUTES)
.build();
protected static SSLSocketFactory createTrustAllSSLFactory(TrustAllManager trustAllManager) {
SSLSocketFactory ssfFactory = null;
try {
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, new TrustManager[]{trustAllManager}, new SecureRandom());
ssfFactory = sc.getSocketFactory();
} catch (Exception ignored) {
ignored.printStackTrace();
}
return ssfFactory;
}
/**
* get请求
*
* @param url
*/
public static Response getOkHttp(String url) throws IOException {
Request request = new Request.Builder()
.url(url)
.get()
.build();
return okHttpClient.newCall(request).execute();
}
/**
* get请求
*
* @param url
*/
public static Response headOkHttp(String url) throws IOException {
Request request = new Request.Builder()
.url(url)
.head()
.build();
return okHttpClient.newCall(request).execute();
}
/**
* post请求
*
* @param url
* @param body
*/
public static Response postOkHttp(String url, RequestBody body)
throws IOException {
Request request = new Request.Builder()
.url(url)
.post(body)
.build();
return okHttpClient.newCall(request).execute();
}
/**
* patch方式
*
* @param url
* @return
* @throws IOException
*/
public static Response patchOkHttp(String url, int index, int offset, byte[] buffer)
throws IOException {
MediaType mediaType = MediaType.parse("application/octet-stream");
RequestBody body = RequestBody.create(mediaType, buffer);
Request request = new Builder()
.url(url)
.patch(body)
.header("Content-Type", "application/octet-stream")
.header("Content-Length", String.valueOf(buffer.length))
.header("Content-Range", String.format("%s-%s", index, offset))
.build();
return okHttpClient.newCall(request).execute();
}
/**
* put方式
*
* @param url
* @param index
* @param offset
* @param buffer
* @return
* @throws IOException
*/
public static Response putOkHttp(String url, int index, int offset, byte[] buffer)
throws IOException {
MediaType mediaType = MediaType.parse("application/octet-stream");
RequestBody body = RequestBody.create(mediaType, buffer);
Request request = new Builder()
.url(url)
.put(body)
.header("Content-Type", "application/octet-stream")
.header("Content-Length", String.valueOf(buffer.length))
.header("Content-Range", String.format("%s-%s", index, offset))
.build();
return okHttpClient.newCall(request).execute();
}
/**
* put方式
*
* @param url
* @param buffer
* @return
* @throws IOException
*/
public static Response putOkHttp(String url, byte[] buffer)
throws IOException {
MediaType mediaType = MediaType.parse("application/octet-stream");
RequestBody body = RequestBody.create(mediaType, buffer);
Request request = new Builder()
.url(url)
.put(body)
.header("Content-Type", "application/octet-stream")
.header("Content-Length", String.valueOf(buffer.length))
.build();
return okHttpClient.newCall(request).execute();
}
/**
* put方式
*
* @param url
* @param buffers
* @return
* @throws IOException
*/
public static Response putManifestOkHttp(String url, byte[] buffers)
throws IOException {
MediaType mediaType = MediaType.parse("application/vnd.docker.distribution.manifest.v2+json");
RequestBody body = RequestBody.create(mediaType, buffers);
Request request = new Builder()
.url(url)
.put(body)
.header("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
.build();
return okHttpClient.newCall(request).execute();
}
}
class TrustAllManager implements X509TrustManager{
@Override
public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
}
@Override
public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
- 清单文件结构ManifestV2.java
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@Accessors(chain = true)
public class ManifestV2 {
private Integer schemaVersion;
private String mediaType;
private Config config;
private List<Layer> layers;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@Accessors(chain = true)
public class Config {
private String mediaType;
private Integer size;
private String digest;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class Layer {
private String mediaType;
private Integer size;
private String digest;
}
- controller层
@PostMapping("fileUpload")
public String fileUpload2(@RequestParam("file") MultipartFile file, String project) throws IOException {
long startTime = System.currentTimeMillis();
String path = "C:\\test" + File.separator + file.getOriginalFilename();
File newFile = new File(path);
//通过CommonsMultipartFile的方法直接写文件(注意这个时候)
file.transferTo(newFile);
long endTime = System.currentTimeMillis();
log.info("file:{} upload success,size:{} byte,spend time:{} ms", newFile.getName(), newFile.length(), endTime - startTime);
log.info("start to push registry...");
dockerImageUploadBiz.push(newFile, project);
return "/success";
}
-
调用方式--postman
pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>docker_images_push</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.6.RELEASE</version>
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.20</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.54</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>3.8.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.4</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.1.6.RELEASE</version>
</plugin>
</plugins>
</build>
</project>
以上。
引用: