Исполнение SSH-команд на сотнях серверов с помощью Go

    О чём статья


    В этой статье мы с вами напишем простенькую программу на Go (в 100 строк), которая может исполнять команды через протокол SSH на сотнях серверов, делая это достаточно эффективно. Программа будет реализована с помощью go.crypto/ssh — реализации SSH протокола авторами Go.

    Более «продвинутая» версия программы, написанной в этой статье, доступна на гитхабе под названием GoSSHa (Go SSH agent).

    Введение


    В компании, в которой я работаю, чуть больше, чем 1 сервер, и для эффективной работы с нашим количеством серверов по протоколу SSH была написана библиотека libpssh на основе libssh2. Эта библиотека была написана на C с использованием libevent много лет назад, и до сих пор хорошо справляется со своими обязанностями, но является весьма сложной в поддержке. Также, язык Go от компании Google начал набирать популярность, в том числе внутри нашей компании, поэтому я решил попробовать написать замену libpssh на Go, и исправить некоторые её недостатки, заодно значительно упростив код и сложность поддержки.

    Чтобы начать работать, нам потребуется компилятор языка Go (можно скачать по адресу golang.org) и работающая команда hg, чтобы скачать go.crypto/ssh с помощью «go get».

    Начало работы


    Создадим файл «main.go» в какой-нибудь директории, желательно пустой. Давайте теперь напишем «каркас» нашей программы, а потом реализуем недостающие функции по ходу статьи:

    package main
    
    import (
    	"code.google.com/p/go.crypto/ssh"
    	// ...
    )
    
    // ...
    
    func main() {
    	cmd := os.Args[1] // первый аргумент - команда, которую мы исполним на всех серверах
    	hosts := os.Args[2:] // остальные аргументы (начиная со второго) - список серверов
    
    	results := make(chan string, 10) // будем записывать результаты в буферизированный канал строк
    	timeout := time.After(5 * time.Second) // через 5 секунд в канал timeout придет сообщение
    
    	// инициализируем структуру с конфигурацией для пакета ssh. Функцию makeKeyring() напишем позднее
    	config := &ssh.ClientConfig{
    		User: os.Getenv("LOGNAME"),
    		Auth: []ssh.ClientAuth{makeKeyring()},
    	}
    
    	// запустим по одной goroutine (легковесный аналог OS thread) на сервер, функцию executeCmd() напишем позднее
    	for _, hostname := range hosts {
    		go func(hostname string) {
    			results <- executeCmd(cmd, hostname, config)
    		}(hostname)
    	}
    
    	// соберем результаты со всех серверов, или напишем "Timed out", если общее время исполнения истекло
    	for i := 0; i < len(hosts); i++ {
    		select {
    		case res := <-results:
    			fmt.Print(res)
    		case <-timeout:
    			fmt.Println("Timed out!")
    			return
    		}
    	}
    }
    


    Если не считать того, что нам нужно написать функции makeKeyring() и executeCmd(), наша программа готова! Благодаря «магии Go» мы установим соединение ко всем серверам параллельно и выполним на них заданную команду, и в любом случае завершимся через 5 секунд, напечатав на экран результаты со всех серверов, которые успели выполниться. Настолько простой способ реализации общего таймаута для всех параллельно исполняющихся операций возможен благодаря концепции каналов и наличию конструкции select, позволяющей выполнять общение одновременно между несколькими каналами: как только хотя бы одна из конструкций в case может выполниться, будет исполнен соответствующий блок кода.

    Инициализация структур данных для go.crypto/ssh


    Мы ещё не написали makeKeyring() и executeCmd(), но скорее всего ничего сильно интересного вы здесь не увидите. Будем авторизоваться только с помощью SSH-ключей, и будем предполагать, что ключи располагаются в .ssh/id_rsa или .ssh/id_dsa:

    type SignerContainer struct {
    	signers []ssh.Signer
    }
    
    func (t *SignerContainer) Key(i int) (key ssh.PublicKey, err error) {
    	if i >= len(t.signers) {
    		return
    	}
    	key = t.signers[i].PublicKey()
    	return
    }
    
    func (t *SignerContainer) Sign(i int, rand io.Reader, data []byte) (sig []byte, err error) {
    	if i >= len(t.signers) {
    		return
    	}
    	sig, err = t.signers[i].Sign(rand, data)
    	return
    }
    
    func makeSigner(keyname string) (signer ssh.Signer, err error) {
    	fp, err := os.Open(keyname)
    	if err != nil {
    		return
    	}
    	defer fp.Close()
    
    	buf, _ := ioutil.ReadAll(fp)
    	signer, _ = ssh.ParsePrivateKey(buf)
    	return
    }
    
    func makeKeyring() ssh.ClientAuth {
    	signers := []ssh.Signer{}
    	keys := []string{os.Getenv("HOME") + "/.ssh/id_rsa", os.Getenv("HOME") + "/.ssh/id_dsa"}
    
    	for _, keyname := range keys {
    		signer, err := makeSigner(keyname)
    		if err == nil {
    			signers = append(signers, signer)
    		}
    	}
    
    	return ssh.ClientAuthKeyring(&SignerContainer{signers})
    }
    


    Как можно видеть, мы возвращаем интерфейс ssh.ClientAuth, который обладает нужными методами для авторизации на сервере. Для краткости обработка ошибок почти полностью отсутствует, в production-режиме объем кода будет раза в 1,5 больше.

    Чтобы исполнить команду на сервере, код тоже весьма тривиален (обработка ошибок выкинута для краткости):

    func executeCmd(cmd, hostname string, config *ssh.ClientConfig) string {
    	conn, _ := ssh.Dial("tcp", hostname+":22", config)
    	session, _ := conn.NewSession()
    	defer session.Close()
    
    	var stdoutBuf bytes.Buffer
    	session.Stdout = &stdoutBuf
    	session.Run(cmd)
    
    	return hostname + ": " + stdoutBuf.String()
    }
    


    Для простоты и краткости мы всегда используем текущее имя пользователя для авторизации на серверах, а также 22 порт по умолчанию.

    Наша программа готова! Полный исходный текст программы находится под спойлером:
    Скрытый текст
    package main
    
    import (
    	"bytes"
    	"code.google.com/p/go.crypto/ssh"
    	"fmt"
    	"io"
    	"io/ioutil"
    	"os"
    	"time"
    )
    
    type SignerContainer struct {
    	signers []ssh.Signer
    }
    
    func (t *SignerContainer) Key(i int) (key ssh.PublicKey, err error) {
    	if i >= len(t.signers) {
    		return
    	}
    	key = t.signers[i].PublicKey()
    	return
    }
    
    func (t *SignerContainer) Sign(i int, rand io.Reader, data []byte) (sig []byte, err error) {
    	if i >= len(t.signers) {
    		return
    	}
    	sig, err = t.signers[i].Sign(rand, data)
    	return
    }
    
    func makeSigner(keyname string) (signer ssh.Signer, err error) {
    	fp, err := os.Open(keyname)
    	if err != nil {
    		return
    	}
    	defer fp.Close()
    
    	buf, _ := ioutil.ReadAll(fp)
    	signer, _ = ssh.ParsePrivateKey(buf)
    	return
    }
    
    func makeKeyring() ssh.ClientAuth {
    	signers := []ssh.Signer{}
    	keys := []string{os.Getenv("HOME") + "/.ssh/id_rsa", os.Getenv("HOME") + "/.ssh/id_dsa"}
    
    	for _, keyname := range keys {
    		signer, err := makeSigner(keyname)
    		if err == nil {
    			signers = append(signers, signer)
    		}
    	}
    
    	return ssh.ClientAuthKeyring(&SignerContainer{signers})
    }
    
    func executeCmd(cmd, hostname string, config *ssh.ClientConfig) string {
    	conn, _ := ssh.Dial("tcp", hostname+":22", config)
    	session, _ := conn.NewSession()
    	defer session.Close()
    
    	var stdoutBuf bytes.Buffer
    	session.Stdout = &stdoutBuf
    	session.Run(cmd)
    
    	return hostname + ": " + stdoutBuf.String()
    }
    
    func main() {
    	cmd := os.Args[1]
    	hosts := os.Args[2:]
    
    	results := make(chan string, 10)
    	timeout := time.After(5 * time.Second)
    	config := &ssh.ClientConfig{
    		User: os.Getenv("LOGNAME"),
    		Auth: []ssh.ClientAuth{makeKeyring()},
    	}
    
    	for _, hostname := range hosts {
    		go func(hostname string) {
    			results <- executeCmd(cmd, hostname, config)
    		}(hostname)
    	}
    
    	for i := 0; i < len(hosts); i++ {
    		select {
    		case res := <-results:
    			fmt.Print(res)
    		case <-timeout:
    			fmt.Println("Timed out!")
    			return
    		}
    	}
    }
    


    Запустим наше приложение:

    $ vim main.go # напишем программу :)
    $ go get # скачаем все зависимости
    $ time go run main.go 'hostname -f; sleep 4.7' localhost srv1 srv2
    localhost: localhost
    srv1: srv1
    Timed out!
    
    real	0m5.543s
    


    Работает! У серверов localhost, srv1 и srv2 было всего 0,3 секунды, чтобы исполнить все команды, и медленный srv2 не успел. Вместе с компиляцией программы «на лету» из исходных текстов, исполнение программы заняло 5,5 секунд, из которых 5 секунд — это наш таймаут по умолчанию на выполнение команды.

    Заключение


    Статья получилась короткой, но при этом мы написали весьма полезное приложение, которое можно спокойно использовать в production. Более продвинутую версию этого приложения мы проверили в production-окружении и она показала отличные результаты.

    Ссылки:


    1. Язык Go: golang.org
    2. Библиотека go.crypto: code.google.com/p/go/source/checkout?repo=crypto
    3. GoSSHa (SSH-proxy с общением с внешним миром через JSON): github.com/YuriyNasretdinov/GoSSHa
    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 26
    • +6
      NIH-синдром. Есть же ansible.
      • +5
        Велосипеды разные нужны, велосипеды разные важны.
        Я здесь вижу как минимум годный пример для изучения языка, во-вторых, кому-то ansible может не подойти по тем или иным причинам.
        • +5
          Ansible выглядит очень интересно, спасибо. Но, я думаю, для многих задач системы вроде chef, puppet, ansible слишком громоздки в настройке, если вам нужно всего-лишь, скажем, выполнить «svn up» на всех серверах :). Как мне кажется, системы вроде ansible предназначены больше для управления конфигурациями, а libpssh, GoSSHa подходят для, например, деплоя кода (у нас используется libpssh+uftp для деплоя кода, puppet для сишных сервисов и системных пакетов, а также самописный, в 300 строк впраппер над rsync для управления конфигурациями nginx и прочего).
          • 0
            Мы для деплоя использовали связку fabric + puppet, сейчас хотим заменить fabric на ansible, так как он умеет все то же и даже больше…
            • +1
              Из требований, что Ansible заявляет к управляемым хостам — только Python 2.4 (на деле — что-то около Python 2.6 + некоторые операции требуют дополнительные модули), но простые ssh-команды можно выполнять без дополнительной подготовки управляемых серверов.

              Но за статью все равно спасибо.
              • 0
                Автору, спасибо за статью и интересный опыт!

                С другой стороны я давно хотел попробовать Ansible из-за его «безагентности». Недавно разговаривал с ними на SCALE, очень открытые и общительные ребята.
                Моя инфраструктура — это зоопарк из Windows и Linux, поэтому puppet как нельзя лучше подходит в качестве единого средства управления конфигурациями. Эх, как бы я хотел роскошь *nix only!
            • 0
              Расскажите, пожалуйста, почему вы решили сделать свою реализацию, а не воспользовались, к примеру, code.google.com/p/parallel-ssh/?
              • +4
                Все выше можно сделать спомошью xargs.
                • +2
                  Можно. Но, например, с общим таймаутом на исполнение будет уже довольно тяжело :). А если у вас сотни или тысячи серверов, то держать постоянно запущенными тысячи процессов кажется довольно расточительным, как для CPU, так и по потреблению памяти ядра.
                  • 0
                    Общий — совсем просто, одинаковый — еще проще.
                • 0
                  Помню, на старой работе писали скрипт на php, который выполнял команды на операторских компьютерах (~250 штук) по ssh (благо везде стоял одинаковый дистр и логин/пароль на админский аккаунт). Чуть позже всё это чудо мигрировало на самописную систему клиентов и сервера, написанных на python, через которую можно было управлять машинами, обновлять приложения операторов и т.п., а также иногда втихаря издеватся над этими ж операторами (zenity рулил).

                  Эх, было время…
                  • +1
                    На прошлой работе управлял зоопарком из примерно 100 линуксовых машин с помощью консольной утилиты dsh.
                    Простой как 3 копейки и довольно удобно и параллельно.

                    Но в среде более неблагоприятной, например разные конфигурации машин, наверное понадобится что-то другое.
                  • +1
                    Неплохо. По коду бросается в глаза, что background автора — динамические языки ruby/python… )

                    А что добавлено в расширенной версии для продашна?
                    • +1
                      Неужели все так плохо :)? В GoSSHa добавлен JSON-протокол для выполнения команд, поддержка зашифрованных ключей и ssh-agent, загрузка файлов.
                      • 0
                        Без явных объявлений переменных читать код сложно. Не всегда понятно какой тип возвращает функция… )
                        • +2
                          Интересное замечание… :). Никогда об этом не задумывался, всегда опирался на здравый смысл, когда речь идет о типе возвращаемого значения из функции, слава богу в Go есть определенные соглашения, делающие интуитивное понимание типов возможным :). Но типы же при этом никуда не деваются и компилятор их проверяет, поэтому зачем писать лишние буквы :)?
                          • 0
                            Типичный подход адепта языков с динамической типизацией… )) Не буду утверждать что этот подход плохой или хороший. У всего есть достоинства и недостатки…

                            Когда вам нужно будет поправить чужой код или добавить функционал здравого смысла вам не хватит. Вам придется лезть в документацию и/или прыгать по всему коду искать определения функций и типов…
                            • 0
                              В IDE есть поддержка перехода к описанию функции, т.е. далеко лазать/прыгать не придется — горячую клавишу нажать. В IntelliJ с golang плагином например хорошо работает.
                    • 0
                      Крупный провайдер. Сегментированная сеть с кучей цисок. И одна циска центральная.

                      Обычный день, ничто не предвещает беды. Вдруг падает вся сеть. Народ в панике ищет в чём проблема. Оборачивается один сисадмин и говорит: «Я дико извиняюсь. Программировал новую циску. Стёр конфиг старый, перегрузил… Оказалось, это был центральный маршрутизатор… Окошком ошибся»

                      (с)bash
                      • 0
                        sync/WaitGrope еще сейчас популярно в дизайне таких штук на Go
                        • +1
                          В badoo разрешили писать не на PHP/C++?
                          • +1
                            В свободное от работы время любой работник компании Badoo всегда имел полное право писать на тех ЯП, на которых ему вздумается :). А если вам посчастливилось работать, скажем, в C-отделе, то на том же Go можно писать и в рабочее время, если это согласовано с руководителем.
                          • +2
                            Написал, для сравнения, то-же самое на Erlang. Запускать не пробовал.
                            В стандартной библиотеке есть полная реализация SSH сервера, клиента и SFTP.
                            main([Cmd | Hosts]) ->
                                Timeout = 5000,
                                Master = self(),
                            
                                erlang:send_after(Timeout, self(), timeout), % запускаем таймер
                            
                                %% используем генератор списков для запуска параллельной задачи на каждый хост
                                [spawn_link(fun() -> task(Host, Cmd, Master, Timeout) end) || Host <- Hosts],
                                await(length(Hosts)).
                            
                            task(Host, Cmd, Master, Timeout) ->
                                %% использует $USER, $HOME/.ssh/id_rsa по умолчанию
                                {ok, Conn} = ssh:connect(Host, 22, {}, Timeout), %тут timeout чисто номинально
                                %% SSH поддерживает мультиплексирование
                                {ok, Chan} = ssh_connection:session_channel(Conn, Timeout),
                                success = ssh_connection:exec(Conn, Chan, Cmd, Timeout),
                                Response = read_response(Conn, Chan, <<>>),
                                %% отправляем результат выполнения мастеру
                                Master ! {result, Host, Response}.
                                
                            
                            await(0) -> ok; % все результаты получены. Выходим.
                            await(N) ->
                                receive
                                    {result, Host, Response} -> % пришло сообщение от одной из задач
                                        io:format("Host: ~s; Response: ~p~n", [Host, Response]),
                                        await(N - 1); % зацикливаемся
                                    timeout -> % пришло сообщение от таймера
                                        io:format("Timeout, but ~p tasks not ready!", [N])
                                end.
                            
                            read_response(Conn, Chan, Acc) ->
                                %% рекурсивно читаем ответ команды
                                receive
                                    {ssh_cm, Conn, {data, Chan, _, Data}} ->
                                        %% цикл, собираем результат выполнения команды в аккумулятор
                                        read_response(Conn, Chan, <<Acc/binary, Data/binary>>);
                                    {ssh_cm, Conn, {closed, Chan}} ->
                                        %% Команда отработала. Разрываем рекурсию.
                                        Acc
                                end.
                            

                            Экономия строчек на SSH-ключи, но больше места занимает вычитывание ответов.
                            • +1
                              Ещё интересный доклад от Яндекса был на PyCon в прошлом году примерно на эту тему pycon.ru/2013/program/content/distributed-execution/ но там десятки тысяч серверов и вообще всё сложнее.
                              • 0
                                Да, если серверов очень много, то по CPU намного дешевле один раз открыть соединения и посылать команды на исполнение. Как ни странно, GoSSHa в 500 строк на Go позволяет это делать :). Нет возможности проверить на 10 000 серверов, но на 500 серверах latency тоже получается в районе 0,5 сек на команду.
                                • 0
                                  Ну а что, conn.NewSession() на каждую команду вроде ничего сложного. Но это не так часто нужно. Обычно нужно подключиться и выполнить одну-две команды.
                                  Но в докладе не об этом. Там именно распределённая сеть какая то используется в стиле «клиентский ПК подключается к 50 аггрегаторам, каждый из которых подключается к другим 50 аггрегаторам, каждый из которых подключен к N конечных серверов». И при этом ответы выдаются не как есть а тоже аггрегируются, мерджатся и т.п. Деталей сейчас уже не помню.

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.