背景
據我所知,目前 kubernetes 本身或者其它第三方社群都沒提供 kubernetes 的檔案系統。也就是說要從 kubernetes 的容器中下載或上傳檔案,需要先進入容器檢視目錄結構,然後再通過 kubectl cp 指令把檔案拷貝進或出容器。雖然說不太麻煩,但也不太方便。當時正好推出 .net 5 + blazor,就趁著這個機會使用 .net 5 + blazor 做一個 kubernetes 的開原始檔系統。
介面簡介
建立叢集
建立叢集其實就是上傳需要接管的 kubernetes 的 kubeconfig,並給叢集取個幫助區分的名字:
瀏覽、上傳、下載檔案
建立完叢集后,就可以方便地選擇叢集 -> 名稱空間 -> Pod -> 容器,然後瀏覽容器目錄,上傳檔案到容器,或者下載檔案到本地:
使用方法
- 克隆程式碼,https://github.com/ErikXu/kubernetes-filesystem
- 安裝 docker
- 執行 bash build.sh 指令
- 執行 bash pack.sh 指令
- 下載 kubectl 並儲存到 /usr/local/bin/kubectl
- 執行 bash run.sh 指令
程式碼目錄
├── build.sh # 構建指令碼 ├── docker # docker 目錄 │ └── Dockerfile # Dockerfile ├── pack.sh # 打包指令碼 ├── publish.sh # 釋出指令碼 ├── README_CN.md # 專案說明(中文) ├── README.md # 專案說明 ├── run.sh # 執行指令碼 └── src # 原始碼目錄 ├── Kubernetes.Filesystem.sln # 解決方案 ├── Web # Web 專案 │ ├── App.razor # 入口 APP │ ├── _Imports.razor # 引用檔案 │ ├── Pages │ │ ├── Cluster.razor # 叢集管理頁面 │ │ └── File.razor # 檔案管理頁面 │ ├── Shared │ │ ├── MainLayout.razor # 佈局檔案 │ │ ├── MainLayout.razor.css # 佈局樣式檔案 │ │ ├── NavMenu.razor # 導航檔案 │ │ ├── NavMenu.razor.css # 導航樣式檔案 │ │ └── SurveyPrompt.razor # 調查彈出框 │ ├── Web.csproj # Web 專案檔案 │ └── wwwroot │ ├── css # 樣式資料夾 │ ├── favicon.ico # icon 檔案 │ └── index.html # html 入口頁 └── WebApi # WebApi 專案 ├── appsettings.Development.json # 開發環境配置檔案 ├── appsettings.json # 配置檔案 ├── Controllers # 控制器目錄 │ ├── ClustersController.cs # 叢集控制器 │ ├── ContainersController.cs # 容器控制器 │ ├── FilesController.cs # 檔案控制器 │ ├── NamespacesController.cs # 名稱空間控制器 │ └── PodsController.cs # Pod 控制器 ├── Startup.cs # Startup 檔案 └── WebApi.csproj # WebApi 專案檔案
程式碼簡析
ClustersController
ClustersController 主要對叢集進行管理,叢集資訊使用 json 檔案儲存,路徑為:/root/k8s-config。
namespace WebApi { public class Program { public static readonly string ConfigDir = "/root/k8s-config"; ... } }
建構函式主要建立 cluster json 檔案及目錄,並把 json 內容反序列化成 cluster list。
private readonly string _configName = "cluster.json"; private List<Cluster> _clusters; public ClustersController() { _clusters = new List<Cluster>() if (!Directory.Exists(Program.ConfigDir)) { Directory.CreateDirectory(Program.ConfigDir); var configPath = Path.Combine(Program.ConfigDir, _configName); if (!System.IO.File.Exists(configPath)) { var json = JsonConvert.SerializeObject(_clusters); System.IO.File.WriteAllText(configPath, json); } else { var json = System.IO.File.ReadAllText(configPath); if (!string.IsNullOrWhiteSpace(json)) { _clusters = JsonConvert.DeserializeObject<List<Cluster>>(json); } } }
獲取叢集列表,直接返回 cluster json list。
[HttpGet] public IActionResult List() { return Ok(_clusters); }
獲取指定叢集的詳情資訊,並讀取 kubernetes 證書資訊進行展示。
[HttpGet("{id}")] public async Task<IActionResult> Get(string id) { var cluster = _clusters.SingleOrDefault(n => n.Id == id); if (cluster == null) { return BadRequest(new { Message = "Cluster is not existed!" }); } var certificatePath = Path.Combine(Program.ConfigDir, cluster.Name); var certificate = await System.IO.File.ReadAllTextAsync(certificatePath); cluster.Certificate = certificate; return Ok(cluster); }
獲取指定叢集的版本資訊,主要使用 .net process + kubernetes 的證書執行 kubectl version --short 指令獲取版本資訊。
[HttpGet("{id}/version")] public IActionResult GetVersion(string id) { var cluster = _clusters.SingleOrDefault(n => n.Id == id); if (cluster == null) { return BadRequest(new { Message = "Cluster is not existed!" }); } var configPath = Path.Combine(Program.ConfigDir, cluster.Name.ToLower()); var command = $"kubectl version --short --kubeconfig {configPath}"; var (code, message) = ExecuteCommand(command); if (code != 0) { return StatusCode(StatusCodes.Status500InternalServerError, new { Message = message }); } var lines = message.Split(Environment.NewLine); var version = new ClusterVersion { Client = lines[0].Replace("Client Version:", string.Empty).Trim(), Server = lines[1].Replace("Server Version:", string.Empty).Trim() }; version.ClientNum = double.Parse(version.Client.Substring(1, 4)); version.ServerNum = double.Parse(version.Server.Substring(1, 4)); return Ok(version); }
建立叢集,主要是上傳叢集證書,並把叢集資訊序列化成 json,並儲存到檔案。
[HttpPost] public async Task<IActionResult> Create([FromBody] Cluster cluster) { cluster.Name = cluster.Name.ToLower(); if (_clusters.Any(n => n.Name.Equals(cluster.Name))) { return BadRequest(new { Message = "Cluster name is existed!" }); } var certificatePath = Path.Combine(Program.ConfigDir, cluster.Name); await System.IO.File.WriteAllTextAsync(certificatePath, cluster.Certificate); cluster.Id = Guid.NewGuid().ToString(); cluster.Certificate = string.Empty; _clusters.Add(cluster); var json = JsonConvert.SerializeObject(_clusters); var configPath = Path.Combine(Program.ConfigDir, _configName); await System.IO.File.WriteAllTextAsync(configPath, json); return Ok(); }
更新叢集,主要是更新叢集證書及叢集資訊。
[HttpPut("{id}")] public async Task<IActionResult> Update(string id, [FromBody] Cluster form) { var cluster = _clusters.SingleOrDefault(n => n.Id == id); if (cluster == null) { return BadRequest(new { Message = "Cluster is not existed!" }); } var certificatePath = Path.Combine(Program.ConfigDir, cluster.Name); System.IO.File.Delete(certificatePath); cluster.Name = form.Name; certificatePath = Path.Combine(Program.ConfigDir, form.Name); await System.IO.File.WriteAllTextAsync(certificatePath, form.Certificate); var json = JsonConvert.SerializeObject(_clusters); var configPath = Path.Combine(Program.ConfigDir, _configName); await System.IO.File.WriteAllTextAsync(configPath, json); return Ok(); }
刪除叢集,主要是刪除叢集資訊,並清理叢集證書。
[HttpDelete("{id}")] public async Task<IActionResult> Delete(string id) { var cluster = _clusters.SingleOrDefault(n => n.Id == id); if (cluster == null) { return BadRequest(new { Message = "Cluster is not existed!" }); } _clusters.Remove(cluster); var certificatePath = Path.Combine(Program.ConfigDir, cluster.Name); System.IO.File.Delete(certificatePath); var configPath = Path.Combine(Program.ConfigDir, _configName); var json = JsonConvert.SerializeObject(_clusters); await System.IO.File.WriteAllTextAsync(configPath, json); return NoContent(); }
使用 .net process 執行 linux 指令的輔助函式。
private static (int, string) ExecuteCommand(string command) { var escapedArgs = command.Replace("\"", "\\\""); var process = new Process { StartInfo = new ProcessStartInfo { FileName = "/bin/sh", Arguments = $"-c \"{escapedArgs}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } }; process.Start(); process.WaitForExit(); var message = process.StandardOutput.ReadToEnd(); if (process.ExitCode != 0) { message = process.StandardError.ReadToEnd(); } return (process.ExitCode, message); }
NamespacesController
NamespacesController 比較簡單,主要是使用 kubernetes 證書 + kubernetes api 獲取叢集的名稱空間列表。
[HttpGet] public IActionResult List([FromQuery] string cluster) { var configPath = Path.Combine(Program.ConfigDir, cluster.ToLower()); if (!System.IO.File.Exists(configPath)) { return BadRequest(new { Message = "Cluster is not existed!" }); } var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(configPath); var client = new k8s.Kubernetes(config); var namespaces = client.ListNamespace().Items.Select(n => n.Metadata.Name); return Ok(namespaces); }
PodsController
PodsController 也比較簡單,主要是獲取指定名稱空間下的 pod 列表,用於級聯下拉選單。
[HttpGet] public IActionResult List([FromQuery] string cluster, [FromQuery] string @namespace) { var configPath = Path.Combine(Program.ConfigDir, cluster.ToLower()); if (!System.IO.File.Exists(configPath)) { return BadRequest(new { Message = "Cluster is not existed!" }); } var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(configPath); var client = new k8s.Kubernetes(config); var pods = client.ListNamespacedPod(@namespace).Items.Select(n => n.Metadata.Name); return Ok(pods); }
ContainersController
ContainersController 也比較簡單,主要是獲取指定 pod 裡的容器列表,用於級聯下拉選單。
[HttpGet] public IActionResult List([FromQuery] string cluster, [FromQuery] string @namespace, [FromQuery] string pod) { var configPath = Path.Combine(Program.ConfigDir, cluster.ToLower()); if (!System.IO.File.Exists(configPath)) { return BadRequest(new { Message = "Cluster is not existed!" }); } var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(configPath); var client = new k8s.Kubernetes(config); var specificPod = client.ListNamespacedPod(@namespace).Items.Where(n => n.Metadata.Name == pod).First(); var containers = specificPod.Spec.Containers.Select(n => n.Name); return Ok(containers); }
FilesController
FilesController 是最重要,同時也是稍微有點複雜的一個控制器。
獲取容器指定路徑的檔案列表,主要是呼叫 kubernetes api 的 exec 方法,執行指令 "ls -Alh --time-style long-iso {dir}" 獲得檔案內容資訊。
由於 exec 是互動式的,所以方法使用的是 web socket:
public async Task<IActionResult> List([FromQuery] string cluster, [FromQuery] string @namespace, [FromQuery] string pod, [FromQuery] string container, [FromQuery] string dir) { var configPath = Path.Combine(Program.ConfigDir, cluster.ToLower()); if (!System.IO.File.Exists(configPath)) { return BadRequest(new { Message = "Cluster is not existed!" }); } var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(configPath); var client = new k8s.Kubernetes(config); var webSocket = await client.WebSocketNamespacedPodExecAsync(pod, @namespace, new string[] { "ls", "-Alh", "--time-style", "long-iso", dir }, container).ConfigureAwait(false); var demux = new StreamDemuxer(webSocket); demux.Start(); var buff = new byte[4096]; var stream = demux.GetStream(1, 1); stream.Read(buff, 0, 4096); var bytes = TrimEnd(buff); var text = System.Text.Encoding.Default.GetString(bytes).Trim(); var files = ToFiles(text); return Ok(files); }
再看一下指令 "ls -Alh --time-style long-iso {dir}" 的一個例子:
~ ls -Alh --time-style long-iso /usr/bin total 107M lrwxrwxrwx. 1 root root 8 2019-02-21 10:47 ypdomainname -> hostname -rwxr-xr-x. 1 root root 62K 2018-10-30 17:55 ar drwxr-xr-x 8 root root 4.0K 2022-04-01 09:37 scripts
第一行 total 開頭的資訊不太重要可以忽略,從第二行可以看出,每一行的格式是固定的。
第 0 列:許可權,l 開頭表示是連結,- 開頭表示是檔案,d 開頭表示是資料夾
第 1 列:link 數量
第 2 列:使用者
第 3 例:組
第 4 列:檔案(夾)大小
第 5 列:日期
第 6 列:時間
第 7 列:檔案(夾)名稱
如果記錄型別為 l,則檔名稱為 7,8,9 列合成。
因此,解析程式碼如下:
private static List<FileItem> ToFiles(string text) { var files = new List<FileItem>(); var lines = text.Split(Environment.NewLine); foreach (var line in lines) { if (line.StartsWith("total")) { continue; } var trimLine = line.Trim(); var array = trimLine.Split(" ").ToList().Where(n => !string.IsNullOrWhiteSpace(n)).ToList(); var file = new FileItem { Permission = array[0], Links = array[1], Owner = array[2], Group = array[3], Size = array[4], Date = array[5], Time = array[6], Name = array[7] }; if (file.Permission.StartsWith("l")) { file.Name = $"{array[7]} {array[8]} {array[9]}"; } files.Add(file); } return files; }
上傳檔案主要是把檔案上傳到伺服器,再使用 kubectl cp 指令把檔案拷貝到指定容器中:
[HttpPost("upload")] public async Task<IActionResult> UploadFile([FromQuery] string cluster, [FromQuery] string @namespace, [FromQuery] string pod, [FromQuery] string container, [FromQuery] string dir, IFormFile file) { var configPath = Path.Combine(Program.ConfigDir, cluster.ToLower()); if (!System.IO.File.Exists(configPath)) { return BadRequest(new { Message = "Cluster is not existed!" }); } var tmpPath = Path.Combine("/tmp", System.Guid.NewGuid().ToString()); await using (var stream = System.IO.File.Create(tmpPath)) { await file.CopyToAsync(stream); } var path = Path.Combine(dir, file.FileName); var command = $"kubectl cp {tmpPath} {pod}:{path} -c {container} -n {@namespace} --kubeconfig {configPath}"; var (code, message) = ExecuteCommand(command); System.IO.File.Delete(tmpPath); if (code == 0) { return Ok(); } return StatusCode(StatusCodes.Status500InternalServerError, new { Message = message }); }
下載檔案主要是使用 kubectl cp 指令把檔案從容器拷貝到伺服器,再把檔案讀取下載:
[HttpGet("download")] public async Task<IActionResult> DownloadFile([FromQuery] string cluster, [FromQuery] string @namespace, [FromQuery] string pod, [FromQuery] string container, [FromQuery] string path) { var configPath = Path.Combine(Program.ConfigDir, cluster.ToLower()); if (!System.IO.File.Exists(configPath)) { return BadRequest(new { Message = "Cluster is not existed!" }); } var tmpPath = Path.Combine("/tmp", System.Guid.NewGuid().ToString()); var command = $"kubectl cp {pod}:{path} {tmpPath} -c {container} -n {@namespace} --kubeconfig {configPath}"; var (code, message) = ExecuteCommand(command); if (code != 0) { return StatusCode(StatusCodes.Status500InternalServerError, new { Message = message }); } var memory = new MemoryStream(); using (var stream = new FileStream(tmpPath, FileMode.Open)) { await stream.CopyToAsync(memory); } memory.Position = 0; var contentType = GetContentType(tmpPath); System.IO.File.Delete(tmpPath); return File(memory, contentType); }
一個小插曲
有個哥們提了一個 issue 提到 kubernetes 在 1.20 引入了一個新指令 "kubectl debug",目的是為了解決容器中未安裝 bash 或者 sh 的問題。因此在新版本獲取檔案列表的方法中,我也實現了該指令:
[HttpGet] public async Task<IActionResult> List([FromQuery] string cluster, [FromQuery] string @namespace, [FromQuery] string pod, [FromQuery] string container, [FromQuery] string dir) { var configPath = Path.Combine(Program.ConfigDir, cluster.ToLower()); if (!System.IO.File.Exists(configPath)) { return BadRequest(new { Message = "Cluster is not existed!" }); } var command = $"kubectl version --short --kubeconfig {configPath}"; var (code, message) = ExecuteCommand(command); if (code != 0) { return StatusCode(StatusCodes.Status500InternalServerError, new { Message = message }); } var lines = message.Split(Environment.NewLine); var version = new ClusterVersion { Client = lines[0].Replace("Client Version:", string.Empty).Trim(), Server = lines[1].Replace("Server Version:", string.Empty).Trim() }; version.ClientNum = double.Parse(version.Client.Substring(1, 4)); version.ServerNum = double.Parse(version.Server.Substring(1, 4)); var text = string.Empty; if (version.ClientNum >= 1.2 && version.ServerNum >= 1.2) { command = $"kubectl debug -it {pod} -n {@namespace} --image=centos --target={container} --kubeconfig {configPath} -- sh -c 'ls -Alh --time-style long-iso {dir}'"; (code, message) = ExecuteCommand(command); if (code != 0) { return StatusCode(StatusCodes.Status500InternalServerError, new { Message = message }); } text = message; } else { var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(configPath); var client = new k8s.Kubernetes(config); var webSocket = await client.WebSocketNamespacedPodExecAsync(pod, @namespace, new string[] { "ls", "-Alh", "--time-style", "long-iso", dir }, container).ConfigureAwait(false); var demux = new StreamDemuxer(webSocket); demux.Start(); var buff = new byte[4096]; var stream = demux.GetStream(1, 1); stream.Read(buff, 0, 4096); var bytes = TrimEnd(buff); text = System.Text.Encoding.Default.GetString(bytes).Trim(); } var files = ToFiles(text); return Ok(files); }
但意料之外的是,kubectl debug 裡獲取到的檔案列表和容器的檔案列表不一致,因此,如果想使用舊版本的方式,請使用 d293e34 版本的程式碼。
Cluster.razor 和 File.razor
介面部分相對比較簡單,基本就是 Bootstrap 和一些 http client 的呼叫,請大家自行去查閱即可,有問題可以留言討論。
專案地址
https://github.com/ErikXu/kubernetes-filesystem
歡迎大家 star,提 pr,提 issue,在文章留言交流,或者在公眾號 - 跬步之巔留言交流。