0%

简析 crane pull image 过程

详细过程在内网写了,粗略过程罗列于此

以 docker hub registry 为例

以 crane 二进制工具为参考

https://github.com/google/go-containerregistry/blob/master/cmd/crane/doc/crane.md

google 的同事真是为世界造轮子,many thanks

Target

从 registry 下载 nginx:latest 镜像

1
docker pull nginx:latest

Auth

https://docs.docker.com/registry/spec/auth/token/#requesting-a-token

  1. ping registry
1
2
3
4
5
6
7
8
curl -i https://registry.hub.docker.com/v2/
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Docker-Distribution-Api-Version: registry/2.0
Www-Authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io"
Date: Sat, 17 Nov 2018 01:27:49 GMT
Content-Length: 87
Strict-Transport-Security: max-age=31536000

在 Www-Authenticate 请求头中可见 Registry 使用 Bearer 认证方式

  1. refresh docker auth
1
2
3
4
5
6
7
curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/nginx:pull" | python -mjson.tool
{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUlDK2pDQ0FwK2dBd0lCQWdJQkFEQUtCZ2dxaGtqT1BRUURBakJHTVVRd1FnWURWUVFERXpzeVYwNVpPbFZMUzFJNlJFMUVVanBTU1U5Rk9reEhOa0U2UTFWWVZEcE5SbFZNT2tZelNFVTZOVkF5VlRwTFNqTkdPa05CTmxrNlNrbEVVVEFlRncweE9EQXlNVFF5TXpBMk5EZGFGdzB4T1RBeU1UUXlNekEyTkRkYU1FWXhSREJDQmdOVkJBTVRPMVpCUTFZNk5VNWFNenBNTkZSWk9sQlFTbGc2VWsxQlZEcEdWalpQT2xZMU1sTTZRa2szV2pwU1REVk9PbGhXVDBJNlFsTmFSanBHVTFRMk1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBMGtyTmgyZWxESnVvYjVERWd5Wi9oZ3l1ZlpxNHo0OXdvNStGRnFRK3VPTGNCMDRyc3N4cnVNdm1aSzJZQ0RSRVRERU9xNW5keEVMMHNaTE51UXRMSlNRdFY1YUhlY2dQVFRkeVJHUTl2aURPWGlqNFBocE40R0N0eFV6YTNKWlNDZC9qbm1YbmtUeDViOElUWXBCZzg2TGNUdmMyRFVUV2tHNy91UThrVjVPNFFxNlZKY05TUWRId1B2Mmp4YWRZa3hBMnhaaWNvRFNFQlpjWGRneUFCRWI2YkRnUzV3QjdtYjRRVXBuM3FXRnRqdCttKzBsdDZOR3hvenNOSFJHd3EwakpqNWtZbWFnWHpEQm5NQ3l5eDFBWFpkMHBNaUlPSjhsaDhRQ09GMStsMkVuV1U1K0thaTZKYVNEOFZJc2VrRzB3YXd4T1dER3U0YzYreE1XYUx3SURBUUFCbzRHeU1JR3ZNQTRHQTFVZER3RUIvd1FFQXdJSGdEQVBCZ05WSFNVRUNEQUdCZ1JWSFNVQU1FUUdBMVVkRGdROUJEdFdRVU5XT2pWT1dqTTZURFJVV1RwUVVFcFlPbEpOUVZRNlJsWTJUenBXTlRKVE9rSkpOMW82VWt3MVRqcFlWazlDT2tKVFdrWTZSbE5VTmpCR0JnTlZIU01FUHpBOWdEc3lWMDVaT2xWTFMxSTZSRTFFVWpwU1NVOUZPa3hITmtFNlExVllWRHBOUmxWTU9rWXpTRVU2TlZBeVZUcExTak5HT2tOQk5sazZTa2xFVVRBS0JnZ3Foa2pPUFFRREFnTkpBREJHQWlFQWdZTWF3Si9uMXM0dDlva0VhRjh2aGVkeURzbERObWNyTHNRNldmWTFmRTRDSVFEbzNWazJXcndiSjNmU1dwZEVjT3hNazZ1ZEFwK2c1Nkd6TjlRSGFNeVZ1QT09Il19.eyJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6ImxpYnJhcnkvbmdpbngiLCJhY3Rpb25zIjpbInB1bGwiXX1dLCJhdWQiOiJyZWdpc3RyeS5kb2NrZXIuaW8iLCJleHAiOjE1NDI0MTg2MjgsImlhdCI6MTU0MjQxODMyOCwiaXNzIjoiYXV0aC5kb2NrZXIuaW8iLCJqdGkiOiI1SG5JQllqVkluZERFYlRkRlUzOSIsIm5iZiI6MTU0MjQxODAyOCwic3ViIjoiIn0.zzVk9t-govqoyzQCwHfivOAkdIG0D6r5RoMS7HRq4vOBj1bQdASOfB6YqVLGWP6G-4cf6ESCDTxdidREgZYnklpApX7dYdrAf6OpxA5HXP5MYDMTE7PEZueoUpBipz0UsPI4lzMC1j80UjjgTVHyjiIMcwgxPXpT6-zPJJFp9EjDrLsBHtj2cdmPv_54KA0j50VQLZKccUvC67z0iT5KpSRvKyFcWLEActeCnmuZjkJgySmaVduVfLiDLFbboBOw0mNLeTFIodfHoEdFqYBooBK1d_x37GFCSunYH8fFZ0XkfS7OFDyaYiOlQzafbnz0TxLIUU-jOEsJkaofOnHF2Q",
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUlDK2pDQ0FwK2dBd0lCQWdJQkFEQUtCZ2dxaGtqT1BRUURBakJHTVVRd1FnWURWUVFERXpzeVYwNVpPbFZMUzFJNlJFMUVVanBTU1U5Rk9reEhOa0U2UTFWWVZEcE5SbFZNT2tZelNFVTZOVkF5VlRwTFNqTkdPa05CTmxrNlNrbEVVVEFlRncweE9EQXlNVFF5TXpBMk5EZGFGdzB4T1RBeU1UUXlNekEyTkRkYU1FWXhSREJDQmdOVkJBTVRPMVpCUTFZNk5VNWFNenBNTkZSWk9sQlFTbGc2VWsxQlZEcEdWalpQT2xZMU1sTTZRa2szV2pwU1REVk9PbGhXVDBJNlFsTmFSanBHVTFRMk1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBMGtyTmgyZWxESnVvYjVERWd5Wi9oZ3l1ZlpxNHo0OXdvNStGRnFRK3VPTGNCMDRyc3N4cnVNdm1aSzJZQ0RSRVRERU9xNW5keEVMMHNaTE51UXRMSlNRdFY1YUhlY2dQVFRkeVJHUTl2aURPWGlqNFBocE40R0N0eFV6YTNKWlNDZC9qbm1YbmtUeDViOElUWXBCZzg2TGNUdmMyRFVUV2tHNy91UThrVjVPNFFxNlZKY05TUWRId1B2Mmp4YWRZa3hBMnhaaWNvRFNFQlpjWGRneUFCRWI2YkRnUzV3QjdtYjRRVXBuM3FXRnRqdCttKzBsdDZOR3hvenNOSFJHd3EwakpqNWtZbWFnWHpEQm5NQ3l5eDFBWFpkMHBNaUlPSjhsaDhRQ09GMStsMkVuV1U1K0thaTZKYVNEOFZJc2VrRzB3YXd4T1dER3U0YzYreE1XYUx3SURBUUFCbzRHeU1JR3ZNQTRHQTFVZER3RUIvd1FFQXdJSGdEQVBCZ05WSFNVRUNEQUdCZ1JWSFNVQU1FUUdBMVVkRGdROUJEdFdRVU5XT2pWT1dqTTZURFJVV1RwUVVFcFlPbEpOUVZRNlJsWTJUenBXTlRKVE9rSkpOMW82VWt3MVRqcFlWazlDT2tKVFdrWTZSbE5VTmpCR0JnTlZIU01FUHpBOWdEc3lWMDVaT2xWTFMxSTZSRTFFVWpwU1NVOUZPa3hITmtFNlExVllWRHBOUmxWTU9rWXpTRVU2TlZBeVZUcExTak5HT2tOQk5sazZTa2xFVVRBS0JnZ3Foa2pPUFFRREFnTkpBREJHQWlFQWdZTWF3Si9uMXM0dDlva0VhRjh2aGVkeURzbERObWNyTHNRNldmWTFmRTRDSVFEbzNWazJXcndiSjNmU1dwZEVjT3hNazZ1ZEFwK2c1Nkd6TjlRSGFNeVZ1QT09Il19.eyJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6ImxpYnJhcnkvbmdpbngiLCJhY3Rpb25zIjpbInB1bGwiXX1dLCJhdWQiOiJyZWdpc3RyeS5kb2NrZXIuaW8iLCJleHAiOjE1NDI0MTg2MjgsImlhdCI6MTU0MjQxODMyOCwiaXNzIjoiYXV0aC5kb2NrZXIuaW8iLCJqdGkiOiI1SG5JQllqVkluZERFYlRkRlUzOSIsIm5iZiI6MTU0MjQxODAyOCwic3ViIjoiIn0.zzVk9t-govqoyzQCwHfivOAkdIG0D6r5RoMS7HRq4vOBj1bQdASOfB6YqVLGWP6G-4cf6ESCDTxdidREgZYnklpApX7dYdrAf6OpxA5HXP5MYDMTE7PEZueoUpBipz0UsPI4lzMC1j80UjjgTVHyjiIMcwgxPXpT6-zPJJFp9EjDrLsBHtj2cdmPv_54KA0j50VQLZKccUvC67z0iT5KpSRvKyFcWLEActeCnmuZjkJgySmaVduVfLiDLFbboBOw0mNLeTFIodfHoEdFqYBooBK1d_x37GFCSunYH8fFZ0XkfS7OFDyaYiOlQzafbnz0TxLIUU-jOEsJkaofOnHF2Q",
"expires_in": 300,
"issued_at": "2018-11-17T01:32:08.367366757Z"
}

设置 token var

1
export token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUlDK2pDQ0FwK2dBd0lCQWdJQkFEQUtCZ2dxaGtqT1BRUURBakJHTVVRd1FnWURWUVFERXpzeVYwNVpPbFZMUzFJNlJFMUVVanBTU1U5Rk9reEhOa0U2UTFWWVZEcE5SbFZNT2tZelNFVTZOVkF5VlRwTFNqTkdPa05CTmxrNlNrbEVVVEFlRncweE9EQXlNVFF5TXpBMk5EZGFGdzB4T1RBeU1UUXlNekEyTkRkYU1FWXhSREJDQmdOVkJBTVRPMVpCUTFZNk5VNWFNenBNTkZSWk9sQlFTbGc2VWsxQlZEcEdWalpQT2xZMU1sTTZRa2szV2pwU1REVk9PbGhXVDBJNlFsTmFSanBHVTFRMk1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBMGtyTmgyZWxESnVvYjVERWd5Wi9oZ3l1ZlpxNHo0OXdvNStGRnFRK3VPTGNCMDRyc3N4cnVNdm1aSzJZQ0RSRVRERU9xNW5keEVMMHNaTE51UXRMSlNRdFY1YUhlY2dQVFRkeVJHUTl2aURPWGlqNFBocE40R0N0eFV6YTNKWlNDZC9qbm1YbmtUeDViOElUWXBCZzg2TGNUdmMyRFVUV2tHNy91UThrVjVPNFFxNlZKY05TUWRId1B2Mmp4YWRZa3hBMnhaaWNvRFNFQlpjWGRneUFCRWI2YkRnUzV3QjdtYjRRVXBuM3FXRnRqdCttKzBsdDZOR3hvenNOSFJHd3EwakpqNWtZbWFnWHpEQm5NQ3l5eDFBWFpkMHBNaUlPSjhsaDhRQ09GMStsMkVuV1U1K0thaTZKYVNEOFZJc2VrRzB3YXd4T1dER3U0YzYreE1XYUx3SURBUUFCbzRHeU1JR3ZNQTRHQTFVZER3RUIvd1FFQXdJSGdEQVBCZ05WSFNVRUNEQUdCZ1JWSFNVQU1FUUdBMVVkRGdROUJEdFdRVU5XT2pWT1dqTTZURFJVV1RwUVVFcFlPbEpOUVZRNlJsWTJUenBXTlRKVE9rSkpOMW82VWt3MVRqcFlWazlDT2tKVFdrWTZSbE5VTmpCR0JnTlZIU01FUHpBOWdEc3lWMDVaT2xWTFMxSTZSRTFFVWpwU1NVOUZPa3hITmtFNlExVllWRHBOUmxWTU9rWXpTRVU2TlZBeVZUcExTak5HT2tOQk5sazZTa2xFVVRBS0JnZ3Foa2pPUFFRREFnTkpBREJHQWlFQWdZTWF3Si9uMXM0dDlva0VhRjh2aGVkeURzbERObWNyTHNRNldmWTFmRTRDSVFEbzNWazJXcndiSjNmU1dwZEVjT3hNazZ1ZEFwK2c1Nkd6TjlRSGFNeVZ1QT09Il19.eyJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6ImxpYnJhcnkvbmdpbngiLCJhY3Rpb25zIjpbInB1bGwiXX1dLCJhdWQiOiJyZWdpc3RyeS5kb2NrZXIuaW8iLCJleHAiOjE1NDI0MTg2MjgsImlhdCI6MTU0MjQxODMyOCwiaXNzIjoiYXV0aC5kb2NrZXIuaW8iLCJqdGkiOiI1SG5JQllqVkluZERFYlRkRlUzOSIsIm5iZiI6MTU0MjQxODAyOCwic3ViIjoiIn0.zzVk9t-govqoyzQCwHfivOAkdIG0D6r5RoMS7HRq4vOBj1bQdASOfB6YqVLGWP6G-4cf6ESCDTxdidREgZYnklpApX7dYdrAf6OpxA5HXP5MYDMTE7PEZueoUpBipz0UsPI4lzMC1j80UjjgTVHyjiIMcwgxPXpT6-zPJJFp9EjDrLsBHtj2cdmPv_54KA0j50VQLZKccUvC67z0iT5KpSRvKyFcWLEActeCnmuZjkJgySmaVduVfLiDLFbboBOw0mNLeTFIodfHoEdFqYBooBK1d_x37GFCSunYH8fFZ0XkfS7OFDyaYiOlQzafbnz0TxLIUU-jOEsJkaofOnHF2Q

Pull

  1. Get Image Manifest
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
curl -v -H "Authorization: Bearer $token" -H "Accept: application/vnd.docker.distribution.manifest.v2+json" "https://registry.hub.docker.com/v2/library/nginx/manifests/latest"
resp

{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 6022,
"digest": "sha256:e81eb098537d6c4a75438eacc6a2ed94af74ca168076f719f3a0558bd24d646a"
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 22486277,
"digest": "sha256:a5a6f2f73cd8abbdc55d0df0d8834f7262713e87d6c8800ea3851f103025e0f0"
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 22204196,
"digest": "sha256:67da5fbcb7a04397eda35dccb073d8569d28de13172fbd569fbb7a3e30b5886b"
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 203,
"digest": "sha256:e82455fa5628738170735528c8db36567b5423ec59802a1e2c084ed42b082527"
}
]
}
  1. Get Image Config
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
145
146
curl -i -H "Authorization: Bearer $token" "https://registry.hub.docker.com/v2/library/nginx/blobs/sha256:e81eb098537d6c4a75438eacc6a2ed94af74ca168076f719f3a0558bd24d646a"
HTTP/1.1 307 Temporary Redirect
Content-Type: text/html; charset=utf-8
Docker-Distribution-Api-Version: registry/2.0
Location: https://production.cloudflare.docker.com/registry-v2/docker/registry/v2/blobs/sha256/e8/e81eb098537d6c4a75438eacc6a2ed94af74ca168076f719f3a0558bd24d646a/data?verify=1542423249-8ogA6RSAc3PlmtNd%2FOuiIuAUo3c%3D
Date: Sat, 17 Nov 2018 02:04:09 GMT
Content-Length: 244
Strict-Transport-Security: max-age=31536000
redirect

curl https://production.cloudflare.docker.com/registry-v2/docker/registry/v2/blobs/sha256/e8/e81eb098537d6c4a75438eacc6a2ed94af74ca168076f719f3a0558bd24d646a/data?verify=1542423249-8ogA6RSAc3PlmtNd%2FOuiIuAUo3c%3D | python -mjson.tool
config json file

{
"architecture": "amd64",
"config": {
"Hostname": "",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"ExposedPorts": {
"80/tcp": {}
},
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NGINX_VERSION=1.15.6-1~stretch",
"NJS_VERSION=1.15.6.0.2.5-1~stretch"
],
"Cmd": [
"nginx",
"-g",
"daemon off;"
],
"ArgsEscaped": true,
"Image": "sha256:eb4657966d3e92498b450e24969a0a2808f254ab44102f31674543f642e35ed7",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": [],
"Labels": {
"maintainer": "NGINX Docker Maintainers <docker-maint@nginx.com>"
},
"StopSignal": "SIGTERM"
},
"container": "d4fa15093ad8ad3df60d7403c1752a379503686e32a76b70771b3ea268ec5d66",
"container_config": {
"Hostname": "d4fa15093ad8",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"ExposedPorts": {
"80/tcp": {}
},
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NGINX_VERSION=1.15.6-1~stretch",
"NJS_VERSION=1.15.6.0.2.5-1~stretch"
],
"Cmd": [
"/bin/sh",
"-c",
"#(nop) ",
"CMD [\"nginx\" \"-g\" \"daemon off;\"]"
],
"ArgsEscaped": true,
"Image": "sha256:eb4657966d3e92498b450e24969a0a2808f254ab44102f31674543f642e35ed7",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": [],
"Labels": {
"maintainer": "NGINX Docker Maintainers <docker-maint@nginx.com>"
},
"StopSignal": "SIGTERM"
},
"created": "2018-11-16T13:32:10.147294787Z",
"docker_version": "17.06.2-ce",
"history": [
{
"created": "2018-11-15T22:45:06.938205528Z",
"created_by": "/bin/sh -c #(nop) ADD file:dab9baf938799c515ddce14c02f899da5992f0b76a432fa10a2338556a3cb04f in / "
},
{
"created": "2018-11-15T22:45:07.243453424Z",
"created_by": "/bin/sh -c #(nop) CMD [\"bash\"]",
"empty_layer": true
},
{
"created": "2018-11-16T13:31:11.175776557Z",
"created_by": "/bin/sh -c #(nop) LABEL maintainer=NGINX Docker Maintainers <docker-maint@nginx.com>",
"empty_layer": true
},
{
"created": "2018-11-16T13:31:11.487598267Z",
"created_by": "/bin/sh -c #(nop) ENV NGINX_VERSION=1.15.6-1~stretch",
"empty_layer": true
},
{
"created": "2018-11-16T13:31:11.783900832Z",
"created_by": "/bin/sh -c #(nop) ENV NJS_VERSION=1.15.6.0.2.5-1~stretch",
"empty_layer": true
},
{
"created": "2018-11-16T13:32:07.382613887Z",
"created_by": "/bin/sh -c set -x \t&& apt-get update \t&& apt-get install --no-install-recommends --no-install-suggests -y gnupg1 apt-transport-https ca-certificates \t&& \tNGINX_GPGKEY=573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62; \tfound=''; \tfor server in \t\tha.pool.sks-keyservers.net \t\thkp://keyserver.ubuntu.com:80 \t\thkp://p80.pool.sks-keyservers.net:80 \t\tpgp.mit.edu \t; do \t\techo \"Fetching GPG key $NGINX_GPGKEY from $server\"; \t\tapt-key adv --keyserver \"$server\" --keyserver-options timeout=10 --recv-keys \"$NGINX_GPGKEY\" && found=yes && break; \tdone; \ttest -z \"$found\" && echo >&2 \"error: failed to fetch GPG key $NGINX_GPGKEY\" && exit 1; \tapt-get remove --purge --auto-remove -y gnupg1 && rm -rf /var/lib/apt/lists/* \t&& dpkgArch=\"$(dpkg --print-architecture)\" \t&& nginxPackages=\" \t\tnginx=${NGINX_VERSION} \t\tnginx-module-xslt=${NGINX_VERSION} \t\tnginx-module-geoip=${NGINX_VERSION} \t\tnginx-module-image-filter=${NGINX_VERSION} \t\tnginx-module-njs=${NJS_VERSION} \t\" \t&& case \"$dpkgArch\" in \t\tamd64|i386) \t\t\techo \"deb https://nginx.org/packages/mainline/debian/ stretch nginx\" >> /etc/apt/sources.list.d/nginx.list \t\t\t&& apt-get update \t\t\t;; \t\t*) \t\t\techo \"deb-src https://nginx.org/packages/mainline/debian/ stretch nginx\" >> /etc/apt/sources.list.d/nginx.list \t\t\t\t\t\t&& tempDir=\"$(mktemp -d)\" \t\t\t&& chmod 777 \"$tempDir\" \t\t\t\t\t\t&& savedAptMark=\"$(apt-mark showmanual)\" \t\t\t\t\t\t&& apt-get update \t\t\t&& apt-get build-dep -y $nginxPackages \t\t\t&& ( \t\t\t\tcd \"$tempDir\" \t\t\t\t&& DEB_BUILD_OPTIONS=\"nocheck parallel=$(nproc)\" \t\t\t\t\tapt-get source --compile $nginxPackages \t\t\t) \t\t\t\t\t\t&& apt-mark showmanual | xargs apt-mark auto > /dev/null \t\t\t&& { [ -z \"$savedAptMark\" ] || apt-mark manual $savedAptMark; } \t\t\t\t\t\t&& ls -lAFh \"$tempDir\" \t\t\t&& ( cd \"$tempDir\" && dpkg-scanpackages . > Packages ) \t\t\t&& grep '^Package: ' \"$tempDir/Packages\" \t\t\t&& echo \"deb [ trusted=yes ] file://$tempDir ./\" > /etc/apt/sources.list.d/temp.list \t\t\t&& apt-get -o Acquire::GzipIndexes=false update \t\t\t;; \tesac \t\t&& apt-get install --no-install-recommends --no-install-suggests -y \t\t\t\t\t\t$nginxPackages \t\t\t\t\t\tgettext-base \t&& apt-get remove --purge --auto-remove -y apt-transport-https ca-certificates && rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/nginx.list \t\t&& if [ -n \"$tempDir\" ]; then \t\tapt-get purge -y --auto-remove \t\t&& rm -rf \"$tempDir\" /etc/apt/sources.list.d/temp.list; \tfi"
},
{
"created": "2018-11-16T13:32:08.778195069Z",
"created_by": "/bin/sh -c ln -sf /dev/stdout /var/log/nginx/access.log \t&& ln -sf /dev/stderr /var/log/nginx/error.log"
},
{
"created": "2018-11-16T13:32:09.22115772Z",
"created_by": "/bin/sh -c #(nop) EXPOSE 80/tcp",
"empty_layer": true
},
{
"created": "2018-11-16T13:32:09.696803649Z",
"created_by": "/bin/sh -c #(nop) STOPSIGNAL [SIGTERM]",
"empty_layer": true
},
{
"created": "2018-11-16T13:32:10.147294787Z",
"created_by": "/bin/sh -c #(nop) CMD [\"nginx\" \"-g\" \"daemon off;\"]",
"empty_layer": true
}
],
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:ef68f6734aa485edf13a8509fe60e4272428deaf63f446a441b79d47fc5d17d3",
"sha256:876456b964239fb297770341ec7e4c2630e42b64b7bbad5112becb1bd2c72795",
"sha256:9a8f339aeebe1e8bcef322376e1274360653fb802abd4b94c69ea45a54f71a2b"
]
}
}
  1. Get Image Layers

根据 Image Manifest Layers 下载 Image Layers

1
2
3
4
5
6
curl -i -H "Authorization: Bearer $token" -H "Accept: application/vnd.docker.distribution.manifest.v2+json" "https://registry.hub.docker.com/v2/library/nginx/blobs/sha256:a5a6f2f73cd8abbdc55d0df0d8834f7262713e87d6c8800ea3851f103025e0f0"
curl -o a5a6f2f73cd8abbdc55d0df0d8834f7262713e87d6c8800ea3851f103025e0f0.tar.gz https://production.cloudflare.docker.com/registry-v2/docker/registry/v2/blobs/sha256/a5/a5a6f2f73cd8abbdc55d0df0d8834f7262713e87d6c8800ea3851f103025e0f0/data?verify=1542424303-e8ERUR8oG%2BoBh41TGIpCy7iFeYg%3D
curl -i -H "Authorization: Bearer $token" -H "Accept: application/vnd.docker.distribution.manifest.v2+json" "https://registry.hub.docker.com/v2/library/nginx/blobs/sha256:67da5fbcb7a04397eda35dccb073d8569d28de13172fbd569fbb7a3e30b5886b"
curl -o 67da5fbcb7a04397eda35dccb073d8569d28de13172fbd569fbb7a3e30b5886b.tar.gz https://production.cloudflare.docker.com/registry-v2/docker/registry/v2/blobs/sha256/67/67da5fbcb7a04397eda35dccb073d8569d28de13172fbd569fbb7a3e30b5886b/data\?verify\=1542424505-PVOE52Er6OY7iKDTx5QZSZxI99I%3D
curl -i -H "Authorization: Bearer $token" -H "Accept: application/vnd.docker.distribution.manifest.v2+json" "https://registry.hub.docker.com/v2/library/nginx/blobs/sha256:e82455fa5628738170735528c8db36567b5423ec59802a1e2c084ed42b082527"
curl -o e82455fa5628738170735528c8db36567b5423ec59802a1e2c084ed42b082527.tar.gz https://production.cloudflare.docker.com/registry-v2/docker/registry/v2/blobs/sha256/e8/e82455fa5628738170735528c8db36567b5423ec59802a1e2c084ed42b082527/data\?verify\=1542424575-kXRXCIAWm%2FXyHHfrhI1yOIPt4FA%3D
  1. Generated Image Tar Archive Description (manifest.json)

根据 Config file 及 Layers files 的组织目录结构,生成 Image Tar Archive Manifest

1
2
3
4
5
6
7
8
9
10
11
12
13
[
{
"Config": "sha256:e81eb098537d6c4a75438eacc6a2ed94af74ca168076f719f3a0558bd24d646a",
"RepoTags": [
"index.docker.io/library/nginx:latest"
],
"Layers": [
"a5a6f2f73cd8abbdc55d0df0d8834f7262713e87d6c8800ea3851f103025e0f0.tar.gz",
"67da5fbcb7a04397eda35dccb073d8569d28de13172fbd569fbb7a3e30b5886b.tar.gz",
"e82455fa5628738170735528c8db36567b5423ec59802a1e2c084ed42b082527.tar.gz"
]
}
]
  1. Tar Archive File Structure Summary
1
2
3
4
5
6
7
.
├── 67da5fbcb7a04397eda35dccb073d8569d28de13172fbd569fbb7a3e30b5886b.tar.gz --- Layer file
├── a5a6f2f73cd8abbdc55d0df0d8834f7262713e87d6c8800ea3851f103025e0f0.tar.gz --- Layer file
├── e82455fa5628738170735528c8db36567b5423ec59802a1e2c084ed42b082527.tar.gz --- Layer file
├── manifest.json --- Image Tar Archive Description
└── sha256:e81eb098537d6c4a75438eacc6a2ed94af74ca168076f719f3a0558bd24d646a --- Image Config
0 directories, 5 files

在上一遍文章中我们 get 到了 crane pull 的过程,然而好奇的我仍然有个疑问,那就是 tar archive 中并未定义了 layer 的 stack 关系,如此 docker load 如何能正确组织 image 的 fs changeset 呢 ?

docker load

https://docs.docker.com/engine/reference/commandline/load/

docker (moby)

https://github.com/moby/moby

Layer 是如何串联起来的

https://www.hi-linux.com/posts/44544.html

Study

按说理论上每层,应有镜像层 id,并且有指向 parent layer 的指针,当然基础镜像没有 parent layer

否则无法将 layer 组织起来

既然如此那在 image tar archive 中,是如何体现这种 layer stack 关系的 ?

docker save

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── 2daafd635a629218204652bd3b10ddd23ae5e33abe1ebc3c26c01103e33369de
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── 9fbc75679caed833594370d2effbdbba4e09eb6ee7a87f9d2e94b41627d56831
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── e81eb098537d6c4a75438eacc6a2ed94af74ca168076f719f3a0558bd24d646a.json
├── ecfed8505473c79ab6831d6272a1adff68a42cd102e9992e60b2f8925df01927
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── manifest.json
└── repositories
3 directories, 12 files

可见 docker save 时会将 layer 的 metadata 文件输出,查看 json 文件后发现其的确有 parent 的定义

1
2
3
4
5
{
"id": "2daafd635a629218204652bd3b10ddd23ae5e33abe1ebc3c26c01103e33369de",
"parent": "9fbc75679caed833594370d2effbdbba4e09eb6ee7a87f9d2e94b41627d56831",
"created": "2018-11-16T13:32:10.147294787Z"
}

我还做了个实验,将每层的 json, VERSION 及 repositories 文件删除,检查是否仍然能继续 docker load,呃当然是肯定的

1
2
3
4
5
6
7
8
9
10
.
├── 2daafd635a629218204652bd3b10ddd23ae5e33abe1ebc3c26c01103e33369de
│ └── layer.tar --- Layer File
├── 9fbc75679caed833594370d2effbdbba4e09eb6ee7a87f9d2e94b41627d56831
│ └── layer.tar --- Layer File
├── e81eb098537d6c4a75438eacc6a2ed94af74ca168076f719f3a0558bd24d646a.json --- Config file
├── ecfed8505473c79ab6831d6272a1adff68a42cd102e9992e60b2f8925df01927
│ └── layer.tar --- Layer File
└── manifest.json --- Image Tar Archive Description
3 directories, 5 files

docker load 一把

1
2
3
4
5
6
tar -cf nginx-new.tar *
> docker load -i nginx-new.tar
ef68f6734aa4: Loading layer [==================================================>] 58.44MB/58.44MB
876456b96423: Loading layer [==================================================>] 54.38MB/54.38MB
9a8f339aeebe: Loading layer [==================================================>] 3.584kB/3.584kB
Loaded image: nginx:latest

docker load 各 layer, 其中左侧的 hex 值为 layer sha256 hash value

可见的确未受影响,所以 docker load 并未依赖 docker save 时保留的 layer metadata 信息

1
2
3
4
5
6
7
8
9
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:ef68f6734aa485edf13a8509fe60e4272428deaf63f446a441b79d47fc5d17d3",
"sha256:876456b964239fb297770341ec7e4c2630e42b64b7bbad5112becb1bd2c72795",
"sha256:9a8f339aeebe1e8bcef322376e1274360653fb802abd4b94c69ea45a54f71a2b"
]
}

另外对于镜像 Config file 中说明的 rootfs.diff_ids, 其中的 sha256 hash 值均为各 layer 的 sha256 hash 值

1
2
3
ef68f6734aa485edf13a8509fe60e4272428deaf63f446a441b79d47fc5d17d3  ecfed8505473c79ab6831d6272a1adff68a42cd102e9992e60b2f8925df01927/layer.tar
876456b964239fb297770341ec7e4c2630e42b64b7bbad5112becb1bd2c72795 9fbc75679caed833594370d2effbdbba4e09eb6ee7a87f9d2e94b41627d56831/layer.tar
9a8f339aeebe1e8bcef322376e1274360653fb802abd4b94c69ea45a54f71a2b 2daafd635a629218204652bd3b10ddd23ae5e33abe1ebc3c26c01103e33369de/layer.tar

https://www.huweihuang.com/article/docker/docker-commands-principle/

如果你要持久化一个镜像,可以使用 docker save 指令
它与 docker export 的区别在于其保留了所有元数据和历史层
另外 docker export 用于容器,而不是镜像

docker inspect 用于查看镜像最顶层的 metadata

docker images -a 该指令用作列出镜像的所有镜像层。镜像层的排序以每个顶层镜像 ID 为首,依次列出每个镜像下的所有镜像层

docker history 查看该镜像 ID 下的所有历史镜像

Summary

对比发现,其实在 Image Tar Archive 中,layer 的 parent-child 关系实际上就是定义于镜像 Config file 中的 rootfs.diff_ids 顺序

[0] <- [1] <- [2] <- …

以 nginx:latest 为例

  1. ef68f6734aa485edf13a8509fe60e4272428deaf63f446a441b79d47fc5d17d3 (base layer)
  2. 876456b964239fb297770341ec7e4c2630e42b64b7bbad5112becb1bd2c72795
  3. 9a8f339aeebe1e8bcef322376e1274360653fb802abd4b94c69ea45a54f71a2b

这下就恍然大悟了

Creating an Image Filesystem Changeset

https://github.com/moby/moby/blob/master/image/spec/v1.md#creating-an-image-filesystem-changeset

描述了如何 Creating an Image Filesystem Changeset

每层 layers file 仅可能有如下的情况

  • add
  • update
  • deleted

例如

1
2
3
Added:      /etc/my-app.d/default.cfg
Modified: /bin/my-app-tools
Deleted: /etc/my-app-config

对于 changeset 来说,会生成如下文件

1
2
3
/etc/my-app.d/default.cfg
/bin/my-app-tools
/etc/.wh.my-app-config

.wh. i.e. without

Loading an Image Filesystem Changeset

那么我们又如何 Loading an Image Filesystem Changeset

https://github.com/moby/moby/blob/master/image/spec/v1.md#loading-an-image-filesystem-changeset

  1. 找到 the root ancestor changeset
  2. 从 root ancestor changeset 开始,逐级解压 layer’s filesystem changeset archive 到目录 (将被使用来作为 the root of a container filesystem)
    1.1 每层解压之后再遍历一次目录,删除已被标记删除的目录 removing any files with the prefix .wh. and the corresponding file or directory named without this prefix

Owner and Group

另外尝试改变文件属主

changeset 也算作文件 update

untar 的时候注意 –same-owner

这里有个新问题,就是 docker load 是如何处理 Image Filesystem Changeset 中的属主的

实际测试得需要 root 用户 tar --same-owner -xvf 才行, 解压出来的属主和 group 也仅为 id 值,毕竟宿主机上不一定有该 owner 和 group

1
2
3
4
5
ash-3.2$ ls -al
total 0
drwxr-xr-x 3 zrss staff 102 11 17 19:24 .
drwxr-xr-x 10 zrss staff 340 11 17 18:39 ..
drwxr-xr-x 13 101 101 442 11 17 19:25 var

101 nginx

Permission

改变文件权限

changeset 也算作文件 update

直接解压即可,可以保留原权限

Scan Image Tar Archive

业界做法扫描 layer,

https://docs.docker.com/ee/dtr/user/manage-images/scan-images-for-vulnerabilities/

而不是将 layer combine 成 container root fs 之后,再全文件扫描

当然可能因为是病毒扫描,这样做比较简单

话说有没有必要组成 root fs 之后再扫描呢,因为毕竟可能之前 layer 的漏洞,在下一 layer 被修复了,感觉可能是会误报的 ? 细节上不知道可以如何实现

倒是可以看下 coreos clair 是如何实现的

🙄 其实也是一样的,把 layer 解压之后,扫文件,比对数据库

Summary

其实是有点儿疑惑的, 业界镜像扫描解决方案 (当然是针对病毒扫描) 都是直接扫描 image layer

暂未发现有按照 Loading an Image Filesystem Changeset 描述的过程那样,挂载出 container root fs 之后,再扫描的解决方案

当然描述的过程感觉其实只是好理解,实际上 dockerd 再组织镜像 root fs 时,是需要根据不同的 storage driver 的实现,调用不同的命令实现的挂载 (或者换一个说法,storage driver 本质上实现了描述的过程 …)

  1. overlay2

https://terriblecode.com/blog/how-docker-images-work-union-file-systems-for-dummies/

1
2
3
4
5
6
mkdir base diff overlay workdir
sudo mount \
-t overlay \
-o lowerdir=base,upperdir=diff,workdir=workdir \
overlay \
overlay

这哥们没讲太细

  1. aufs

https://coolshell.cn/articles/17061.html?spm=a2c4e.11153940.blogcont62949.21.53a61eearfeDBm

有文件删除的话,在可写层放个 .wh.[file-name],文件就被隐藏了。和直接 rm 是一样的

  1. devicemapper

https://coolshell.cn/articles/17200.html?spm=a2c4e.11153940.blogcont62949.22.53a61eearfeDBm

描述如何用 devicemapper 实现 layers 挂载成 union file system 的,各层可以通过 devicemapper 的 snapshot 技术实现,对用户来说就是单一的 fs

最近看了看 knative-build-controller 中使用的 workqueue,不得不说是个设计复杂

而且对于 controller 来说,非常好用的一个东西。因为 controller 中会使用到 informer,而 informer 产生的待 reconcile key 可以放入 workqueue 中,等待 controller 逐一处理

workqueue 的实现是由 client-go https://github.com/kubernetes/client-go 库提供的,目录位于 util/workqueue

其中 knative-build-controller 使用的为 rate_limitting_queue.go,也就是通过

1
2
3
4
5
6
func NewRateLimitingQueue(rateLimiter RateLimiter) RateLimitingInterface {
return &rateLimitingType{
DelayingInterface: NewDelayingQueue(),
rateLimiter: rateLimiter,
}
}

创建出来的 queue

使用 default_rate_limiters.go,也就是通过

1
2
3
4
5
6
7
func DefaultControllerRateLimiter() RateLimiter {
return NewMaxOfRateLimiter(
NewItemExponentialFailureRateLimiter(5*time.Millisecond, 1000*time.Second),
// 10 qps, 100 bucket size. This is only for retry speed and its only the overall factor (not per item)
&BucketRateLimiter{Limiter: rate.NewLimiter(rate.Limit(10), 100)},
)
}

创建出来的 limiter,用来计算限流时间的,会取 NewItemExponentialFailureRateLimiterBucketRateLimiter 中限流时间的大者

咱们重点看 queue 的实现

RateLimitingQueue 提供了 rate limit (限流) 的功能,而我们知道 knative-build-controller (当然 k8s 中许多其他的 controller 也是如此模型) 的处理模型为循环从 queue 中获取 item,并 reconcile 之

从 api 来看,rate_limitting_queue 组合了 DelayingInterface 接口,并提供了

  • AddRateLimited
  • Forget
  • NumRequeues

方法

其中 DelayingInterface 接口又组合了 Interface 接口,并提供了

  • AddAfter

方法

最后 Interface 接口提供基本的 queue 功能

  • Add
  • Len
  • Get
  • Done
  • Shutdown
  • ShuttingDown

RateLimittingQueue

限流队列

AddRateLimited

实际上是调用了限流算法,根据重试次数计算出当前限流时间,在该时间之后,再将 item 加入 queue 中

1
2
3
func (q *rateLimitingType) AddRateLimited(item interface{}) {
q.DelayingInterface.AddAfter(item, q.rateLimiter.When(item))
}

因此这个实现依赖于 DelayingQueue 的 AddAfter 方法

Forget

这个方法有点儿特殊,Forget 是啥语义?忘了?我 item 好端端的,为啥要忘了我

😂 一开始我也是一脸懵逼,不过想想呐,咱们调用了 AddRateLimited,而这个方法在计算限流时间时,需要使用到 item 的重试次数的,没有这个重试次数,当然计算限流时间就没有意义了

所以 Forget 呢,实际上是清除 item 的重试次数,这样下次再将这个 item AddRateLimited 时,就不会受限流的影响了

Forget 在 ItemExponentialFailureRateLimiter 中的实现

1
2
3
4
5
func (r *ItemExponentialFailureRateLimiter) Forget(item interface{}) {
r.failuresLock.Lock()
defer r.failuresLock.Unlock()
delete(r.failures, item) // 从重试统计次数表中删除 item
}

以及计算限流时间的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (r *ItemExponentialFailureRateLimiter) When(item interface{}) time.Duration {
r.failuresLock.Lock()
defer r.failuresLock.Unlock()
exp := r.failures[item] // 当 item 不在表中时,exp 为 0
// 将重试次数设置为 1,也就是每次递增 1
r.failures[item] = r.failures[item] + 1
// 使用指数算法计算限流时间
backoff := float64(r.baseDelay.Nanoseconds()) * math.Pow(2, float64(exp))
if backoff > math.MaxInt64 {
return r.maxDelay
}
calculated := time.Duration(backoff)
if calculated > r.maxDelay {
return r.maxDelay
}
return calculated
}

DelayingQueue

如此我们首先查看 DelayingInterface 接口的实现之一 delaying_queue.go

我们看,这个 queue 想实现如何的功能?

AddAfter ? 所谓的在 n time 之后的再将 item 加入 queue 的功能

为了实现这个功能,首先要考虑 AddAfter 是同步的,亦或是异步的方法 ?

当前实现是异步的方法

AddAfter

计算出 readyAt 时间,将该时间与 item 一并存入 waitingForAddCh,这个 ch 的大小为 1000,也就是说未达到 1000 时,AddAfter 是不会被阻塞的

细心的同学可能会问,如果 AddAfter 的时间为 0 甚至为负怎么办,当然这种情况直接加入 queue 即可,就不需要再加入 waitingForAddCh 了

waitingLoop

当然为了实现 AddAfter 这个功能,免不了 queue 需要做一些额外的维护事情,最重要的就是 queue 初始化时,开始用协程执行 waitingLoop 方法

这个方法是实现 delaying_queue 功能的核心逻辑

注意到待加入 queue 的 item 位于 waitingForAddCh 中,waitingLoop 当可以从 waitingForAddCh 获取到 item 时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
case waitEntry := <-q.waitingForAddCh:
if waitEntry.readyAt.After(q.clock.Now()) {
insert(waitingForQueue, waitingEntryByData, waitEntry)
} else {
q.Add(waitEntry.data)
}
drained := false
for !drained {
select {
case waitEntry := <-q.waitingForAddCh:
if waitEntry.readyAt.After(q.clock.Now()) {
insert(waitingForQueue, waitingEntryByData, waitEntry)
} else {
q.Add(waitEntry.data)
}
default:
drained = true
}
}
}

首先会判断这个 item 是否可以加入 queue 了,如果时候还没到,那么将该 item 加入以 readyAt 为排序关键的优先队列中。若时候到了,则加入 queue。处理完第一个 item 之后,会将 waitingForAddCh 中剩余的 item 均按照相同的逻辑处理之

这个 item 加入优先队列时,还有一个讲究的地方,注意到下述代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// insert adds the entry to the priority queue, or updates the readyAt if it already exists in the queue
func insert(q *waitForPriorityQueue, knownEntries map[t]*waitFor, entry *waitFor) {
// if the entry already exists, update the time only if it would cause the item to be queued sooner
existing, exists := knownEntries[entry.data]
if exists {
if existing.readyAt.After(entry.readyAt) {
existing.readyAt = entry.readyAt
heap.Fix(q, existing.index)
}
return
}
heap.Push(q, entry)
knownEntries[entry.data] = entry
}

如果加入的 item 已经存在,并且新加入 item 的 readyAt 时间比已经存在的 item 的时间晚,那么不好意思哈,这个 item 会被直接丢弃。只有新加入的 item 的 readyAt 时间比已存在的 item 时间要早,才会更新已存在的 item 的 readyAt 时间,并调整 item 在优先队列中的位置

所以看到这里,delaying_queue 实际上还有去重功能

回到 waitingLoop 的 loop 来,loop 首先要执行的操作,即为从优先队列中依次 Peek item,即不断从优先队列中取出第一个 item,这个 item 最有可能到触发时间了

1
2
3
4
5
6
7
8
9
10
// Add ready entries
for waitingForQueue.Len() > 0 {
entry := waitingForQueue.Peek().(*waitFor)
if entry.readyAt.After(now) {
break
}
entry = heap.Pop(waitingForQueue).(*waitFor)
q.Add(entry.data)
delete(waitingEntryByData, entry.data)
}

显然如果到达了 Add 时间,那么就将其加入 queue 中,并执行一些清理工作。若终于遍历到未到触发时间的 item 了,这个时候可以退出遍历优先队列的循环了

因为这个时候,所有当前可以 Add queue 的 item 都已经处理完了,所以接下来,可以执行一段优化的逻辑,加快 item 的处理

1
2
3
4
5
6
// Set up a wait for the first item's readyAt (if one exists)
nextReadyAt := never
if waitingForQueue.Len() > 0 {
entry := waitingForQueue.Peek().(*waitFor)
nextReadyAt = q.clock.After(entry.readyAt.Sub(now))
}

这个有点意思,都处理完了是吧,那我选优先队列中的第一个 item,用它的 readyAt 时间设置一个定时器,这样的话,一旦到达需要 Add 的时间,waitLoop 就会处理了 (当然用户态的程序,定时上都有些许偏差,不会特别特别精确)

都准备好之后,waitLoop 就进入等待数据/事件的逻辑了

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
select {
case <-q.stopCh:
return
case <-q.heartbeat.C():
// continue the loop, which will add ready items
// 这里是心跳,所谓的最大等待时间,目前是 10s,即如果 10s 内啥都没发生的话,也会执行一般 waitLoop 中的循环逻辑
case <-nextReadyAt:
// continue the loop, which will add ready items
// 这里是优先队列的第一个 item 的定时器触发了
case waitEntry := <-q.waitingForAddCh:
// 这是我们说的,首先判断第一个 item 是否可以加入 queue
if waitEntry.readyAt.After(q.clock.Now()) {
insert(waitingForQueue, waitingEntryByData, waitEntry)
} else {
q.Add(waitEntry.data)
}
// 接下来会处理仍然处于 waitingForAddCh 中的 item,与上述逻辑一致
drained := false
for !drained {
select {
case waitEntry := <-q.waitingForAddCh:
if waitEntry.readyAt.After(q.clock.Now()) {
insert(waitingForQueue, waitingEntryByData, waitEntry)
} else {
q.Add(waitEntry.data)
}
default:
drained = true
}
}
}

ok,至此 delaying_queue 的要点就说完了,下面提几点使用的时候的注意点,避免踩坑

  • AddAfter 为异步方法,所以现象为调用 AddAfter 之后,item 并不会立即被加入到 queue 中
  • AddAfter 会做去重处理,在 queue 中依然有相同的 item 时,如果新加入 item 的 readyAt time 靠后的话,新加入的 item 会被丢弃

Queue

代码实现位于 queue.go 中

好了,在经过 DelayQueue 的延时加入策略之后,最终 item 还是被加入 queue 中的,而 queue 的实现也多有讲究,来一探究竟吧

queue 内部有两个重要的数据结构

  • processing set
  • dirty set

有一个比较特殊的方法

  • Done

首先来看一下 queue 的 Add 方法

Add

代码不多,直接上了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (q *Type) Add(item interface{}) {
// cond 加锁
q.cond.L.Lock()
defer q.cond.L.Unlock()
if q.shuttingDown {
return
}
// 如果在 dirty set 中,那么该 item 被忽略
if q.dirty.has(item) {
return
}
q.metrics.add(item)
// dirty set 标记
q.dirty.insert(item)
// 如果当前 item 仍然在处理中,则被忽略
if q.processing.has(item) {
return
}
// 加入 queue
q.queue = append(q.queue, item)
// 通知 cond lock 的协程
q.cond.Signal()
}

Get

queue 的出口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (q *Type) Get() (item interface{}, shutdown bool) {
// cond 加锁
q.cond.L.Lock()
defer q.cond.L.Unlock()
// 如果 queue 为空,并且 queue 未关闭,则等待
// 即 Get 方法在这种情况下,是阻塞的
for len(q.queue) == 0 && !q.shuttingDown {
q.cond.Wait()
}
// 这种情况是 queue 关闭的时候
if len(q.queue) == 0 {
// We must be shutting down.
return nil, true
}
// 取 queue 中第一个 item 返回
item, q.queue = q.queue[0], q.queue[1:]
q.metrics.get(item)
// 标记 item 为处理中
q.processing.insert(item)
// 去除 item dirty 标记
q.dirty.delete(item)
return item, false
}

看了 Add 与 Get 实现后,我们得到几个结论

  • queue 也实现了去重: Add 相同 item,若该 item 未被 Get,那仅会被加入 queue 一次
  • queue.Get 方法在 queue 中没数据时,是阻塞的,即你可以这写
1
2
3
4
5
for {
obj := queue.Get()
// obj 一定存在
// blabla
}

Done

1
2
3
4
5
6
7
8
9
10
func (q *Type) Done(item interface{}) {
q.cond.L.Lock()
defer q.cond.L.Unlock()
q.metrics.done(item)
q.processing.delete(item)
if q.dirty.has(item) { // 如果 item 被标记为 dirty,则重新加入 queue 中
q.queue = append(q.queue, item)
q.cond.Signal()
}
}

Done 方法会去掉 item 的 processing 标记,并且如果 item 被标记为 dirty,那会再将 item 加入 queue。有这个逻辑那我们应该如何使用 queue ?

我们首先来考虑这种场景

itemA 被 Add queue,此时 X 协程 Get 时,获取到了 itemA,在这种情况下,itemA 被标记为 processing,而没有 dirty 的标记,若此时另一 Y 协程又再次调用 Add 方法将 itemA Add queue,这时 itemA 就被标记为 dirty 了,并且因为有 processing 标记,所以并不会被加入 queue 中

过了一些时间后,X 协程执行 ok 了,调用 Done(itemA) 方法,去除 itemA 的 processing 标记,因为 itemA 为 dirty,所以将其重新加入 queue 中,等待被 Get 并处理

所以在这种场景下就好理解多了,实际上 queue 还有个特性,即 queue 中不会有重复的 item,但是仅允许 item 被 Get 之后,Done 之前,被 Add 一次,在这种情况下,Done 的时候,会重新将 item 加入 queue 中

可以理解为如果这个 item 正在被处理时,queue 允许至多缓存一次相同的 item

所以再总结一次 queue 的特性

  • 添加 item 调用 Add 方法
  • 获取 item 调用 Get 方法
  • 处理 item 之后调用 Done 方法。否则再次 Add 相同 item 时,若该 item 仍未被 Get 则直接被忽略。若该 item 已被 Get,则被打上 dirty 标记,在其被调用 Done 时,该 item 才会被重新加入 queue 中
  • 本质上 queue 中不会有重复的 item

Summary

在看了这几种 queue 的实现之后,是否更了解 rate_limmiting_queue.go 该如何使用了?

例如在 knative-build-controller 中它被如此初始化 (天下代码一大抄)

1
workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "Builds"),

具体使用时

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
func (c *Controller) processNextWorkItem() bool {
// 从 queue 中取 item
obj, shutdown := c.workqueue.Get()
if shutdown {
return false
}
if err := func(obj interface{}) error {
// 处理结束之后,需要调用 Done
defer c.workqueue.Done(obj)

key, ok := obj.(string)
if !ok {
c.workqueue.Forget(obj) // Fatal 错误,调用 Forget,没有重试的必要
runtime.HandleError(fmt.Errorf("expected string in workqueue but got %#v", obj))
return nil
}

if err := c.syncHandler(key); err != nil {
// 处理失败时,不调用 Forget,增加 item 的重试次数
return fmt.Errorf("error syncing '%s': %s", key, err.Error())
}
// 处理成功调用 Forget,清除 item 的重试次数,使得下次相同的 item 不受 rate limit 影响
c.workqueue.Forget(obj)
c.logger.Infof("Successfully synced '%s'", key)
return nil
}(obj); err != nil {
runtime.HandleError(err)
}
return true
}

所以再说一遍浓缩用法

  • 添加 item 调用 Add 方法
  • 获取 item 调用 Get 方法
  • 处理 item 之后调用 Done 方法
  • 不增加 item 重试次数调用 Forget 方法

再说一遍 rate limit queue 重点,切莫踩坑

  • Add 是异步方法
  • Add 有去重功能
    • 先经过 DelayQueue 去重处理,对于新加入的 item,在其优先队列中依然有相同的 item 时,如果新加入 item 的 readyAt time 较原 item 的 readyAt 时间靠后的话,新加入的 item 会被丢弃
    • 再经过 Queue 去重处理,如果 queue 中有相同 item 则直接被丢弃。若 queue 中没有相同 item,但是 item 处于被处理中,即未被调用 Done 时,会将 item 标记为 dirty,待 item 被调用 Done 时,重新加入 queue
  • 处理 item 结束之后,无论如何调用 Done,标识该 item 已被处理结束
  • 若不需要增加 item 的重试次数,则结束之后调用 Forget 方法,清除该 item 的重试次数统计
  • 如果需要调用 Forget,则先调用 Forget 再调用 Done,确保再次 Add 的时候不受限流影响

之所以关注到这个问题,是因为在写 build-controller 一个 bugfix 的 ut 时,各种坑,遂研究了下 workqueue 的细节,关于这个 bugfix 的讨论看这个链接 Timeout of build may have problem

Thanks for your time 😁

占个坑,还是在 ut 的时候被坑到了,后续有时间补上,这个我认为 k8s 最成功的地方

不是容器调度,而是这套基于 etcd 抽象出来的 api 😃

  • informer
  • lister

这 informer 呀,其实就是提供 add/update/create 回调的封装 (所谓 informer)

而这 lister 呢,是对象 cache,从 lister 中可以获取到对象。lister 由 informer 提供。

其实还挺自然的,想想 informer 回调 add/update/create 的时候,基本思路

  • list-watch kube-apiserver
  • watch 到对象变化,根据 cache 计算 diff,然后相应回调 add/update/create

所以它是需要 lister 这样的 local cache 的

所以如果自己要实现一个 k8s style 的 controller,需要使用到 informer 时,首先初始化 informer,初始化 ok 之后,如果需要查对象,那可以通过 informer 的 lister 来获取

也许 kubernetes 对于开发者来说仍然过于复杂,另外现实系统中,又对服务发布后的流量管理有诸多需求

Knative 的出现,似乎是要真正实现 PAAS on the kubernetes

开发者仅需要关心如何编写代码 (write code) ,其他的所有,交给 Knative 吧(Maybe)

One Platform for Your Functions, Applications, and Containers (Cloud Next ‘18)

Knative serving

controller

  • revision
  • configuration
  • services
  • routes

抽象了一套统一的框架去实现四个 controller,不过当前命名的不是很理想,虽然都在 controller package 下面,然而文件名仅为 revision.go … 不如 kubernetes 的 job_controller.go 来的直接,也便于搜索

controller 的主要方法均为 Reconsile,即从 kube-apiserver list-watch CRD 的增量更新后,调用 Reconsile 执行相应操作,使得最终状态与用户期望的一致

提到 list-watch 而又不能不提到 kubernetes 中的杰出 api sdk 实现——informer

基于已日渐稳定的 kubernetes,knative 目前实现的简洁直接

1
ctrlr.Run(threadsPerController, stopCh)

每个 controller 启动 2 (threadsPerController) 个 goroutine 处理 list-watch 获得的 CRD 更新信息

1
2
3
4
5
6
for i := 0; i < threadiness; i++ {
go wait.Until(func() {
for c.processNextWorkItem(syncHandler) {
}
}, time.Second, stopCh)
}

syncHandler 由各个不同的 controller 传入

下面简单分析 knative serving 模块的几个 controller

services

根据 knative 的 simple demo app,开始的时候,我们需要使用 yaml 创建一个 service,这是所有 knative 奇妙之旅的开端 getting-started-knative-app

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: serving.knative.dev/v1alpha1 # Current version of Knative
kind: Service
metadata:
name: helloworld-go # The name of the app
namespace: default # The namespace the app will use
spec:
runLatest:
configuration:
revisionTemplate:
spec:
container:
image: gcr.io/knative-samples/helloworld-go # The URL to the image of the app
env:
- name: TARGET # The environment variable printed out by the sample app
value: "Go Sample v1"

services 的 reconcile 首先会查询

service 对应的 configuration (its name is the same with service-name) 是否存在

  • 不存在,创建之
  • 存在,reconcile 之

service 对应的 routes (its name is the same with service-name) 是否存在

  • 不存在,创建之
  • 存在,reconcile 之

configuration

获取对应的 rev

[config-name]-[config.spec.Generation]: helloworld-go-00001

  • 不存在,创建之

随后更新 configuration 的 status

所以可以看到在 configuration 中其实实现了 app 的多版本管理,每次 configuration 的修改(Generation + 1)均会生成一个新的 revision

revision

revision 关注下述几种资源,在下述资源有变化时,将变化加入 queue 中,等待 revision 2 个 goroutine 处理之

  • revisionInformer
  • deploymentInformer

暂时仅关注 revisionInformer,endpointsInformer 及 deploymentInformer

revision controller 获取到 revision 之后

若未找到其对应的 deployment

[rev-name]-deployment

将其 revision 的 status 更新为

  • ResourcesAvailable [status: Unknown, reason: Deploying]
  • ContainerHealthy [status: Unknown, reason: Deploying]
  • Ready [status: Unknown, reason: Deploying]

并调用 kube-apiserver api 创建 deployment

注意到在创建 deployment 时,revision controller 需要连接至该 deployment 的镜像仓库,获取其 digest,因此如果 revision controller 所在节点的网络受限的话,revision 的 status 可能会提示如下信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
status:
conditions:
- lastTransitionTime: 2018-08-29T19:03:43Z
reason: Deploying
status: Unknown
type: ResourcesAvailable
- lastTransitionTime: 2018-08-29T19:04:13Z
message: 'Get https://gcr.io/v2/: dial tcp: i/o timeout'
reason: ContainerMissing
status: "False"
type: ContainerHealthy
- lastTransitionTime: 2018-08-29T19:04:13Z
message: 'Get https://gcr.io/v2/: dial tcp: i/o timeout'
reason: ContainerMissing
status: "False"
type: Ready

即连接镜像仓库(如示例中的连接 gcr.io 超时),导致 revision notReady,正常工作的 revision 状态如下

1
2
3
4
5
6
7
8
9
10
11
status:
conditions:
- lastTransitionTime: 2018-08-30T09:36:46Z
status: "True"
type: ResourcesAvailable
- lastTransitionTime: 2018-08-30T09:36:46Z
status: "True"
type: ContainerHealthy
- lastTransitionTime: 2018-08-30T09:36:46Z
status: "True"
type: Ready

非 active 的 revision 状态如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
status:
conditions:
- lastTransitionTime: 2018-08-30T09:09:18Z
reason: Updating
status: Unknown
type: ResourcesAvailable
- lastTransitionTime: 2018-08-30T09:09:18Z
reason: Updating
status: Unknown
type: ContainerHealthy
- lastTransitionTime: 2018-08-30T09:09:18Z
reason: Inactive
status: "False"
type: Ready

若找到其对应的 deployment

则根据 rev 的状态,决定 deployment replica 的数量

  • rev.spec.servingState 状态为 Active,且 deployment replica = 0 时,需将其调整为 1
  • rev.spec.servingState 状态为 Reserve,且 deployment replica != 0 时,需将其调整为 0

如果期望的 deployment replica 与实际的 replica 相同,那么将 rev 的 status 更新为

  • ResourcesAvailable [status: Unknown, reason: Updating]
  • ContainerHealthy [status: Unknown, reason: Updating]
  • Ready [status: Unknown, reason: Updating]

如果不相同,调用 kube-apiserver api 更新 deployment

routes

routes 获取其对应的 kubernetes svc

[route-name] 例如 helloworld-go

  • 不存在,创建之
  • 否则,更新之

随后通过 istio CRD Istio VirtualService 配置流量

  • 不存在,创建之
  • 否则,更新之

Summary

controller of serving

  • services controller
  • configuration controller
  • routes controller
  • revision controller

数据源均来自 list-watch 相应的 CRD,实现相应的 reconcile 方法

services controller 负责创建 configuration 和 routes 资源

configuration controller 负责创建 revision 资源

routes controller 负责创建 Istio VirtualService 资源

Issues

Knative 还处于较为年轻的阶段,花了两天时间最后成功在内网环境上成功运行了其 simple demo app。目前在需要使用 proxy 访问公网网络的情况下,如何配置 knative,其文档中还没有相关的说明

目前为止尝试 knative 的一些 debug 经历,可参看下述 issues

Knative http proxy sample

istio-statsd-prom-bridge pod crash due to unknown short flag

Configuration is waiting for a Revision to become ready

to be cont …

kubelet 为运行在 node 上的主要组件

其一方面 list-watch kube-apiserver pod 资源的变化

另一方面调用 docker 接口获取当前实际存在的 container 来 SyncLoop (PLEG)

所以下面分两条路线来分析 kubelet 的一些细节 (仅关注 pod/container,略去其他无关资源)

overview

  • Run
    • start a goroutine, one second trigger once podKiller method
    • call kl.statusManager.Start()
    • call kl.probeManager.Start()
    • call kl.pleg.Start()
      • start a goroutine, one second trigger once relist method
    • call kubelet main loop kl.syncLoop(updates, kl)
      • syncLoopIteration

注意到 kubelet main loop 传递的 updates channel 为从 kube-apiserver list-watch 到的 pod 变化数据,当 kubelet 重启时,会收到当前 node 上的所有 pod 数据

syncLoopIteration 是 kubelet 的 main loop,其主要处理

  • configCh channel: pod info with ADD / Update / Delete …, this channel’s data comes from kube-apiserver
  • plegCh channel: pod life cycle generator event, such as ContainerStart / ContainerDied …, this channel’s data comes from docker
  • syncCh channel: a one-second period time ticker
  • livenessManager.Updates() channel
  • housekeepingCh channel: a two-second period time ticker

当 kubelet 启动时指定 -v=2 的情况下

kubelet 处理 configCh 数据时,会显示如下日志

1
2
3
4
5
6
7
SyncLoop (ADD, api): podName_podNamespace(podUID),...

or

SyncLoop (UPDATE, api): podName_podNamespace(podUID),...

and REMOVE / RECONCILE / DELETE / RESTORE type

具体如下

1
SyncLoop (ADD, "api"): "nginx-deployment-6c54bd5869-wppm8_default(1336f9f0-a898-11e8-b01b-000d3a362518)"

kubelet 处理 plegCh 数据时,会显示如下日志

1
SyncLoop (PLEG): podName_podNamespace(podUID),..., event:

具体如下

1
SyncLoop (PLEG): "nginx-deployment-6c54bd5869-9jsp5_default(1336d6cf-a898-11e8-b01b-000d3a362518)", event: &pleg.PodLifecycleEvent{ID:"1336d6cf-a898-11e8-b01b-000d3a362518", Type:"ContainerStarted", Data:"7e06e4ce8ab3a4a0b8bbb84f35ac8ac078bb5ec9db4ce765e35a235664cb3dd7"}

Data 为 ContainerID 与 docker ps 看到的相同

1
2
hzs@kubernetes:~/work/src/k8s.io/kubernetes$ docker ps | grep 7e06e4ce8ab3
7e06e4ce8ab3 nginx "nginx -g 'daemon of…" 3 minutes ago Up 3 minutes k8s_nginx_nginx-deployment-6c54bd5869-9jsp5_default_1336d6cf-a898-11e8-b01b-000d3a362518_0

经过上述分析,大概对 kubelet 的工作原理及数据来源有了个基本认识,下面详细看一下 kubelet 对 configCh 及 plegCh 的数据处理

configCh

list-watch from kube-apiserver in a independant goroutine, once there is events about pods, then these pod data will be put into configCh

syncLoopIteration -> handle the pod data from list-watch from kube-apiserver pod resource -> HandlePodAdditions/… -> dispatchWork -> podWorkers.UpdatePod

UpdatePod

  • 如果之前未处理该 pod,则为该 pod 创建一个大小为 1 的 UpdatePodOptions channel,并启动一个协程调用 managePodLoop(podUpdates)
  • 如果处理过了,判断 isWorking
    • 若 false,则置为 true,并将 *options 置入 UpdatePodOptions channel,以供 managePodLoop 处理
    • 若 true,则进一步判断 lastUndeliveredWorkUpdate 未被记录或者 UpdateType 不等于 kubetypes.SyncPodKill,则更新 lastUndeliveredWorkUpdate 为本次 UpdatePod *options

managePodLoop -> syncPodFn -> kubelet.syncPod

plegCh

one second trigger once time relist -> generate container event (Started/…) -> put the event into eventChannel channel

syncLoopIteration -> handle the event from eventChannel -> HandlePodSyncs -> dispatchWork -> podWorkers.UpdatePod

看到这,简单总结一下,两条更新的路,最终得到统一,即来自于 kube-apiserver pod 更新,又亦或是来自于节点上 container status 的变化 (pleg),最终均会调用 syncPod

1
2
SyncLoop (ADD, "api"): "nginx-deployment-6c54bd5869-wppm8_default(1336f9f0-a898-11e8-b01b-000d3a362518)"
SyncLoop (PLEG): "nginx-deployment-6c54bd5869-9jsp5_default(1336d6cf-a898-11e8-b01b-000d3a362518)", event: &pleg.PodLifecycleEvent{ID:"1336d6cf-a898-11e8-b01b-000d3a362518", Type:"ContainerStarted", Data:"7e06e4ce8ab3a4a0b8bbb84f35ac8ac078bb5ec9db4ce765e35a235664cb3dd7"}

syncPod

举几个典型的例子吧

create a deployment with replica 1

kubelet 的响应流程

configCh

  • SyncLoop (ADD, “api”): “podName_namespace(podUID)”
  • HandlePodAdditions

从 podManager 中获取已存在的 pod,并将新的 pod 添加至其中。从已存在的 pod 中过滤出 activePods,并判断新的 pod canAdmitPod。例如亲和性、反亲和性的判断

  • dispatchWork

调用 podWorkers.UpdatePod

1
2
3
4
5
6
7
8
9
10
kl.podWorkers.UpdatePod(&UpdatePodOptions{
Pod: pod,
MirrorPod: mirrorPod,
UpdateType: syncType, // SyncPodCreate
OnCompleteFunc: func(err error) {
if err != nil {
metrics.PodWorkerLatency.WithLabelValues(syncType.String()).Observe(metrics.SinceInMicroseconds(start))
}
},
})
  • UpdatePod
1
初始化 podUID -> UpdatePodOptions channel (1),并启动协程执行 p.managePodLoop(podUpdates)。p.isWorking[pod.UID] 为 false,随后设置其为 true,并将 *options 置入 UpdatePodOptions channel
  • managePodLoop

循环处理 UpdatePodOptions channel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// This is a blocking call that would return only if the cache
// has an entry for the pod that is newer than minRuntimeCache
// Time. This ensures the worker doesn't start syncing until
// after the cache is at least newer than the finished time of
// the previous sync.
status, err := p.podCache.GetNewerThan(podUID, lastSyncTime)
err = p.syncPodFn(syncPodOptions{
mirrorPod: update.MirrorPod,
pod: update.Pod,
podStatus: status,
killPodOptions: update.KillPodOptions,
updateType: update.UpdateType,
})
lastSyncTime = time.Now()
  • syncPodFn(kubelet.syncPod)

mkdir dir

1
/var/lib/kubelet/pods/[podUID]
  • volumeManager.WaitForAttachAndMount(pod)

获取 imagePullSecrets

1
2
// Fetch the pull secrets for the pod
pullSecrets := kl.getPullSecretsForPod(pod)
  • containerRuntime.SyncPod
1
2
// Call the container runtime's SyncPod callback
result := kl.containerRuntime.SyncPod(pod, apiPodStatus, podStatus, pullSecrets, kl.backOff)
  • createPodSandBox

mkdir logs dir

1
/var/log/pods/[podUID]
  • run init container
  • run container

pleg

relist 的时候,先从 docker 获取一把全量的 pod 数据

1
2
// Get all the pods.
podList, err := g.runtime.GetPods(true)

当前状态与之前的状态一比,生成每个 container 的 PLE (pod life cycle event)

1
SyncLoop (PLEG): "podName_Namespace(podUID)", event: &pleg.PodLifecycleEvent{ID:"podUID", Type:"ContainerStarted", Data:"ContainerID"}

值得注意的是 ContainerDied 就是容器退出的意思

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func generateEvents(podID types.UID, cid string, oldState, newState plegContainerState) []*PodLifecycleEvent {
if newState == oldState {
return nil
}
glog.V(4).Infof("GenericPLEG: %v/%v: %v -> %v", podID, cid, oldState, newState)
switch newState {
case plegContainerRunning:
return []*PodLifecycleEvent{{ID: podID, Type: ContainerStarted, Data: cid}}
case plegContainerExited:
return []*PodLifecycleEvent{{ID: podID, Type: ContainerDied, Data: cid}}
case plegContainerUnknown:
return []*PodLifecycleEvent{{ID: podID, Type: ContainerChanged, Data: cid}}
case plegContainerNonExistent:
switch oldState {
case plegContainerExited:
// We already reported that the container died before.
return []*PodLifecycleEvent{{ID: podID, Type: ContainerRemoved, Data: cid}}
default:
return []*PodLifecycleEvent{{ID: podID, Type: ContainerDied, Data: cid}, {ID: podID, Type: ContainerRemoved, Data: cid}}
}
default:
panic(fmt.Sprintf("unrecognized container state: %v", newState))
}
}
  • plegCh

plegCh 有数据后,调用 HandlePodSyncs 处理之

  • UpdatePod
1
2
3
4
5
// if a request to kill a pod is pending, we do not let anything overwrite that request.
update, found := p.lastUndeliveredWorkUpdate[pod.UID]
if !found || update.UpdateType != kubetypes.SyncPodKill {
p.lastUndeliveredWorkUpdate[pod.UID] = *options
}

即更新 lastUndeliveredWorkUpdate

HandlePodSyncs 执行结束之后(同步)

如果容器挂掉,则执行清理动作

1
2
3
4
5
if e.Type == pleg.ContainerDied {
if containerID, ok := e.Data.(string); ok {
kl.cleanUpContainersInPod(e.ID, containerID)
}
}

默认配置,只保留最新一个。若 pod 被 evicted,或者是 DeletionTimestamp != nil && notRunning(apiPodStatus.ContainerStatuses),那么它的所有容器将会被删除

1
MaxPerPodContainerCount: 1

golang profiling (即剖析),golang 原生提供了 pprof 性能分析工具

前段时间分析了一个 apiserver 处理请求性能较低的问题,正是使用了 pprof 确定了问题点,从而解决了该问题

这次使用 etcd https://github.com/coreos/etcd 来举个例子,关于 pprof 的使用及可视化,Ref 中提到了 golang 性能分析大名鼎鼎的几篇 blog,建议先行参考,看了之后会对 golang 性能分析有个 overall 的思路

此篇并无太多 creative 之处

Show time

之前提到 golang 中自带了 pprof 采集代码,而且启用它们也非常简单

如果是一个 web server 的话,仅需要 import _ “net/http/pprof”,则会注册 pprof 相关的 handler

1
2
3
4
5
6
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
}

web server 启动后,即可调用 pprof 接口获取数据

1
wget http://localhost:6060/debug/pprof/profile -O profile-data

当然 curl 也行

1
curl http://localhost:6060/debug/pprof/profile -o profile-data

对于 etcd 来说,pprof 也已经集成,可通过启动参数指定开启

1
./etcd --enable-pprof

当然对于非 web server 类的 app,也可以使用 runtime/pprof 包中的方法输出 pprof 数据

go tool pprof

以 etcd v3.1.9 为例子, go1.10

采样 10s cpu profile 数据

1
curl http://localhost:2379/debug/pprof/profile?seconds=10 -o etcd-profile-10

使用 go tool pprof 分析

1
2
3
4
5
6
bash-3.2$ go tool pprof $GOPATH/src/github.com/coreos/etcd/bin/etcd etcd-profile-10
File: etcd
Type: cpu
Time: Aug 12, 2018 at 11:45am (CST)
Duration: 10s, Total samples = 40ms ( 0.4%)
Entering interactive mode (type "help" for commands, "o" for options)

注意传入对应的二进制文件,否则可能无法找到相应的方法

go tool pprof 常用命令 top / list,其中 list 方法是正则匹配的,能显示匹配上的方法的 profile 信息

Secure of pprof

注意到之前均使用的是 http 的 Protocol 访问 pprof 接口,如果 server 是 https 该怎么办?

搜索得知 golang 很快便会支持 go tool pprof with client certificates 了

cmd/pprof: add HTTPS support with client certificates: https://github.com/golang/go/issues/20939

Support HTTPS certs, keys and CAs: https://github.com/google/pprof/pull/261

当然如果 server 不要求 client certificates 的话,可以如此使用 go tool pprof 获取数据(注意 https+insecure)

1
go tool pprof -seconds 5 https+insecure://192.168.99.100:32473/debug/pprof/profile

如果要求 client certificates 的话,亦或是日常使用时,其实也没必要直接用 go tool pprof 获取数据,使用 wget / curl 同样可以下载,下载之后再使用 go tool pprof 或者是 go-torch 分析好了

而 curl 显然是支持传入 ca.crt / tls.crt / tls.key 的

1
curl --cacert ca.crt --cert ./tls.crt --key tls.key https://192.168.99.100:32473/debug/pprof/profile -O profile-data

Visual pprof

go tool pprof 命令行模式,并不是特别直观,如果可以图形化的展示各个方法的消耗情况,那么将能更快的确定问题所在

  • graphviz
1
brew install graphviz

安装 ok graphviz 之后

1
2
3
4
5
6
7
bash-3.2$ go tool pprof $GOPATH/src/github.com/coreos/etcd/bin/etcd etcd-profile-10
File: etcd
Type: cpu
Time: Aug 12, 2018 at 11:45am (CST)
Duration: 10s, Total samples = 40ms ( 0.4%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) web

即可在浏览器中显示 .svg 文件,浏览器中 Ctrl+s 保存到本地,即可传阅

web-pprof

  • go-torch

大名鼎鼎的火焰图 (flame-graph)

1
go get github.com/uber/go-torch

clone brandangregg 的火焰图生成脚本

1
git clone git@github.com:brendangregg/FlameGraph.git

生成火焰图

1
2
3
4
5
bash-3.2$ export PATH=$PATH:$GOPATH/src/github.com/brendangregg/FlameGraph
bash-3.2$
bash-3.2$ $GOPATH/bin/go-torch --file "torch.svg" etcd-profile-10
INFO[13:12:17] Run pprof command: go tool pprof -raw -seconds 30 etcd-profile-10
INFO[13:12:17] Writing svg to torch.svg

浏览器打开 torch.svg 即可

torch

个人觉得 flame-graph 更为直观,横向为各个方法消耗占比,纵向为调用栈上的各个方法消耗占比,一目了然,对应分析消耗较大的方法即可

Ref

golang pprof https://blog.golang.org/profiling-go-programs

pprof tools http://colobu.com/2017/03/02/a-short-survey-of-golang-pprof/

top

top 按 CPU 排序

1
2
top
shift + P

top 按 MEM 排序

1
2
top
shift + M

java utils

为 jdk 设置 JAVA_HOME
设置 jdk bin 至 PATH 中

1
2
3
4
// 查看 java 进程堆及 GC 情况
jstat
// 查看 java 进程中的线程情况
jstack

最后一顿排查,jstat 查看了堆内存情况,发现是 tomcat 启动参数 -Xms -Xmx 设置过大了,同一节点上还有其他进程,其他进程占用内存比较猛

Final, cheers !~

通过 part-1/2/3/4 的分析,可以确认 Server 这边的逻辑 ok,那么现在的确认问题的手段只能沿着网络路径逐级抓包了。此篇重点讲述如何在 Wireshark https://www.wireshark.org/download.html 中分析 WebSocket 流量

当然网上有挺多介绍,这里还是再说一遍,是为啥?因为其他文章大多数都是讲解的 ws:// 的,而现在我们面临的是 wss:// 的,显然有很大的不同

所以呢,简单在 display filter 中输入 websocket 是没法过滤出 WebSocket 流量的,毕竟 TLS encrypted 之后看到的全是 TCP 流量

ENV

1
2
3
Wireshark: 2.6.1
OS: macOS Sierra 10.12.6
WebSocketSite: https://www.websocket.org/echo.html

SSL decryted in WireShark

official doc:https://wiki.wireshark.org/SSL#Usingthe.28Pre.29-Master-Secret

step by step guide: https://jimshaver.net/2015/02/11/decrypting-tls-browser-traffic-with-wireshark-the-easy-way/

照着链接二配置一下即可

Capture network trafic through WireShark

1
2
3
export SSLKEYLOGFILE=/Users/zrss/Documents/BrowserSSLKeyLog/sslkeylog.log
open -a "Google Chrome"
wireshark

访问 https://www.websocket.org/echo.html,loading ok 之后

开启 Wireshark 捕获当前网卡流量

单击 Connect 连接 Server WebSocket,连接建立后,发送 Rock it with HTML5 WebSocket 消息,如下图所示

echo WebSocket

停止 Wireshark 捕获,display filter 中输入 http,寻找到 info 中有 101 Web Socket Protocol Handshake 字样的报文,右键 Follow 选中 SSL Stream 即可查看 WebSocket 的流量,如下图所示

websocket traffic

可见 WebSocket 为 TCP 之上与 HTTP 同层的应用层协议

  • TCP 三次握手建立 TCP 连接
  • SSL 握手协商加密机制
  • WebSocket 握手 (HTTP Upgrade) 建立 WebSocket 连接
  • 客户端 send MASKED WebSocket 上行报文
  • 服务端 echo WebSocket 下行报文

另外需要注意的是在 SSL 握手协商加密机制时,服务器端选择的加密套件为 TLS_RSA_WITH_AES_128_CBC_SHA (在 Server Hello 报文中可见)

为啥提到这个算法,因为在测试的时候,一开始是使用 https://socket.io/demos/chat/ 测试的,从 Chrome F12 控制台中可以看到有两个 WebSocket 请求,然而 Wireshark 似乎只能 decrypt 其中一个请求,而该请求服务器端选择的加密套件为 TLS_AES_128_GCM_SHA256

另外一个请求 (实际上的 chat 请求) 未能 decrypt,呃,不过不知道为啥,反复尝试了几次后,啥都 decrypt 不了了

Best Practice

所以这个 decrypt 实际上不一定靠谱,主要还是需要在生产环境上使用 tcpdump 工具抓取来自特定源 IP 的流量,然后通过与正常情况下的流量相比,识别出为 WebSocket 的流量,逐级排查,找到在哪一级组件上 WebSocket 报文丢掉即可

注意到 WebSocket RFC https://tools.ietf.org/html/rfc6455 中提到每个 WebSocket 请求均要求建立一个连接,另外从 Tomcat 7 WebSocket 实现上,可知每个 WebSocket 连接均会建立一个新的 socket 连接

因此在 Wireshark 中首先过滤出 SSL 的 Client Hello 报文

再通过 Client Hello 报文中的 srcport 字段过滤报文 (或者右键 Follow -> SSL Stream),正常的 WebSocket 报文模式,如下

  • SSL Handshake
  • HTTP Upgrade
  • HTTP Continuation

当然需要客户端构造容易识别的 WebSocket 流量模式,我在测试时,一般会持续输入某个字符,因此会有持续的 HTTP Continuation 报文

Summary

WebSocket 在生产环境中使用,最好不复用 HTTPS 443 端口,即 WebSocket 使用独立的网络架构,不复用之前 HTTP 的网络架构。毕竟 HTTP 的网络路径,一路上有各种防火墙,可得小心了

另外还发现了一个有趣的项目 noVNC https://github.com/novnc/noVNC,提供了在界面上远程登录主机的功能,而我们知道大多数 VNC Server 也支持 WebSocket 协议,因此 noVNC 也使用了 WebSocket 协议传输数据,要不支持的 Server,noVNC 有一个子项目:websockify https://github.com/novnc/websockify,将 WebSocket 流量转为 Socket 流量,以兼容不支持 WebSocket 协议的 VNC Server,有时间再研究一下了

The end of WebSocket in Tomcat 7 series

Other Ref

http://jsslkeylog.sourceforge.net/