Вопрос: Утекает память при использовании горутин и waitGroup, как найти корень проблемы?

Лучший ответ:
Подскажу важные хаки для вашего решения.
Когда нужно обработать n каких-то задач в m потоков, в го очень хорошо работает паттерн worker pool.
Суть в том, что создается канал, в одной горутине в него кладутся задачи и как только все задачи будут положены, канал обязательно закрывается.
Тем временем, запускается m воркеров. Воркер представляет из себя функцию, которая в цикле for читает задачи из канала и выполняет их. Удобство тут в том, что как только канал будет закрыт (и в нем не останется ни одной задачи, если он буферизированный), то все циклы for во всех воркерах завершатся. Поэтому не надо делать отдельный канал и селект для завершения.
WaitGroup при этом следит, чтобы все воркеры успели доработать до конца (потому что когда канал закрывается, какие-то воркеры еще могут завершать свои последние задачи).

Шаблон в простом виде прикладываю тут, можете его натянуть на свой случайpackage main

import (
	"fmt"
	"sync"
)

type Task struct {
	URL string
	// сюда можно еще добавить нужные данные для задачи
}

type Result struct {
	// если нам нужно что-то получать как результат обработки задачи
	// то можно результат класть сюда
}

const (
	workersCount = 5
)

func main() {
	tasks := make(chan Task)
	results := make(chan Result)

	go generateTasks(tasks)

	wg := &sync.WaitGroup{}
	for i := 0; i < workersCount; i++ {
		wg.Add(1) // обязательно инкрементим вне воркера, потому что воркер стартует не сразу
		go worker(tasks, results, wg)
	}

	go func() {
		wg.Wait()
		// wg.Wait пустит нас сюда только когда все воркеры завершатся
		// а значит, нам можно закрывать канал результатов, потому что результатов больше точно не будет
		close(results)
	}()

	for result := range results {
		// обрабатываем результаты, если нужно
		// если нам не нужно централизованное место для обработки результатов,
		// канал results и все, что с ним связано, можно убрать
		fmt.Println("got result:", result)
	}
	// программа завершится как только канал results будет закрыт
}

func generateTasks(tasks chan<- Task) { // тип chan<- значит, что в переданный сюда канал можно только писать, нельзя читать
	// тут генерируем наши задачи, читаем их из файла, например
	for i := 0; i < 20; i++ {
		tasks <- Task{
			URL: fmt.Sprintf("http://test.com?i=%d", i),
		}
	}

	// как только задачи закончились, закрываем канал
	// этим мы сигнализируем воркерам, что работы больше нет, можно завершаться
	close(tasks)
}

func worker(tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done() // defer позволяет точно не забыть сделать Done() при завершении функции
	for task := range tasks {
		fmt.Println(task.URL)
		var res Result
		// здесь пишем код, который обрабатывает задачу
		// генерируем на выходе какой-то результат (если результат нам не нужен, можем это опустить)
		results <- res
	}
}

Худший ответ:
Нашёл я в этих ваших Голангах такую вещь как pprof.
В итоге проблема оказалась в for, который принимает сообщения канала.
У него не было условия выхода/таймаута, из-за чего он все равно оставался в памяти вместе с каналом, даже после завершения работы функции.

Решение тривиальное - ещё один канал, который будет работать как сигнал выхода из цикла после заврешения всех горутин:
func GrabberProcess(grabberSchemas []types.GrabberSchema) (output types.Output) {
  // ...
  waitGroup.Wait()
+ done <- true
  return output
}

Ну и написать в принимающем данные for select и кейсы под каждый канал:
func catchingFilesChannel(output *types.Output) {
  for {
    select {
    case message := <-channel:
      for _, file := range message.Files {
        item := types.OutputItem{
          VendorName:   message.VendorName,
          BoardName:    message.Thread.Board.Name,
          SourceThread: message.SourceThread,
          File:         file,
        }
        *output = append(*output, item)
       }
+   case <-done:
+     return
    }
  }
}

Как итог, даже вызов функции в цикле с 50 итерациями: