Montagem dinâmica e implantação de imagens do Docker com werf usando o site de exemplo da documentação com versão

Já falamos sobre a ferramenta GitOps do werf mais de uma vez , mas desta vez gostaríamos de compartilhar a experiência de criar o site com a documentação do projeto - werf.io (a versão russa é ru.werf.io ). Este é um site estático comum, mas sua montagem é interessante porque é construída usando um número dinâmico de artefatos.



Entre nas nuances da estrutura do site: gerando um menu geral para todas as versões, páginas com informações sobre lançamentos, etc. - nós não vamos. Em vez disso, focamos nos problemas e recursos da montagem dinâmica e um pouco nos processos de CI / CD que o acompanham.

Introdução: como o site está organizado


Para começar, a documentação do werf é armazenada junto com seu código. Isso cria certos requisitos de desenvolvimento que geralmente vão além do escopo deste artigo, mas pelo menos podemos dizer que:

  • As novas funções do werf não devem ser liberadas sem atualizar a documentação e, inversamente, quaisquer alterações na documentação implicam o lançamento de uma nova versão do werf;
  • O projeto tem um desenvolvimento bastante intenso: novas versões podem sair várias vezes ao dia;
  • Qualquer implantação manual de um site com uma nova versão da documentação é pelo menos entediante;
  • O projeto adotou a abordagem de versionamento semântico, com 5 canais de estabilidade. O processo de liberação envolve a passagem seqüencial de versões pelos canais, a fim de aumentar a estabilidade: do alfa ao sólido;
  • O site possui uma versão em russo, que "vive e se desenvolve" (ou seja, cujo conteúdo é atualizado) em paralelo com a versão principal (ou seja, em inglês).

Para ocultar ao usuário toda essa "cozinha interna", oferecendo a ele algo que "simplesmente funciona", fizemos uma ferramenta separada de instalação e atualização do werf - este é o multiwerf . Basta indicar o número do release e o canal de estabilidade que você está pronto para usar, e o multiwerf verificará se há uma nova versão no canal e fará o download, se necessário.

A versão mais recente do werf em cada canal está disponível no menu de seleção de versão no site. Por padrão, a versão do canal mais estável para a versão mais recente é aberta em werf.io/documentation - também é indexada pelos mecanismos de pesquisa. A documentação do canal está disponível em endereços individuais (por exemplo, werf.io/v1.0-beta/documentation para a versão beta 1.0).

Total, o site tem as seguintes versões:

  1. raiz (abre por padrão)
  2. para cada canal de atualização ativo para cada versão (por exemplo, werf.io/v1.0-beta ).

Para gerar uma versão específica de um site no caso geral, basta compilá-lo usando as ferramentas Jekyll executando o comando apropriado ( jekyll build ) no diretório /docs do repositório werf, após alternar para a tag Git da versão requerida.

Resta apenas acrescentar que:

  • o próprio utilitário (werf) é usado para montagem;
  • Os processos de IC / CD são baseados no IC do GitLab;
  • e tudo isso, é claro, funciona em Kubernetes.

As tarefas


Agora, formulamos tarefas que levam em conta todas as especificidades descritas:

  1. Após alterar a versão do werf em qualquer canal de atualização, a documentação no site deve ser atualizada automaticamente .
  2. Para o desenvolvimento, você precisa poder ocasionalmente visualizar versões preliminares do site .

A recompilação do site deve ser realizada após a alteração da versão em qualquer canal das tags Git correspondentes, mas no processo de criação da imagem, obteremos os seguintes recursos:

  • Como a lista de versões nos canais está mudando, é necessário remontar a documentação dos canais nos quais a versão foi alterada. Afinal, remontar tudo de novo não é muito bonito.
  • O conjunto de canais para lançamentos pode variar. Em algum momento, por exemplo, a versão nos canais pode não ser mais estável que a versão 1.1 do acesso antecipado, mas com o tempo eles aparecerão - não altere o conjunto manualmente neste caso?

Acontece que a montagem depende da alteração de dados externos .

Implementação


Escolha da abordagem


Como alternativa, você pode executar cada versão necessária com um pod separado no Kubernetes. Essa opção implica em um número maior de objetos no cluster, que crescerá com um aumento no número de liberações de werf estáveis. E isso, por sua vez, implica um serviço mais complexo: cada versão possui seu próprio servidor HTTP e com uma pequena carga. Obviamente, isso implica custos mais altos para os recursos.

Nós seguimos o caminho de reunir todas as versões necessárias em uma imagem . As estáticas compiladas de todas as versões do site estão em um contêiner com o NGINX, e o tráfego para a Implantação correspondente é feito pelo NGINX Ingress. Uma estrutura simples - um aplicativo sem estado - facilita o dimensionamento da implantação (dependendo da carga) usando o próprio Kubernetes.

Para ser mais preciso, coletamos duas imagens: uma para o circuito de produção e outra para o circuito de desenvolvimento. Uma imagem adicional é usada (lançada) apenas no circuito dev juntamente com a principal e contém a versão do site a partir da confirmação de revisão, e o roteamento entre eles é realizado usando os recursos do Ingress.

artefatos e clone werf vs git


Como já mencionado, para gerar estática do site para uma versão específica da documentação, você precisa construir alternando para a tag do repositório correspondente. Também é possível fazer isso clonando o repositório todas as vezes durante a montagem, selecionando as tags apropriadas na lista. No entanto, essa é uma operação que consome bastante recursos e, além disso, requer a gravação de instruções não triviais ... Outro ponto negativo grave - com essa abordagem, não há como armazenar algo em cache durante a montagem.

Aqui, o utilitário werf vem em nosso auxílio, o que implementa o cache inteligente e permite o uso de repositórios externos . O uso do werf para adicionar código do repositório acelerará significativamente a compilação, pois O werf essencialmente faz a clonagem de repositório uma vez e, em seguida, apenas fetch se necessário. Além disso, ao adicionar dados do repositório, podemos selecionar apenas os diretórios necessários (no nosso caso, este é o diretório docs ), o que reduzirá significativamente a quantidade de dados adicionados.

Como Jekyll é uma ferramenta projetada para compilar estática e não é necessária na imagem final, seria lógico compilar no artefato werf e importar apenas o resultado da compilação na imagem final.

Escrevendo werf.yaml


Então, decidimos que iremos compilar cada versão em um artefato werf separado. No entanto, não sabemos quantos desses artefatos serão durante a montagem ; portanto, não podemos escrever uma configuração de montagem fixa (a rigor, ainda podemos, mas não será completamente eficaz).

O werf permite que você use Go-templates no seu arquivo de configuração ( werf.yaml ), e isso possibilita gerar uma configuração "on the fly", dependendo dos dados externos (o que você precisa!). Dados externos em nosso caso são informações sobre versões e liberações, com base nas quais coletamos o número necessário de artefatos e, como resultado, obtemos duas imagens: werf-doc e werf-dev para rodar em caminhos diferentes.

Dados externos são passados ​​por variáveis ​​de ambiente. Aqui está sua composição:

  • RELEASES - uma linha com uma lista de lançamentos e a versão atual correspondente do werf, na forma de uma lista, separada por um espaço, no formato <_>%<_> . Exemplo: 1.0%v1.0.4-beta.20
  • CHANNELS - uma linha com uma lista de canais e a versão atual correspondente do werf, na forma de uma lista com um espaço de valores no formato <>%<_> . Exemplo: 1.0-beta%v1.0.4-beta.20 1.0-alpha%v1.0.5-alpha.22
  • ROOT_VERSION - A versão do werf release para exibição por padrão no site (nem sempre é necessário exibir a documentação para o número de release mais alto). Exemplo: v1.0.4-beta.20
  • REVIEW_SHA - hash do commit de revisão a partir do qual você precisa coletar a versão para o loop de teste.

Essas variáveis ​​serão preenchidas no IC do GitLab do pipeline e como exatamente é descrito abaixo.

Primeiro, por conveniência, definimos variáveis ​​de modelo Go em werf.yaml atribuindo valores a partir de variáveis ​​de ambiente:

 {{ $_ := set . "WerfVersions" (cat (env "CHANNELS") (env "RELEASES") | splitList " ") }} {{ $Root := . }} {{ $_ := set . "WerfRootVersion" (env "ROOT_VERSION") }} {{ $_ := set . "WerfReviewCommit" (env "REVIEW_SHA") }} 

A descrição do artefato para compilar a estática da versão do site geralmente é a mesma para todos os casos que precisamos (incluindo a geração da versão raiz, bem como a versão do circuito dev). Portanto, nós o colocaremos em um bloco separado usando a função define - para reutilização subsequente com include . Passaremos os seguintes argumentos para o modelo:

  • Version gerada por versão (nome da tag);
  • Channel - o nome do canal de atualização para o qual o artefato é gerado;
  • Commit - hash de confirmação se artefato for gerado para confirmação de revisão;
  • contexto.

Descrição do Modelo de Artefato
 {{- define "doc_artifact" -}} {{- $Root := index . "Root" -}} artifact: doc-{{ .Channel }} from: jekyll/builder:3 mount: - from: build_dir to: /usr/local/bundle ansible: install: - shell: | export PATH=/usr/jekyll/bin/:$PATH - name: "Install Dependencies" shell: bundle install args: executable: /bin/bash chdir: /app/docs beforeSetup: {{- if .Commit }} - shell: echo "Review SHA - {{ .Commit }}." {{- end }} {{- if eq .Channel "root" }} - name: "releases.yml HASH: {{ $Root.Files.Get "releases.yml" | sha256sum }}" copy: content: | {{ $Root.Files.Get "releases.yml" | indent 8 }} dest: /app/docs/_data/releases.yml {{- else }} - file: path: /app/docs/_data/releases.yml state: touch {{- end }} - file: path: "{{`{{ item }}`}}" state: directory mode: 0777 with_items: - /app/main_site/ - /app/ru_site/ - file: dest: /app/docs/pages_ru/cli state: link src: /app/docs/pages/cli - shell: | echo -e "werfVersion: {{ .Version }}\nwerfChannel: {{ .Channel }}" > /tmp/_config_additional.yml export PATH=/usr/jekyll/bin/:$PATH {{- if and (ne .Version "review") (ne .Channel "root") }} {{- $_ := set . "BaseURL" ( printf "v%s" .Channel ) }} {{- else if ne .Channel "root" }} {{- $_ := set . "BaseURL" .Channel }} {{- end }} jekyll build -s /app/docs -d /app/_main_site/{{ if .BaseURL }} --baseurl /{{ .BaseURL }}{{ end }} --config /app/docs/_config.yml,/tmp/_config_additional.yml jekyll build -s /app/docs -d /app/_ru_site/{{ if .BaseURL }} --baseurl /{{ .BaseURL }}{{ end }} --config /app/docs/_config.yml,/app/docs/_config_ru.yml,/tmp/_config_additional.yml args: executable: /bin/bash chdir: /app/docs git: - url: https://github.com/flant/werf.git to: /app/ owner: jekyll group: jekyll {{- if .Commit }} commit: {{ .Commit }} {{- else }} tag: {{ .Version }} {{- end }} stageDependencies: install: ['docs/Gemfile','docs/Gemfile.lock'] beforeSetup: '**/*' includePaths: 'docs' excludePaths: '**/*.sh' {{- end }} 

O nome do artefato deve ser exclusivo. Podemos conseguir isso, por exemplo, adicionando o nome do canal (valor da variável .Channel ) como um sufixo para o nome do artefato: artifact: doc-{{ .Channel }} . Mas você precisa entender que, ao importar de artefatos, será necessário consultar os mesmos nomes.

Ao descrever um artefato, um recurso werf como mount é usado . A montagem com o diretório de serviço build_dir permite salvar o cache Jekyll entre as partidas do pipeline, o que acelera bastante a reconstrução .

Você também deve ter notado o uso do arquivo releases.yml - este é o arquivo YAML com os dados da liberação solicitados no github.com (o artefato obtido pela execução do pipeline). É necessário ao compilar o site, mas no contexto do artigo, estamos interessados ​​no fato de que apenas um artefato , o artefato raiz da versão do site , depende de seu estado (em outros artefatos não é necessário).

Isso é implementado usando o operador condicional para modelos {{ $Root.Files.Get "releases.yml" | sha256sum }} go e ​​o {{ $Root.Files.Get "releases.yml" | sha256sum }} {{ $Root.Files.Get "releases.yml" | sha256sum }} no palco . Isso funciona da seguinte maneira: ao montar um artefato para a versão raiz (a variável .Channel é root ), o hash do arquivo releases.yml afeta a assinatura de todo o estágio, pois é um componente do nome do trabalho Ansible (parâmetro name ). Portanto, ao alterar o conteúdo do arquivo releases.yml , o artefato correspondente será reconstruído.

Preste atenção também ao trabalho com um repositório externo. Somente o diretório /docs é incluído na imagem do artefato do repositório werf e, dependendo dos parâmetros passados, os dados da tag ou confirmação necessária são adicionados imediatamente.

Para usar o modelo de artefato para gerar uma descrição de artefato das versões transferidas de canais e liberações, organizamos um loop na variável .WerfVersions em werf.yaml :

 {{ range .WerfVersions -}} {{ $VersionsDict := splitn "%" 2 . -}} {{ dict "Version" $VersionsDict._1 "Channel" $VersionsDict._0 "Root" $Root | include "doc_artifact" }} --- {{ end -}} 

Porque o loop irá gerar vários artefatos (esperamos que sim), é necessário levar em consideração o separador entre eles - a sequência --- (para obter mais informações sobre a sintaxe do arquivo de configuração, consulte a documentação ). Conforme determinado anteriormente, quando você chama o modelo em um loop, passamos os parâmetros de versão, URL e contexto raiz.

Da mesma forma, mas já sem um loop, chamamos o modelo de artefato para "casos especiais": para a versão raiz, bem como a versão do commit de revisão:

 {{ dict "Version" .WerfRootVersion "Channel" "root" "Root" $Root | include "doc_artifact" }} --- {{- if .WerfReviewCommit }} {{ dict "Version" "review" "Channel" "review" "Commit" .WerfReviewCommit "Root" $Root | include "doc_artifact" }} {{- end }} 

Observe que o artefato para a confirmação de revisão será coletado apenas se a variável .WerfReviewCommit estiver .WerfReviewCommit .

Os artefatos estão prontos - é hora de importar!

A imagem final, projetada para ser executada no Kubernetes, é um NGINX regular, no qual o arquivo de configuração do servidor nginx.conf e as estáticas dos artefatos são adicionados. Além do artefato da versão raiz do site, precisamos repetir o loop na variável .WerfVersions para importar artefatos das versões dos canais e releases + observe a regra de nomenclatura do artefato que adotamos anteriormente. Como cada artefato armazena versões do site para dois idiomas, nós os importamos para os locais fornecidos pela configuração.

Descrição da imagem final do werf-doc
 image: werf-doc from: nginx:stable-alpine ansible: setup: - name: "Setup /etc/nginx/nginx.conf" copy: content: | {{ .Files.Get ".werf/nginx.conf" | indent 8 }} dest: /etc/nginx/nginx.conf - file: path: "{{`{{ item }}`}}" state: directory mode: 0777 with_items: - /app/main_site/assets - /app/ru_site/assets import: - artifact: doc-root add: /app/_main_site to: /app/main_site before: setup - artifact: doc-root add: /app/_ru_site to: /app/ru_site before: setup {{ range .WerfVersions -}} {{ $VersionsDict := splitn "%" 2 . -}} {{ $Channel := $VersionsDict._0 -}} {{ $Version := $VersionsDict._1 -}} - artifact: doc-{{ $Channel }} add: /app/_main_site to: /app/main_site/v{{ $Channel }} before: setup {{ end -}} {{ range .WerfVersions -}} {{ $VersionsDict := splitn "%" 2 . -}} {{ $Channel := $VersionsDict._0 -}} {{ $Version := $VersionsDict._1 -}} - artifact: doc-{{ $Channel }} add: /app/_ru_site to: /app/ru_site/v{{ $Channel }} before: setup {{ end -}} 

A imagem adicional, que, juntamente com a imagem principal, é lançada no circuito de desenvolvimento, contém apenas duas versões do site: a versão do commit de revisão e a versão raiz do site (existem ativos gerais e, se você se lembrar, libera dados). Assim, a imagem adicional da imagem principal diferirá apenas na seção de importação (e, é claro, no nome):

 image: werf-dev ... import: - artifact: doc-root add: /app/_main_site to: /app/main_site before: setup - artifact: doc-root add: /app/_ru_site to: /app/ru_site before: setup {{- if .WerfReviewCommit }} - artifact: doc-review add: /app/_main_site to: /app/main_site/review before: setup - artifact: doc-review add: /app/_ru_site to: /app/ru_site/review before: setup {{- end }} 

Como já mencionado acima, o artefato para a confirmação de revisão será gerado apenas quando o werf iniciar com a variável de ambiente REVIEW_SHA . Seria possível não gerar uma imagem werf-dev, se não REVIEW_SHA ambiente REVIEW_SHA , mas, para que a limpeza de imagem baseada em werf, para que as imagens do Docker funcionem para a imagem werf-dev, deixamos que seja coletada apenas com o artefato da versão raiz (de qualquer forma, já montado), para simplificar a estrutura do oleoduto.

Montagem está pronta! Passamos para o CI / CD e nuances importantes.

Pipeline no IC do GitLab e recursos de montagem dinâmica


Ao iniciar a montagem, precisamos definir as variáveis ​​de ambiente usadas no werf.yaml . Isso não se aplica à variável REVIEW_SHA, que definiremos quando o pipeline for chamado a partir do gancho do GitHub.

Geraremos os dados externos necessários no script Bash generate_artifacts , que gerará dois artefatos do GitLab de pipeline:

  • arquivo releases.yml com dados da liberação,
  • arquivo common_envs.sh contendo variáveis ​​de ambiente para exportação.

Você encontrará o conteúdo do arquivo generate_artifacts em nosso repositório de exemplo . A obtenção de dados não é o assunto do artigo, mas o arquivo common_envs.sh é importante para nós, porque o trabalho de werf depende disso. Um exemplo de seu conteúdo:

 export RELEASES='1.0%v1.0.6-4' export CHANNELS='1.0-alpha%v1.0.7-1 1.0-beta%v1.0.7-1 1.0-ea%v1.0.6-4 1.0-stable%v1.0.6-4 1.0-rock-solid%v1.0.6-4' export ROOT_VERSION='v1.0.6-4' 

Você pode usar a saída desse script, por exemplo, usando a função Bash de source .

E agora a parte divertida. Para que os aplicativos de compilação e implantação funcionem corretamente, você deve tornar o werf.yaml mesmo para pelo menos um pipeline . Se essa condição não for atendida, as assinaturas dos estágios que foram calculados durante a montagem e, por exemplo, a implantação, serão diferentes. Isso levará a um erro de implantação, pois a imagem necessária para implantação estará ausente.

Em outras palavras, se durante a montagem da imagem do site as informações sobre releases e versões forem uma, e no momento do release uma nova versão for lançada e as variáveis ​​de ambiente tiverem valores diferentes, a implantação falhará: afinal, o artefato da nova versão ainda não foi coletado.

Se a geração do werf.yaml depender de dados externos (por exemplo, uma lista de versões atuais, como no nosso caso), a composição e os valores desses dados deverão ser registrados no pipeline. Isso é especialmente importante se os parâmetros externos mudarem com bastante frequência.

Receberemos e capturaremos dados externos no primeiro estágio do pipeline no GitLab ( Prebuild ) e os transferiremos ainda mais como um artefato de IC do GitLab . Isso permitirá que você inicie e reinicie as tarefas de pipeline (compilação, implantação, limpeza) com a mesma configuração no werf.yaml .

O conteúdo do estágio Prebuild do arquivo .gitlab - ci.yml :

 Prebuild: stage: prebuild script: - bash ./generate_artifacts 1> common_envs.sh - cat ./common_envs.sh artifacts: paths: - releases.yml - common_envs.sh expire_in: 2 week 

Ao capturar dados externos em um artefato, você pode construir e implantar usando os estágios de pipeline do IC do GitLab padrão: Build and Deploy. Iniciamos o pipeline por ganchos do repositório gerHub werf (ou seja, ao alterar o repositório no GitHub). Os dados para eles podem ser obtidos nas propriedades do projeto GitLab na seção Configurações de CI / CD -> gatilhos de pipeline e, em seguida, criar o Webhook correspondente ( Configurações -> Webhooks ) no GitHub.

O estágio de construção terá a seguinte aparência:

 Build: stage: build script: - type multiwerf && . $(multiwerf use 1.0 alpha --as-file) - type werf && source <(werf ci-env gitlab --tagging-strategy tag-or-branch --verbose) - source common_envs.sh - werf build-and-publish --stages-storage :local except: refs: - schedules dependencies: - Prebuild 

O GitLab adicionará dois artefatos do estágio Pré -construção à fase de construção, portanto exportamos as variáveis ​​com entrada preparada usando a source common_envs.sh . Iniciamos a fase de montagem em todos os casos, exceto no lançamento do pipeline dentro do prazo. De acordo com o cronograma, o oleoduto será lançado para limpeza - não precisamos construir neste caso.

No estágio de implantação, descrevemos duas tarefas - separadamente para a implantação nos circuitos de produção e desenvolvimento, usando o modelo YAML:

 .base_deploy: &base_deploy stage: deploy script: - type multiwerf && . $(multiwerf use 1.0 alpha --as-file) - type werf && source <(werf ci-env gitlab --tagging-strategy tag-or-branch --verbose) - source common_envs.sh - werf deploy --stages-storage :local dependencies: - Prebuild except: refs: - schedules Deploy to Production: <<: *base_deploy variables: WERF_KUBE_CONTEXT: prod environment: name: production url: werf.io only: refs: - master except: variables: - $REVIEW_SHA refs: - schedules Deploy to Test: <<: *base_deploy variables: WERF_KUBE_CONTEXT: dev environment: name: test url: werf.test.flant.com except: refs: - schedules only: variables: - $REVIEW_SHA 

As tarefas essencialmente diferem apenas indicando o contexto do cluster no qual o werf deve executar a implantação ( WERF_KUBE_CONTEXT ) e configurando as variáveis ​​de ambiente do contorno ( environment.name e environment.url ), que são usadas nos modelos de gráfico Helm. O conteúdo dos modelos não será fornecido, porque não há nada interessante para este tópico, mas você pode encontrá-los no repositório do artigo .

Toque final


Como as versões werf são lançadas com bastante frequência, novas imagens geralmente são coletadas e o Docker Registry cresce constantemente. Portanto, é necessário configurar a limpeza automática de imagens por política. É muito fácil de fazer.

Para implementação, você precisará de:

  • Adicione uma etapa de purificação ao .gitlab-ci.yml ;
  • Adicione tarefas de limpeza periódicas;
  • Defina a variável de ambiente com token de acesso de gravação.

Adicione o estágio de limpeza ao .gitlab-ci.yml :

 Cleanup: stage: cleanup script: - type multiwerf && . $(multiwerf use 1.0 alpha --as-file) - type werf && source <(werf ci-env gitlab --tagging-strategy tag-or-branch --verbose) - source common_envs.sh - docker login -u nobody -p ${WERF_IMAGES_CLEANUP_PASSWORD} ${WERF_IMAGES_REPO} - werf cleanup --stages-storage :local only: refs: - schedules 

Quase todos nós já vimos isso um pouco mais alto - apenas para a limpeza, é necessário primeiro fazer logon no Docker Registry com um token com direitos para excluir imagens no Docker Registry (o token de tarefa do GitLab CI emitido automaticamente não possui esses direitos). O token deve ser inserido no GitLab antecipadamente e seu valor deve ser especificado na variável de ambiente WERF_IMAGES_CLEANUP_PASSWORD projeto (Configurações de CI / CD -> Variáveis) .

A adição de uma tarefa de limpeza com a programação necessária é feita em CI / CD ->
Horários

É isso: o projeto no Docker Registry não crescerá mais constantemente a partir de imagens não utilizadas.

No final da parte prática, lembro que as listagens completas do artigo estão disponíveis no Git :


Resultado


  1. Temos uma estrutura de construção lógica: um artefato por versão.
  2. A montagem é universal e não requer alterações manuais quando novas versões do werf são lançadas: a documentação no site é atualizada automaticamente.
  3. São coletadas duas imagens para contornos diferentes.
  4. Funciona rápido porque o armazenamento em cache é usado ao máximo - quando uma nova versão do werf é lançada ou um gancho do GitHub é chamado para uma confirmação de revisão, apenas o artefato correspondente com uma versão modificada é reconstruído.
  5. Não é necessário pensar em excluir imagens não utilizadas: a limpeza da política werf manterá a ordem no Docker Registry.

Conclusões


  • O uso do werf permite que o assembly trabalhe rapidamente, graças ao armazenamento em cache do próprio assembly e ao trabalhar com repositórios externos.
  • Git- . werf , fetch .
  • Go- werf.yaml , .
  • werf — , pipeline.
  • werf , .

PS


:

Source: https://habr.com/ru/post/pt478690/


All Articles